diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8189e7a08da23c30f970e5928a3d8d2c29c13779..13e5107c30fd604d86088bed536b2f4e34ec893d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,31 +1,22 @@ -image: registry.gitlab.com/bitfireat/docker-android-emulator:latest +image: "registry.gitlab.e.foundation:5000/e/apps/docker-android-apps-cicd:latest" + +stages: +- build before_script: - - git submodule update --init --recursive - - export GRADLE_USER_HOME=`pwd`/.gradle; chmod +x gradlew +- git submodule update --init --recursive +- export GRADLE_USER_HOME=$(pwd)/.gradle +- chmod +x ./gradlew cache: + key: ${CI_PROJECT_ID} paths: - - .gradle/ - -test: - tags: - - privileged - script: - - start-emulator.sh - - ./gradlew app:check app:connectedCheck - artifacts: - paths: - - app/build/outputs/lint-results-debug.html - - app/build/reports - - build/reports + - .gradle/ -pages: +build: + stage: build script: - - ./gradlew app:dokka - - mkdir public && mv app/build/dokka public + - ./gradlew build artifacts: paths: - - public - only: - - master-ose + - app/build/outputs/apk/standard/debug/app-standard-debug.apk diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000000000000000000000000000000000..63358f26f2a666fe7832afe847c36a1fa539d1cd --- /dev/null +++ b/AUTHORS @@ -0,0 +1,3 @@ +© 2018-2019 - Author: Nihar Thakkar +© 2018-2019 - Author: Vincent Bourgmayer +© 2018-2019 - Author: Romain Hunault diff --git a/README.md b/README.md index 2a68c139c62c8475b20252d9c600261e3da6abf1..76aa1cd900272fdbc69fc26f7fe5721acd4079c8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -![DAVx⁵ logo](app/src/main/res/mipmap-xxxhdpi/ic_launcher.png) - -DAVx⁵ +Account Manager ======== +Account Manager is a fork of DAVx⁵. + Please see the [DAVx⁵ Web site](https://www.davx5.com) for comprehensive information about DAVx⁵. diff --git a/app/build.gradle b/app/build.gradle index c231dc793c57a69a03a0cf0434d89f0eb8704d04..d464b442b569324ae0b509e271b98b2e7c54fa99 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -72,6 +72,9 @@ android { } defaultConfig { + manifestPlaceholders = [ + 'appAuthRedirectScheme': 'net.openid.appauthdemo' + ] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -120,6 +123,7 @@ dependencies { implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03' implementation 'com.google.android:flexbox:1.1.0' implementation 'com.google.android.material:material:1.2.0-alpha03' + implementation 'androidx.browser:browser:1.0.0' def room_version = '2.2.2' implementation "androidx.room:room-runtime:$room_version" @@ -138,6 +142,8 @@ dependencies { implementation 'dnsjava:dnsjava:2.1.9' implementation 'org.apache.commons:commons-collections4:4.4' implementation 'org.apache.commons:commons-lang3:3.9' + implementation 'net.openid:appauth:0.7.0' + implementation 'com.google.android.material:material:1.0.0' // for tests androidTestImplementation 'androidx.test:runner:1.2.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cb08aa26022fdfbec150341fdf8f6ad016a9e8a3..1fe4e95adea71b9f54f60a7fa01582c9e92ebf1b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ @@ -53,7 +53,7 @@ android:theme="@style/AppTheme.NoActionBar"> - + @@ -75,7 +75,8 @@ @@ -194,6 +195,267 @@ android:resource="@xml/contacts"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png index 4cc81409080c7754124235d4d31c11ce7a8434f0..cafde5df9d1e89e1bdc0c9800cacbdabf7bd084a 100644 Binary files a/app/src/main/ic_launcher-web.png and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/foundation/e/accountmanager/Constants.kt b/app/src/main/java/foundation/e/accountmanager/Constants.kt index 2d12577b284629dfe9bf2ab594e8e6cf3320b813..a2caf777c6f275b126673f3a1805434116bab47c 100644 --- a/app/src/main/java/foundation/e/accountmanager/Constants.kt +++ b/app/src/main/java/foundation/e/accountmanager/Constants.kt @@ -7,11 +7,17 @@ */ package foundation.e.accountmanager +/** + * Authors: Nihar Thakkar and others + */ + object Constants { const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt() - const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours + // NOTE: Android 7 and up don't allow 2 min sync frequencies unless system frameworks are modified + const val DEFAULT_CALENDAR_SYNC_INTERVAL = 2 * 60L // 2 minutes + const val DEFAULT_CONTACTS_SYNC_INTERVAL = 15 * 60L // 15 minutes /** * Context label for [org.apache.commons.lang3.exception.ContextedException]. @@ -27,4 +33,8 @@ object Constants { */ const val EXCEPTION_CONTEXT_REMOTE_RESOURCE = "remoteResource" + const val AUTH_TOKEN_TYPE = "oauth2-access-token" + + const val EELO_SYNC_URL = "https://ecloud.global" + } diff --git a/app/src/main/java/foundation/e/accountmanager/DavService.kt b/app/src/main/java/foundation/e/accountmanager/DavService.kt index 4a4960299e1d98a0028caddafda6ff33f6fe7d0b..9b3cfbad0d7188b765082b392e14c868397f992b 100644 --- a/app/src/main/java/foundation/e/accountmanager/DavService.kt +++ b/app/src/main/java/foundation/e/accountmanager/DavService.kt @@ -28,6 +28,7 @@ import foundation.e.accountmanager.model.Collection import foundation.e.accountmanager.settings.AccountSettings import foundation.e.accountmanager.ui.DebugInfoActivity import foundation.e.accountmanager.ui.NotificationUtils +import net.openid.appauth.AuthState import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.lang.ref.WeakReference @@ -35,6 +36,10 @@ import java.util.* import java.util.logging.Level import kotlin.concurrent.thread +/** + * Authors: Nihar Thakkar and others + */ + class DavService: android.app.Service() { companion object { @@ -142,7 +147,7 @@ class DavService: android.app.Service() { val collectionDao = db.collectionDao() val service = db.serviceDao().get(serviceId) ?: throw IllegalArgumentException("Service not found") - val account = Account(service.accountName, getString(R.string.account_type)) + val account = Account(service.accountName, service.accountType) val homeSets = homeSetDao.getByService(serviceId).associateBy { it.url }.toMutableMap() val collections = collectionDao.getByService(serviceId).associateBy { it.url }.toMutableMap() @@ -272,76 +277,78 @@ class DavService: android.app.Service() { .build().use { client -> val httpClient = client.okHttpClient + val accessToken = service.authState // refresh home set list (from principal) - service.principal?.let { principalUrl -> - Logger.log.fine("Querying principal $principalUrl for home sets") - queryHomeSets(httpClient, principalUrl) - } - - // now refresh homesets and their member collections - val itHomeSets = homeSets.iterator() - while (itHomeSets.hasNext()) { - val homeSet = itHomeSets.next() - Logger.log.fine("Listing home set ${homeSet.key}") - - try { - DavResource(httpClient, homeSet.key).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> - if (!response.isSuccess()) - return@propfind - - if (relation == Response.HrefRelation.SELF) { - // this response is about the homeset itself - homeSet.value.displayName = response[DisplayName::class.java]?.displayName - homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true - } - - // in any case, check whether the response is about a useable collection - val info = Collection.fromDavResponse(response) ?: return@propfind - info.serviceId = serviceId - info.confirmed = true - Logger.log.log(Level.FINE, "Found collection", info) - - // remember usable collections - if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) || - (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type))) - collections[response.href] = info - } - } catch(e: HttpException) { - if (e.code in arrayOf(403, 404, 410)) - // delete home set only if it was not accessible (40x) - itHomeSets.remove() + service.principal?.let { principalUrl -> + Logger.log.fine("Querying principal $principalUrl for home sets") + queryHomeSets(httpClient, principalUrl) } - } - // check/refresh unconfirmed collections - val itCollections = collections.entries.iterator() - while (itCollections.hasNext()) { - val (url, info) = itCollections.next() - if (!info.confirmed) + // now refresh homesets and their member collections + val itHomeSets = homeSets.iterator() + while (itHomeSets.hasNext()) { + val homeSet = itHomeSets.next() + Logger.log.fine("Listing home set ${homeSet.key}") + try { - DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + DavResource(httpClient, homeSet.key, accessToken).propfind(1, *DAV_COLLECTION_PROPERTIES) { response, relation -> if (!response.isSuccess()) return@propfind - val collection = Collection.fromDavResponse(response) ?: return@propfind - collection.confirmed = true - - // remove unusable collections - if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) || - (service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) || - (collection.type == Collection.TYPE_WEBCAL && collection.source == null)) - itCollections.remove() + if (relation == Response.HrefRelation.SELF) { + // this response is about the homeset itself + homeSet.value.displayName = response[DisplayName::class.java]?.displayName + homeSet.value.privBind = response[CurrentUserPrivilegeSet::class.java]?.mayBind ?: true + } + + // in any case, check whether the response is about a useable collection + val info = Collection.fromDavResponse(response) ?: return@propfind + info.serviceId = serviceId + info.confirmed = true + Logger.log.log(Level.FINE, "Found collection", info) + + // remember usable collections + if ((service.type == Service.TYPE_CARDDAV && info.type == Collection.TYPE_ADDRESSBOOK) || + (service.type == Service.TYPE_CALDAV && arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(info.type))) + collections[response.href] = info } } catch(e: HttpException) { if (e.code in arrayOf(403, 404, 410)) - // delete collection only if it was not accessible (40x) - itCollections.remove() - else - throw e + // delete home set only if it was not accessible (40x) + itHomeSets.remove() } - } - } + } + + // check/refresh unconfirmed collections + val itCollections = collections.entries.iterator() + while (itCollections.hasNext()) { + val (url, info) = itCollections.next() + if (!info.confirmed) + try { + DavResource(httpClient, url, accessToken).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + if (!response.isSuccess()) + return@propfind + + val collection = Collection.fromDavResponse(response) ?: return@propfind + collection.confirmed = true + + // remove unusable collections + if ((service.type == Service.TYPE_CARDDAV && collection.type != Collection.TYPE_ADDRESSBOOK) || + (service.type == Service.TYPE_CALDAV && !arrayOf(Collection.TYPE_CALENDAR, Collection.TYPE_WEBCAL).contains(collection.type)) || + (collection.type == Collection.TYPE_WEBCAL && collection.source == null)) + itCollections.remove() + } + } catch(e: HttpException) { + if (e.code in arrayOf(403, 404, 410)) + // delete collection only if it was not accessible (40x) + itCollections.remove() + else + throw e + } + } + } + saveResults() } catch(e: InvalidAccountException) { @@ -372,4 +379,4 @@ class DavService: android.app.Service() { } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt b/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt index 808557cccd4405dbe07ff5bf9bdc0a1303a2df07..3f6f4aca3954fac3a1b7887bb4eb6f3b839f2a68 100644 --- a/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt +++ b/app/src/main/java/foundation/e/accountmanager/PackageChangedReceiver.kt @@ -22,6 +22,10 @@ import foundation.e.accountmanager.resource.LocalTaskList import foundation.e.ical4android.TaskProvider.ProviderName.OpenTasks import kotlin.concurrent.thread +/** + * Authors: Nihar Thakkar and others + */ + class PackageChangedReceiver: BroadcastReceiver() { companion object { @@ -34,11 +38,11 @@ class PackageChangedReceiver: BroadcastReceiver() { // check all accounts and (de)activate OpenTasks if a CalDAV service is defined val db = AppDatabase.getInstance(context) db.serviceDao().getByType(Service.TYPE_CALDAV).forEach { service -> - val account = Account(service.accountName, context.getString(R.string.account_type)) + val account = Account(service.accountName, service.accountType) if (tasksInstalled) { if (ContentResolver.getIsSyncable(account, OpenTasks.authority) <= 0) { ContentResolver.setIsSyncable(account, OpenTasks.authority, 1) - ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL) + ContentResolver.addPeriodicSync(account, OpenTasks.authority, Bundle(), Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) } } else ContentResolver.setIsSyncable(account, OpenTasks.authority, 0) diff --git a/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java b/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java new file mode 100644 index 0000000000000000000000000000000000000000..543c1e0771b11ec5320874b259b6eea113a88538 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/authorization/IdentityProvider.java @@ -0,0 +1,259 @@ +package foundation.e.accountmanager.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; + +/** + * Authors: Nihar Thakkar and others + * + * 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/foundation/e/accountmanager/model/AppDatabase.kt b/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt index 3a1b84669326101700aa8eee445ae15a42b9bd45..a8078a470f2fa7e8a21bfaf84f3f02067d0ab44c 100644 --- a/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/AppDatabase.kt @@ -11,6 +11,10 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import foundation.e.accountmanager.log.Logger +/** + * Authors: Nihar Thakkar and others + */ + @Suppress("ClassName") @Database(entities = [ Service::class, @@ -114,11 +118,14 @@ abstract class AppDatabase: RoomDatabase() { "CREATE TABLE service(" + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + "accountName TEXT NOT NULL," + + "authState TEXT," + + "accountType TEXT," + + "addressBookAccountType TEXT," + "type TEXT NOT NULL," + "principal TEXT DEFAULT NULL" + ")", "CREATE UNIQUE INDEX index_service_accountName_type ON service(accountName, type)", - "INSERT INTO service(id, accountName, type, principal) SELECT _id, accountName, service, principal FROM services", + "INSERT INTO service(id, accountName, authState, accountType, addressBookAccountType, type, principal) SELECT _id, accountName, authState, accountType, addressBookAccountType, service, principal FROM services", "DROP TABLE services", // migrate "homesets" to "homeset": rename columns, make id NOT NULL @@ -221,4 +228,4 @@ abstract class AppDatabase: RoomDatabase() { } } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/Collection.kt b/app/src/main/java/foundation/e/accountmanager/model/Collection.kt index 43146738caea9de9b593405f0952d4f977f8b742..ede34873c3f67c744ebaf213b6cad034eac66035 100644 --- a/app/src/main/java/foundation/e/accountmanager/model/Collection.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/Collection.kt @@ -50,7 +50,7 @@ data class Collection( var source: HttpUrl? = null, /** whether this collection has been selected for synchronization */ - var sync: Boolean = false + var sync: Boolean = true ): IdEntity() { @@ -154,4 +154,4 @@ data class Collection( fun title() = displayName ?: DavUtils.lastSegmentOfUrl(url) fun readOnly() = forceReadOnly || !privWriteContent -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt b/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt index c85d5155e581732d04658e733587cd3de4ca88a5..6196a535bbb2ac1c0d4549125201e1ff7a6fd0c5 100644 --- a/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/Credentials.kt @@ -8,14 +8,24 @@ package foundation.e.accountmanager.model +import net.openid.appauth.AuthState +import java.net.URI + +/** + * Authors: Nihar Thakkar and others + */ + class Credentials( val userName: String? = null, val password: String? = null, - val certificateAlias: String? = null + val authState: AuthState? = null, + val certificateAlias: String? = null, + val serverUri: URI? = null ) { enum class Type { UsernamePassword, + OAuth, ClientCertificate } @@ -25,14 +35,16 @@ class Credentials( type = when { !certificateAlias.isNullOrEmpty() -> Type.ClientCertificate + !userName.isNullOrEmpty() && (authState != null) -> + Type.OAuth !userName.isNullOrEmpty() && !password.isNullOrEmpty() -> Type.UsernamePassword else -> - throw IllegalArgumentException("Either username/password or certificate alias must be set") + throw IllegalArgumentException("Invalid account type/credentials") } } override fun toString() = "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)" -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/Service.kt b/app/src/main/java/foundation/e/accountmanager/model/Service.kt index 7b2a2c93824827d560c5d6bb4f92412039aa807c..acc37ba7da7e8d939e4a185822e52b913f170170 100644 --- a/app/src/main/java/foundation/e/accountmanager/model/Service.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/Service.kt @@ -17,6 +17,10 @@ data class Service( var accountName: String, var type: String, + var authState: String, + var accountType: String, + + var addressBookAccountType: String, var principal: HttpUrl? ) { @@ -25,4 +29,4 @@ data class Service( const val TYPE_CARDDAV = "carddav" } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt b/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt index e595692b784d586d0df5c589bf29e44e26231de6..55e82cd930a5ccfc6d3e3e32266e6936731c56cb 100644 --- a/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt +++ b/app/src/main/java/foundation/e/accountmanager/model/ServiceDao.kt @@ -20,6 +20,9 @@ interface ServiceDao { @Query("SELECT * FROM service WHERE type=:type") fun getByType(type: String): List + @Query("SELECT * FROM service WHERE accountName=:accountName") + fun getByAccountName(accountName: String): Service? + @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertOrReplace(service: Service): Long @@ -32,4 +35,4 @@ interface ServiceDao { @Query("UPDATE service SET accountName=:newName WHERE accountName=:oldName") fun renameAccount(oldName: String, newName: String) -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt index dc9ddba8d9bde23e2288e538e31eb99792ee3d04..8d726f65e97feaba2c259107730a818880ae828e 100644 --- a/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt +++ b/app/src/main/java/foundation/e/accountmanager/resource/LocalAddressBook.kt @@ -22,6 +22,7 @@ import foundation.e.accountmanager.DavUtils import foundation.e.accountmanager.R import foundation.e.accountmanager.log.Logger import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.AppDatabase import foundation.e.accountmanager.model.SyncState import foundation.e.vcard4android.* import java.io.ByteArrayOutputStream @@ -50,7 +51,11 @@ class LocalAddressBook( fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: Collection): LocalAddressBook { val accountManager = AccountManager.get(context) - val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) + var account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book)) + + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountName(mainAccount.name) ?: throw IllegalArgumentException("Service not found") + account = Account(accountName(mainAccount, info), service.addressBookAccountType) val userData = initialUserData(mainAccount, info.url.toString()) Logger.log.log(Level.INFO, "Creating local address book $account", userData) if (!accountManager.addAccountExplicitly(account, null, userData)) @@ -76,11 +81,17 @@ class LocalAddressBook( return addressBook } - fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context) - .getAccountsByType(context.getString(R.string.account_type_address_book)) - .map { LocalAddressBook(context, it, provider) } + fun findAll(context: Context, provider: ContentProviderClient?, mainAccount: Account?): List { + val accountManager = AccountManager.get(context) + val accounts = ArrayList() + accountManager.getAccountsByType(context.getString(R.string.account_type_eelo_address_book)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_google_address_book)).forEach { accounts.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).forEach { accounts.add(it) } + + return accounts.toTypedArray().map { LocalAddressBook(context, it, provider) } .filter { mainAccount == null || it.mainAccount == mainAccount } .toList() + } fun accountName(mainAccount: Account, info: Collection): String { val baos = ByteArrayOutputStream() @@ -106,7 +117,10 @@ class LocalAddressBook( } fun mainAccount(context: Context, account: Account): Account = - if (account.type == context.getString(R.string.account_type_address_book)) { + if (account.type == context.getString(R.string.account_type_address_book) || + account.type == context.getString(R.string.account_type_eelo_address_book) || + account.type == context.getString(R.string.account_type_google_address_book) + ) { val manager = AccountManager.get(context) Account( manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME), @@ -374,4 +388,4 @@ class LocalAddressBook( } } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt b/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt index b4440926ce26f341177dc5bbd719b276711b0fb1..f5575ba828b581c9434615fdd0c097af2013cef0 100644 --- a/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt +++ b/app/src/main/java/foundation/e/accountmanager/settings/AccountSettings.kt @@ -34,12 +34,15 @@ import foundation.e.ical4android.TaskProvider import foundation.e.ical4android.TaskProvider.ProviderName.OpenTasks import foundation.e.vcard4android.ContactsStorageException import foundation.e.vcard4android.GroupMethod +import net.openid.appauth.AuthState import okhttp3.HttpUrl import org.apache.commons.lang3.StringUtils import org.dmfs.tasks.contract.TaskContract import java.util.logging.Level /** + * Authors: Nihar Thakkar and others + * * Manages settings of an account. * * @throws InvalidAccountException on construction when the account doesn't exist (anymore) @@ -55,6 +58,8 @@ class AccountSettings( const val KEY_SETTINGS_VERSION = "version" 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) @@ -95,16 +100,27 @@ class AccountSettings( const val SYNC_INTERVAL_MANUALLY = -1L - fun initialUserData(credentials: Credentials): Bundle { + fun initialUserData(credentials: Credentials, baseURL: String?): Bundle { val bundle = Bundle(2) bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString()) when (credentials.type) { - Credentials.Type.UsernamePassword -> + Credentials.Type.UsernamePassword -> { + bundle.putString(KEY_USERNAME, credentials.userName) + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + } + Credentials.Type.OAuth -> { bundle.putString(KEY_USERNAME, credentials.userName) + bundle.putString(KEY_EMAIL_ADDRESS, credentials.userName) + bundle.putString(KEY_AUTH_STATE, credentials.authState!!.jsonSerializeString()) + } Credentials.Type.ClientCertificate -> bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) } + + if (!baseURL.isNullOrEmpty()) { + bundle.putString("oc_base_url", baseURL) + } return bundle } @@ -133,16 +149,35 @@ class AccountSettings( // authentication settings - fun credentials() = Credentials( - accountManager.getUserData(account, KEY_USERNAME), - accountManager.getPassword(account), - 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) + } } @@ -427,7 +462,7 @@ 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), Constants.DEFAULT_SYNC_INTERVAL) + setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CONTACTS_SYNC_INTERVAL) } /* Android 7.1.1 OpenTasks fix */ diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt index 29ec996e096c74e1aba276e68cc962e0494f0946..9ba80923e9b62b9f7b0a229d8d8cc5764c560d9b 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/AccountAuthenticatorService.kt @@ -37,12 +37,20 @@ class AccountAuthenticatorService: Service(), OnAccountsUpdateListener { 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 } + 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 - accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)) - .map { LocalAddressBook(context, it, null) } + 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)) diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt index 0e547ab0211c50b779007e1dfbc09d4a45fa935b..00f66a49c515c381c8f4ef3c21529761fdd826be 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarSyncManager.kt @@ -39,6 +39,8 @@ import java.util.* import java.util.logging.Level /** + * Authors: Nihar Thakkar and others + * * Synchronization manager for CalDAV collections; handles events (VEVENT) */ class CalendarSyncManager( @@ -53,7 +55,7 @@ class CalendarSyncManager( override fun prepare(): Boolean { collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) // if there are dirty exceptions for events, mark their master events as dirty, too localCollection.processDirtyExceptions() @@ -116,7 +118,7 @@ class CalendarSyncManager( if (bunch.size == 1) { val remote = bunch.first() // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().authState?.accessToken)) { resource -> resource.get(DavCalendar.MIME_ICALENDAR.toString()) { response -> // CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4] val eTag = response.header("ETag")?.let { GetETag(it).eTag } diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt index e6ba170204c479698090a02d95dfadc859198eda..b4985871431707940c0979407456ba7be64aaf0a 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/CalendarsSyncAdapterService.kt @@ -13,17 +13,24 @@ import android.content.ContentResolver import android.content.Context import android.content.SyncResult import android.os.Bundle +import android.os.AsyncTask import android.provider.CalendarContract 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.LocalCalendar import foundation.e.accountmanager.settings.AccountSettings import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService import okhttp3.HttpUrl import java.util.logging.Level +/** + * Authors: Nihar Thakkar and others + */ + class CalendarsSyncAdapterService: SyncAdapterService() { override fun syncAdapter() = CalendarsSyncAdapter(this) @@ -58,7 +65,33 @@ class CalendarsSyncAdapterService: SyncAdapterService() { for (calendar in calendars) { Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { - it.performSync() + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } } } } catch(e: Exception) { diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt index 5df902d333581f04e90bd9483e7a28e0667dd605..1aab6d06e14a5d7831446546fd9280878ff44e1e 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncAdapterService.kt @@ -20,6 +20,10 @@ import foundation.e.accountmanager.resource.LocalAddressBook import foundation.e.accountmanager.settings.AccountSettings import java.util.logging.Level +/** + * Authors: Nihar Thakkar and others + */ + class ContactsSyncAdapterService: SyncAdapterService() { companion object { diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt index 511cae93bb3e2919e33e9af0ed113262e39939f4..2875e9200f619a919ab8e0744b467a8416bf219b 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/ContactsSyncManager.kt @@ -38,6 +38,8 @@ import java.io.* import java.util.logging.Level /** + * Authors: Nihar Thakkar and others + * * Synchronization manager for CardDAV collections; handles contacts and groups. * * Group handling differs according to the {@link #groupMethod}. There are two basic methods to @@ -88,6 +90,8 @@ class ContactsSyncManager( } private val readOnly = localAddressBook.readOnly + private val accessToken: String? = accountSettings.credentials().authState?.accessToken + private var hasVCard4 = false private val groupMethod = accountSettings.getGroupMethod() @@ -110,7 +114,7 @@ class ContactsSyncManager( } collectionURL = HttpUrl.parse(localCollection.url) ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) resourceDownloader = ResourceDownloader(davCollection.location) @@ -273,7 +277,7 @@ class ContactsSyncManager( if (bunch.size == 1) { val remote = bunch.first() // only one contact, use GET - useRemote(DavResource(httpClient.okHttpClient, remote)) { resource -> + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().authState?.accessToken)) { resource -> resource.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5") { response -> // CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3] val eTag = response.header("ETag")?.let { GetETag(it).eTag } @@ -437,10 +441,17 @@ class ContactsSyncManager( val client = builder.build() try { - val response = client.okHttpClient.newCall(Request.Builder() - .get() - .url(httpUrl) - .build()).execute() + val requestBuilder = Request.Builder() + .get() + .url(httpUrl) + + if (accessToken!!.isNotEmpty()) { + requestBuilder.header("Authorization", "Bearer $accessToken") + } + + val response = client.okHttpClient.newCall(requestBuilder + .build()) + .execute() if (response.isSuccessful) return response.body()?.bytes() diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..9c40c34d93b5599eef0034838a1078063466753b --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAccountAuthenticatorService.kt @@ -0,0 +1,140 @@ +/* + * 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 foundation.e.accountmanager.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 foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.ui.setup.LoginActivity +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread +import kotlin.collections.ArrayList +import kotlin.collections.HashSet + +/** + * Authors: Nihar Thakkar and others + * + * 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 = 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) + + val eeloAccounts = ArrayList(accounts?.asList()?: emptyList()) + eeloAccounts.removeIf { it.type != getString(R.string.eelo_account_type) } + eeloAccounts.removeAll(accountManager.getAccountsByType(getString( + R.string.eelo_account_type))) + for (removedAccount in eeloAccounts) { + val intent = Intent("drive.services.ResetService") + intent.setPackage(getString(R.string.e_drive_package_name)) + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, removedAccount.name) + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, removedAccount.type) + startService(intent) + } + } + } + + + 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/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..1e928c4e53148a0a5ce0d306df6fd410a06c7ac5 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAddressBooksSyncAdapterService.kt @@ -0,0 +1,132 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.AccountActivity +import okhttp3.HttpUrl +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this) + + + class AddressBooksSyncAdapter( + context: Context + ) : SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (updateLocalAddressBooks(account, syncResult)) + for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { + Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) + val syncExtras = Bundle(extras) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) + } + + Logger.log.info("Address book sync complete") + } + + private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) + remoteAddressBooks[collection.url] = collection + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (remoteAddressBooks.isEmpty()) + Logger.log.info("No contacts permission, but no address book selected for synchronization") + else { + // no contacts permission, but address books should be synchronized -> show notification + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + notifyPermissions(intent) + } + return false + } + + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + try { + if (contactsProvider == null) { + Logger.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return false + } + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = HttpUrl.parse(addressBook.url)!! + val info = remoteAddressBooks[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + try { + Logger.log.log(Level.FINE, "Updating local address book $url", info) + addressBook.update(info) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) + } + // we already have a local address book for this remote collection, don't take into consideration anymore + remoteAddressBooks -= url + } + } + + // create new local address books + for ((_, info) in remoteAddressBooks) { + Logger.log.log(Level.INFO, "Adding local address book", info) + LocalAddressBook.create(context, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..c986a214945057d8bc7d3124f461b6c9f1a0d825 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloAppDataSyncAdapterService.kt @@ -0,0 +1,33 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloAppDataSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..299b0d30664aff47a0fac7cd06ab1f392757a40a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloCalendarsSyncAdapterService.kt @@ -0,0 +1,140 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.AsyncTask +import android.provider.CalendarContract +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.LocalCalendar +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloCalendarsSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val priorityCalendars = priorityCollections(extras) + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + .sortedByDescending { priorityCalendars.contains(it.id) } + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCalendars = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncCalendars(service.id)) { + remoteCalendars[collection.url] = collection + } + + // delete/update local calendars + val updateColors = settings.getManageCalendarColors() + for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) + calendar.name?.let { + val url = HttpUrl.parse(it)!! + val info = remoteCalendars[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local calendar $url", info) + calendar.update(info, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remoteCalendars -= url + } + } + + // create new local calendars + for ((_, info) in remoteCalendars) { + Logger.log.log(Level.INFO, "Adding local calendar", info) + LocalCalendar.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..f11de768ecad8cc523205b9b2a49b9b7fd508f0e --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloContactsSyncAdapterService.kt @@ -0,0 +1,82 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.ContactsContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloContactsSyncAdapterService: SyncAdapterService() { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun syncAdapter() = ContactsSyncAdapter(this) + + + class ContactsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val addressBook = LocalAddressBook(context, account, provider) + val accountSettings = AccountSettings(context, addressBook.mainAccount) + + // handle group method change + val groupMethod = accountSettings.getGroupMethod().name + accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod -> + if (previousGroupMethod != groupMethod) { + Logger.log.info("Group method changed, deleting all local contacts/groups") + + // delete all local contacts and groups so that they will be downloaded again + provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null) + provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null) + + // reset sync state + addressBook.syncState = null + } + } + accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + Logger.log.info("Synchronizing address book: ${addressBook.url}") + Logger.log.info("Taking settings from: ${addressBook.mainAccount}") + + ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { + it.performSync() + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e) + } + Logger.log.info("Contacts sync complete") + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..201814c43b42a9825aaf4eb00d3329af10331daf --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloEmailSyncAdapterService.kt @@ -0,0 +1,33 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..e45e105f99d136174008cc45224857f7f5046800 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloMediaSyncAdapterService.kt @@ -0,0 +1,33 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloMediaSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..097eb0b35ee861ed6cb675f74bf39484603b619a --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNotesSyncAdapterService.kt @@ -0,0 +1,33 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloNotesSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..9f5c07f1ace3fa7f62ef3c03e04eb261dc4e1c04 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloNullAuthenticatorService.kt @@ -0,0 +1,57 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import foundation.e.accountmanager.ui.AccountsActivity + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloNullAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + 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, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + 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/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..9216f202fd4c00d26a4445ea90f6ad7b8d3fd7c4 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/EeloTasksSyncAdapterService.kt @@ -0,0 +1,175 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.accountmanager.R +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.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + * + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ +class EeloTasksSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = TasksSyncAdapter(this) + + + class TasksSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val taskProvider = TaskProvider.fromProviderClient(context, provider) + + // make sure account can be seen by OpenTasks + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + + val accountSettings = AccountSettings(context, account) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val priorityTaskLists = priorityCollections(extras) + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + .sortedByDescending { priorityTaskLists.contains(it.id) } + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch (e: TaskProvider.ProviderTooOldException) { + val nm = NotificationManagerCompat.from(context) + val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName) + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + try { + val icon = context.packageManager.getApplicationIcon(e.provider.packageName) + if (icon is BitmapDrawable) + notify.setLargeIcon(icon.bitmap) + } catch(ignored: PackageManager.NameNotFoundException) {} + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) + if (intent.resolveActivity(context.packageManager) != null) + notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setAutoCancel(true) + + nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build()) + syncResult.databaseError = true + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e) + syncResult.databaseError = true + } + + Logger.log.info("Task sync complete") + } + + private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteTaskLists = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncTaskLists(service.id)) { + remoteTaskLists[collection.url] = collection + } + + // delete/update local task lists + val updateColors = settings.getManageCalendarColors() + + for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) + list.syncId?.let { + val url = HttpUrl.parse(it)!! + val info = remoteTaskLists[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local task list $url") + list.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local task list $url", info) + list.update(info, updateColors) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteTaskLists -= url + } + } + + // create new local task lists + for ((_,info) in remoteTaskLists) { + Logger.log.log(Level.INFO, "Adding local task list", info) + LocalTaskList.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..92091f56723a71b36a6cc537178825f5ede65bd6 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAccountAuthenticatorService.kt @@ -0,0 +1,158 @@ +/* + * 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 foundation.e.accountmanager.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 foundation.e.accountmanager.R +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.ui.setup.LoginActivity +import java.util.* +import java.util.logging.Level +import kotlin.concurrent.thread +import android.accounts.AccountManager +import foundation.e.accountmanager.settings.AccountSettings +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService + +/** + * Authors: Nihar Thakkar and others + * + * 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/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..3491737df4305a11884d23c09013865c551b7176 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleAddressBooksSyncAdapterService.kt @@ -0,0 +1,132 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.Manifest +import android.accounts.Account +import android.content.* +import android.content.pm.PackageManager +import android.os.Bundle +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import foundation.e.accountmanager.closeCompat +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.AppDatabase +import foundation.e.accountmanager.model.Collection +import foundation.e.accountmanager.model.Service +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.account.AccountActivity +import okhttp3.HttpUrl +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class GoogleAddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = AddressBooksSyncAdapter(this) + + + class AddressBooksSyncAdapter( + context: Context + ) : SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (updateLocalAddressBooks(account, syncResult)) + for (addressBookAccount in LocalAddressBook.findAll(context, null, account).map { it.account }) { + Logger.log.log(Level.INFO, "Running sync for address book", addressBookAccount) + val syncExtras = Bundle(extras) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) + } + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync address books", e) + } + + Logger.log.info("Address book sync complete") + } + + private fun updateLocalAddressBooks(account: Account, syncResult: SyncResult): Boolean { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CARDDAV) + + val remoteAddressBooks = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getByServiceAndSync(service.id)) + remoteAddressBooks[collection.url] = collection + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + if (remoteAddressBooks.isEmpty()) + Logger.log.info("No contacts permission, but no address book selected for synchronization") + else { + // no contacts permission, but address books should be synchronized -> show notification + val intent = Intent(context, AccountActivity::class.java) + intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + notifyPermissions(intent) + } + return false + } + + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + try { + if (contactsProvider == null) { + Logger.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return false + } + + // delete/update local address books + for (addressBook in LocalAddressBook.findAll(context, contactsProvider, account)) { + val url = HttpUrl.parse(addressBook.url)!! + val info = remoteAddressBooks[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local address book", url) + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + try { + Logger.log.log(Level.FINE, "Updating local address book $url", info) + addressBook.update(info) + } catch (e: Exception) { + Logger.log.log(Level.WARNING, "Couldn't rename address book account", e) + } + // we already have a local address book for this remote collection, don't take into consideration anymore + remoteAddressBooks -= url + } + } + + // create new local address books + for ((_, info) in remoteAddressBooks) { + Logger.log.log(Level.INFO, "Adding local address book", info) + LocalAddressBook.create(context, contactsProvider, account, info) + } + } finally { + contactsProvider?.closeCompat() + } + + return true + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..dabe32fd4e6a2e681d5c69b88ddc438a8cd3c6c2 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleCalendarsSyncAdapterService.kt @@ -0,0 +1,140 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.os.AsyncTask +import android.provider.CalendarContract +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.LocalCalendar +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.ical4android.AndroidCalendar +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class GoogleCalendarsSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val accountSettings = AccountSettings(context, account) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + if (accountSettings.getEventColors()) + AndroidCalendar.insertColors(provider, account) + else + AndroidCalendar.removeColors(provider, account) + + updateLocalCalendars(provider, account, accountSettings) + + val priorityCalendars = priorityCollections(extras) + val calendars = AndroidCalendar + .find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null) + .sortedByDescending { priorityCalendars.contains(it.id) } + for (calendar in calendars) { + Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}") + CalendarSyncManager(context, account, accountSettings, extras, authority, syncResult, calendar).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync calendars", e) + } + Logger.log.info("Calendar sync complete") + } + + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteCalendars = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncCalendars(service.id)) { + remoteCalendars[collection.url] = collection + } + + // delete/update local calendars + val updateColors = settings.getManageCalendarColors() + for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null)) + calendar.name?.let { + val url = HttpUrl.parse(it)!! + val info = remoteCalendars[url] + if (info == null) { + Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url) + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local calendar $url", info) + calendar.update(info, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remoteCalendars -= url + } + } + + // create new local calendars + for ((_, info) in remoteCalendars) { + Logger.log.log(Level.INFO, "Adding local calendar", info) + LocalCalendar.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..25298e39d280e49b6b862c0faf8442a11313eb51 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleContactsSyncAdapterService.kt @@ -0,0 +1,82 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.SyncResult +import android.os.Bundle +import android.provider.ContactsContract +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.resource.LocalAddressBook +import foundation.e.accountmanager.settings.AccountSettings +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + */ + +class GoogleContactsSyncAdapterService: SyncAdapterService() { + + companion object { + const val PREVIOUS_GROUP_METHOD = "previous_group_method" + } + + override fun syncAdapter() = ContactsSyncAdapter(this) + + + class ContactsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val addressBook = LocalAddressBook(context, account, provider) + val accountSettings = AccountSettings(context, addressBook.mainAccount) + + // handle group method change + val groupMethod = accountSettings.getGroupMethod().name + accountSettings.accountManager.getUserData(account, PREVIOUS_GROUP_METHOD)?.let { previousGroupMethod -> + if (previousGroupMethod != groupMethod) { + Logger.log.info("Group method changed, deleting all local contacts/groups") + + // delete all local contacts and groups so that they will be downloaded again + provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null) + provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null) + + // reset sync state + addressBook.syncState = null + } + } + accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod) + + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + Logger.log.info("Synchronizing address book: ${addressBook.url}") + Logger.log.info("Taking settings from: ${addressBook.mainAccount}") + + ContactsSyncManager(context, account, accountSettings, extras, authority, syncResult, provider, addressBook).use { + it.performSync() + } + } catch(e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync contacts", e) + } + Logger.log.info("Contacts sync complete") + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..2ff2a304ea0cfe8011b0f71ab4bf871c093319c7 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleEmailSyncAdapterService.kt @@ -0,0 +1,33 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.content.* +import android.os.Bundle + +/** + * Authors: Nihar Thakkar and others + */ + +class GoogleEmailSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter() = CalendarsSyncAdapter(this) + + + class CalendarsSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + // Unused + } + } + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt new file mode 100644 index 0000000000000000000000000000000000000000..9c8f0e05f845837bf412c6e568005c2dad823286 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleNullAuthenticatorService.kt @@ -0,0 +1,57 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.AbstractAccountAuthenticator +import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse +import android.accounts.AccountManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Bundle +import foundation.e.accountmanager.ui.AccountsActivity + +/** + * Authors: Nihar Thakkar and others + */ + +class GoogleNullAuthenticatorService: Service() { + + private lateinit var accountAuthenticator: AccountAuthenticator + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent?) = + accountAuthenticator.iBinder.takeIf { intent?.action == AccountManager.ACTION_AUTHENTICATOR_INTENT } + + + 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, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + 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/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt new file mode 100644 index 0000000000000000000000000000000000000000..230869675a1a7b6d2a29ca6e2c474e1a2dcaad5f --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/GoogleTasksSyncAdapterService.kt @@ -0,0 +1,175 @@ +/* + * 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 foundation.e.accountmanager.syncadapter + +import android.accounts.Account +import android.accounts.AccountManager +import android.app.PendingIntent +import android.content.* +import android.content.pm.PackageManager +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.AsyncTask +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import foundation.e.accountmanager.R +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.LocalTaskList +import foundation.e.accountmanager.settings.AccountSettings +import foundation.e.accountmanager.ui.NotificationUtils +import foundation.e.ical4android.AndroidTaskList +import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService +import okhttp3.HttpUrl +import org.dmfs.tasks.contract.TaskContract +import java.util.logging.Level + +/** + * Authors: Nihar Thakkar and others + * + * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). + */ +class GoogleTasksSyncAdapterService: SyncAdapterService() { + + override fun syncAdapter() = TasksSyncAdapter(this) + + + class TasksSyncAdapter( + context: Context + ): SyncAdapter(context) { + + override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + try { + val taskProvider = TaskProvider.fromProviderClient(context, provider) + + // make sure account can be seen by OpenTasks + if (Build.VERSION.SDK_INT >= 26) + AccountManager.get(context).setAccountVisibility(account, taskProvider.name.packageName, AccountManager.VISIBILITY_VISIBLE) + + val accountSettings = AccountSettings(context, account) + /* don't run sync if + - sync conditions (e.g. "sync only in WiFi") are not met AND + - this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions) + */ + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings)) + return + + updateLocalTaskLists(taskProvider, account, accountSettings) + + val priorityTaskLists = priorityCollections(extras) + val taskLists = AndroidTaskList + .find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null) + .sortedByDescending { priorityTaskLists.contains(it.id) } + for (taskList in taskLists) { + Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") + TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } + } + } + } catch (e: TaskProvider.ProviderTooOldException) { + val nm = NotificationManagerCompat.from(context) + val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName) + val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(context.getString(R.string.sync_error_opentasks_too_old)) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setCategory(NotificationCompat.CATEGORY_ERROR) + + try { + val icon = context.packageManager.getApplicationIcon(e.provider.packageName) + if (icon is BitmapDrawable) + notify.setLargeIcon(icon.bitmap) + } catch(ignored: PackageManager.NameNotFoundException) {} + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}")) + if (intent.resolveActivity(context.packageManager) != null) + notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setAutoCancel(true) + + nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build()) + syncResult.databaseError = true + } catch (e: Exception) { + Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e) + syncResult.databaseError = true + } + + Logger.log.info("Task sync complete") + } + + private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val db = AppDatabase.getInstance(context) + val service = db.serviceDao().getByAccountAndType(account.name, Service.TYPE_CALDAV) + + val remoteTaskLists = mutableMapOf() + if (service != null) + for (collection in db.collectionDao().getSyncTaskLists(service.id)) { + remoteTaskLists[collection.url] = collection + } + + // delete/update local task lists + val updateColors = settings.getManageCalendarColors() + + for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)) + list.syncId?.let { + val url = HttpUrl.parse(it)!! + val info = remoteTaskLists[url] + if (info == null) { + Logger.log.fine("Deleting obsolete local task list $url") + list.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.log(Level.FINE, "Updating local task list $url", info) + list.update(info, updateColors) + // we already have a local task list for this remote collection, don't take into consideration anymore + remoteTaskLists -= url + } + } + + // create new local task lists + for ((_,info) in remoteTaskLists) { + Logger.log.log(Level.INFO, "Adding local task list", info) + LocalTaskList.create(account, provider, info) + } + } + + } + +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt index a2336633db024f348192626b1793677f01ea041f..86768d8f3085eda19bcc65a455cafeb38d1cecb8 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/SyncManager.kt @@ -53,6 +53,10 @@ import java.util.logging.Level import javax.net.ssl.SSLHandshakeException import kotlin.math.min +/** + * Authors: Nihar Thakkar and others + */ + @Suppress("MemberVisibilityCanBePrivate") abstract class SyncManager, out CollectionType: LocalCollection, RemoteType: DavCollection>( val context: Context, @@ -288,7 +292,7 @@ abstract class SyncManager, out CollectionType: L if (fileName != null) { Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})") - useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote -> + useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build(), accountSettings.credentials().authState?.accessToken)) { remote -> try { remote.delete(local.eTag) {} numDeleted++ @@ -674,7 +678,10 @@ abstract class SyncManager, out CollectionType: L val contentIntent: Intent var viewItemAction: NotificationCompat.Action? = null - if (e is UnauthorizedException) { + if ((account.type == context.getString(R.string.account_type) || + account.type == context.getString(R.string.eelo_account_type) || + account.type == context.getString(R.string.google_account_type)) && + (e is UnauthorizedException || e is NotFoundException)) { contentIntent = Intent(context, SettingsActivity::class.java) contentIntent.putExtra(SettingsActivity.EXTRA_ACCOUNT, if (authority == ContactsContract.AUTHORITY) @@ -878,4 +885,4 @@ abstract class SyncManager, out CollectionType: L } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt index 3263595751688b3c982bb3dc0fbdab5cb4628775..175d65519ac839e3a8bb8bdff399d9f5f9fc4646 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncAdapterService.kt @@ -16,23 +16,28 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.AsyncTask import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import foundation.e.accountmanager.R 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.LocalTaskList import foundation.e.accountmanager.settings.AccountSettings import foundation.e.accountmanager.ui.NotificationUtils import foundation.e.ical4android.AndroidTaskList import foundation.e.ical4android.TaskProvider +import net.openid.appauth.AuthorizationService import okhttp3.HttpUrl import org.dmfs.tasks.contract.TaskContract import java.util.logging.Level /** + * Authors: Nihar Thakkar and others + * * Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}). */ class TasksSyncAdapterService: SyncAdapterService() { @@ -69,7 +74,33 @@ class TasksSyncAdapterService: SyncAdapterService() { for (taskList in taskLists) { Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]") TasksSyncManager(context, account, accountSettings, extras, authority, syncResult, taskList).use { - it.performSync() + val authState = accountSettings.credentials().authState + if (authState != null) + { + if (authState.needsTokenRefresh) + { + val tokenRequest = authState.createTokenRefreshRequest() + + AuthorizationService(context).performTokenRequest(tokenRequest, + AuthorizationService.TokenResponseCallback { tokenResponse, ex -> + authState.update(tokenResponse, ex) + accountSettings.credentials(Credentials(account.name, null, authState, null)) + it.accountSettings.credentials(Credentials(it.account.name, null, authState, null)) + object : AsyncTask() { + override fun doInBackground(vararg params: Void): Void? { + it.performSync() + return null + } + }.execute() + }) + } + else { + it.performSync() + } + } + else { + it.performSync() + } } } } catch (e: TaskProvider.ProviderTooOldException) { diff --git a/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt index 8df5f67ab5fa7e531aca79a2f1b7e5c286a7f5e4..2290f8fe6651ac5d7d0f9ce02d13bda1a7944422 100644 --- a/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/foundation/e/accountmanager/syncadapter/TasksSyncManager.kt @@ -40,6 +40,8 @@ import java.io.StringReader import java.util.logging.Level /** + * Authors: Nihar Thakkar and others + * * Synchronization manager for CalDAV collections; handles tasks (VTODO) */ class TasksSyncManager( @@ -54,7 +56,7 @@ class TasksSyncManager( override fun prepare(): Boolean { collectionURL = HttpUrl.parse(localCollection.syncId ?: return false) ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().authState?.accessToken) return true } @@ -172,4 +174,4 @@ class TasksSyncManager( override fun notifyInvalidResourceTitle(): String = context.getString(R.string.sync_invalid_task) -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt index 50c44238a2f023fbb53e9e23db8dbbe757555851..7d13c9b966dbb904f32713d552a8776239ad9e99 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/AccountListFragment.kt @@ -38,6 +38,10 @@ import foundation.e.accountmanager.ui.account.AccountActivity import kotlinx.android.synthetic.main.account_list.* import kotlinx.android.synthetic.main.account_list_item.view.* +/** + * Authors: Nihar Thakkar and others + */ + class AccountListFragment: ListFragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -162,11 +166,18 @@ class AccountListFragment: ListFragment() { override fun onAccountsUpdated(newAccounts: Array) { val context = getApplication() + + val account = ArrayList() + val accountManager = AccountManager.get(context) + accountManager.getAccountsByType(context.getString(R.string.eelo_account_type)).forEach { account.add(it) } + accountManager.getAccountsByType(context.getString(R.string.google_account_type)).forEach { account.add(it) } + accountManager.getAccountsByType(context.getString(R.string.account_type)).forEach { account.add(it) } + accounts.postValue( - AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type)) + account.toTypedArray() ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt index 0ed59fe1904b25c47c9daeb6dd4eb92a6c647b2c..85833bfcd16a2f573f1a82521afd389fc5e82d9c 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/AccountsActivity.kt @@ -26,6 +26,10 @@ import kotlinx.android.synthetic.main.accounts_content.* import kotlinx.android.synthetic.main.activity_accounts.* import kotlinx.android.synthetic.main.activity_accounts.view.* +/** + * Authors: Nihar Thakkar and others + */ + class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, SyncStatusObserver { companion object { diff --git a/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt index 1ced397dedee985d4b0eff16d473ba26c397d2ce..4bf0721868350aefc558607fa50e868871f3525b 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/DebugInfoActivity.kt @@ -55,6 +55,10 @@ import java.io.IOException import java.util.logging.Level import kotlin.concurrent.thread +/** + * Authors: Nihar Thakkar and others + */ + class DebugInfoActivity: AppCompatActivity() { companion object { @@ -290,7 +294,12 @@ class DebugInfoActivity: AppCompatActivity() { // main accounts text.append("ACCOUNTS\n") val accountManager = AccountManager.get(context) - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type))) + 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 (acct in accounts.toTypedArray()) { try { val accountSettings = AccountSettings(context, acct) text.append("Account: ${acct.name}\n" + @@ -309,8 +318,10 @@ class DebugInfoActivity: AppCompatActivity() { } catch (e: InvalidAccountException) { text.append("$acct is invalid (unsupported settings version) or does not exist\n") } + } + // address book accounts - for (acct in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) + for (acct in accounts.toTypedArray()) try { val addressBook = LocalAddressBook(context, acct, null) text.append("Address book account: ${acct.name}\n" + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt b/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt index df113138c3a94ab01e25c76ead395e1e5a135634..019a4fa9b18420acc7d0e5e457dc8894d1033542 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/DefaultAccountsDrawerHandler.kt @@ -11,24 +11,19 @@ package foundation.e.accountmanager.ui import android.app.Activity import android.content.Context import android.content.Intent -import android.net.Uri import android.view.Menu import android.view.MenuItem import android.widget.Toast -import foundation.e.accountmanager.App -import foundation.e.accountmanager.BuildConfig import foundation.e.accountmanager.R -class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { - - companion object { - private const val BETA_FEEDBACK_URI = "mailto:play@bitfire.at?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})" - } +/** + * Authors: Nihar Thakkar and others + */ +class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { override fun initMenu(context: Context, menu: Menu) { - if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc")) - menu.findItem(R.id.nav_beta_feedback).isVisible = true + // TODO Provide option for beta feedback } override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean { @@ -37,31 +32,6 @@ class DefaultAccountsDrawerHandler: IAccountsDrawerHandler { activity.startActivity(Intent(activity, AboutActivity::class.java)) R.id.nav_app_settings -> activity.startActivity(Intent(activity, AppSettingsActivity::class.java)) - R.id.nav_beta_feedback -> - if (!UiUtils.launchUri(activity, Uri.parse(BETA_FEEDBACK_URI), Intent.ACTION_SENDTO, false)) - Toast.makeText(activity, R.string.install_email_client, Toast.LENGTH_LONG).show() - R.id.nav_twitter -> - UiUtils.launchUri(activity, - Uri.parse("https://twitter.com/" + activity.getString(R.string.twitter_handle))) - R.id.nav_website -> - UiUtils.launchUri(activity, - App.homepageUrl(activity)) - R.id.nav_manual -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("manual").build()) - R.id.nav_faq -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("faq").build()) - R.id.nav_forums -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("forums").build()) - R.id.nav_donate -> - //if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY) - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("donate").build()) - R.id.nav_privacy -> - UiUtils.launchUri(activity, - App.homepageUrl(activity).buildUpon().appendPath("privacy").build()) else -> return false } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt index 692dea35c23bd66c445555a32275d506ac248431..a6993008166367a3894ad90d040fef9bacfb2841 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/account/SettingsActivity.kt @@ -10,6 +10,7 @@ package foundation.e.accountmanager.ui.account import android.Manifest import android.accounts.Account +import android.accounts.AccountManager import android.app.Application import android.content.ContentResolver import android.content.Intent @@ -91,6 +92,13 @@ class SettingsActivity: AppCompatActivity() { addPreferencesFromResource(R.xml.settings_account) } + private fun launchSetup():Boolean { + AccountManager.get(context).addAccount(getString(R.string.google_account_type), + null, null, null, activity, null, + null) + return true + } + private fun initSettings() { //val accountSettings = AccountSettings(requireActivity(), account) @@ -212,12 +220,14 @@ class SettingsActivity: AppCompatActivity() { }) // preference group: authentication + val prefCredentials = findPreference("credentials")!! val prefUserName = findPreference("username")!! val prefPassword = findPreference("password")!! val prefCertAlias = findPreference("certificate_alias")!! model.credentials.observe(this, Observer { credentials -> when (credentials.type) { Credentials.Type.UsernamePassword -> { + prefCredentials.isVisible = false prefUserName.isVisible = true prefUserName.summary = credentials.userName prefUserName.text = credentials.userName @@ -235,6 +245,7 @@ class SettingsActivity: AppCompatActivity() { prefCertAlias.isVisible = false } Credentials.Type.ClientCertificate -> { + prefCredentials.isVisible = false prefUserName.isVisible = false prefPassword.isVisible = false @@ -247,6 +258,13 @@ class SettingsActivity: AppCompatActivity() { true } } + Credentials.Type.OAuth -> { + prefCredentials.isVisible = true + prefCredentials.setOnPreferenceClickListener{launchSetup()} + prefUserName.isVisible = false + prefPassword.isVisible = false + prefCertAlias.isVisible = false + } } }) diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt index 40a9da0a36be2f5785e7f7c593905213494bbeb3..17fee7017a0b9f4dcb3365dffde4208e3e1425d2 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/AccountDetailsFragment.kt @@ -9,7 +9,9 @@ package foundation.e.accountmanager.ui.setup import android.accounts.Account +import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager +import android.app.Activity import android.app.Application import android.content.ContentResolver import android.content.Intent @@ -35,10 +37,15 @@ import foundation.e.accountmanager.settings.AccountSettings import foundation.e.accountmanager.settings.Settings import foundation.e.ical4android.TaskProvider import foundation.e.vcard4android.GroupMethod +import kotlinx.android.synthetic.main.login_account_details.view.* import com.google.android.material.snackbar.Snackbar import java.util.logging.Level import kotlin.concurrent.thread +/** + * Authors: Nihar Thakkar and others + */ + class AccountDetailsFragment: Fragment() { private lateinit var loginModel: LoginModel @@ -91,10 +98,43 @@ class AccountDetailsFragment: Fragment() { config, GroupMethod.valueOf(groupMethodName) ).observe(this, Observer { success -> - if (success) + if (success) { requireActivity().finish() - else { + if (activity!!.intent.hasExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) && activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) != null) { + activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE).onResult(null) + } + if (activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_EELO) { + val intent = Intent("drive.services.InitializerService") + intent.setPackage(getString(R.string.e_drive_package_name)) + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, view!!.account_name.text.toString()) + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, getString(R.string.eelo_account_type)) + activity!!.startService(intent) + } + + } else { Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() + + if (activity!!.intent.hasExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) && activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) != null) { + activity!!.intent + .getParcelableExtra(AccountManager + .KEY_ACCOUNT_AUTHENTICATOR_RESPONSE).onResult(null) + } + + if (activity!!.intent.getStringExtra(LoginActivity.SETUP_ACCOUNT_PROVIDER_TYPE) == LoginActivity.ACCOUNT_PROVIDER_EELO) { + val intent = Intent("drive.services.InitializerService") + intent.setPackage(getString(R.string.e_drive_package_name)) + intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, view!!.account_name.text.toString()) + intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, getString(R.string.eelo_account_type)) + activity!!.startService(intent) + } v.createAccountProgress.visibility = View.GONE v.createAccount.visibility = View.VISIBLE @@ -130,17 +170,64 @@ class AccountDetailsFragment: Fragment() { val result = MutableLiveData() val context = getApplication() thread { - val account = Account(name, context.getString(R.string.account_type)) + var accountType = context!!.getString(R.string.account_type) + var addressBookAccountType = context!!.getString(R.string.account_type_address_book) + + var baseURL : String? = null + if (config.calDAV != null) { + baseURL = config.calDAV.principal.toString() + } + + val intent = Intent(context, LoginActivity::class.java) + when (intent.getStringExtra("SETUP_ACCOUNT_PROVIDER_TYPE")) { + LoginActivity.ACCOUNT_PROVIDER_EELO -> { + accountType = context!!.getString(R.string.eelo_account_type) + addressBookAccountType = context!!.getString(R.string.account_type_eelo_address_book) + baseURL = credentials.serverUri.toString() + } + LoginActivity.ACCOUNT_PROVIDER_GOOGLE -> { + accountType = context!!.getString(R.string.google_account_type) + addressBookAccountType = context!!.getString(R.string.account_type_google_address_book) + baseURL = null + } + } + + val account = Account(credentials.userName, accountType) // create Android account - val userData = AccountSettings.initialUserData(credentials) + val userData = AccountSettings.initialUserData(credentials, baseURL) Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) val accountManager = AccountManager.get(context) - if (!accountManager.addAccountExplicitly(account, credentials.password, userData)) { - result.postValue(false) - return@thread - } + + if (!accountManager.addAccountExplicitly(account, credentials.password, userData)) { + if (accountType == context.getString(R.string.google_account_type)) { + for (googleAccount in accountManager.getAccountsByType(context.getString( + R.string.google_account_type))) { + if (userData.get(AccountSettings.KEY_EMAIL_ADDRESS) == accountManager + .getUserData(account, AccountSettings.KEY_EMAIL_ADDRESS)) { + accountManager.setUserData(googleAccount, AccountSettings.KEY_AUTH_STATE, + userData.getString(AccountSettings.KEY_AUTH_STATE)) + } + } + } + else { + return@thread + } + } + + if (!credentials.authState?.accessToken.isNullOrEmpty()) { + accountManager.setAuthToken(account, Constants.AUTH_TOKEN_TYPE, credentials.authState!!.accessToken) + } + + if (!credentials.password.isNullOrEmpty()) { + accountManager.setPassword(account, credentials.password) + } + + ContentResolver.setSyncAutomatically(account, context.getString(R.string.notes_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.email_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.media_authority), true) + ContentResolver.setSyncAutomatically(account, context.getString(R.string.app_data_authority), true) // add entries for account to service DB Logger.log.log(Level.INFO, "Writing account configuration to database", config) @@ -154,7 +241,7 @@ class AccountDetailsFragment: Fragment() { if (config.cardDAV != null) { // insert CardDAV service - val id = insertService(db, name, Service.TYPE_CARDDAV, config.cardDAV) + val id = insertService(db, credentials.userName!!, credentials.authState!!.jsonSerializeString(), accountType, addressBookAccountType, Service.TYPE_CARDDAV, config.cardDAV) // initial CardDAV account settings accountSettings.setGroupMethod(groupMethod) @@ -164,26 +251,26 @@ class AccountDetailsFragment: Fragment() { context.startService(refreshIntent) // contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_address_books.xml - accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_SYNC_INTERVAL) + accountSettings.setSyncInterval(context.getString(R.string.address_books_authority), Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) } else ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) if (config.calDAV != null) { // insert CalDAV service - val id = insertService(db, name, Service.TYPE_CALDAV, config.calDAV) + val id = insertService(db, credentials.userName!!, credentials.authState!!.jsonSerializeString(), accountType, addressBookAccountType, Service.TYPE_CALDAV, config.calDAV) // start CalDAV service detection (refresh collections) refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) context.startService(refreshIntent) // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_calendars.xml - accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL) + accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) // enable task sync if OpenTasks is installed // further changes will be handled by PackageChangedReceiver if (LocalTaskList.tasksProviderAvailable(context)) { ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1) - accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL) + accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_CALENDAR_SYNC_INTERVAL) } } else { ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) @@ -200,9 +287,9 @@ class AccountDetailsFragment: Fragment() { return result } - private fun insertService(db: AppDatabase, accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + private fun insertService(db: AppDatabase, accountName: String, authState: String, accountType: String, addressBookAccountType: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { // insert service - val service = Service(0, accountName, type, info.principal) + val service = Service(0, accountName, authState, accountType, addressBookAccountType, type, info.principal) val serviceId = db.serviceDao().insertOrReplace(service) // insert home sets @@ -223,4 +310,4 @@ class AccountDetailsFragment: Fragment() { } -} \ No newline at end of file +} diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt index f3622dfb7443e6b3a59eefba891b2565e389518c..148ebbe8cc9b4c382cbf94e64a617878fee13bb0 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DavResourceFinder.kt @@ -31,6 +31,10 @@ import java.util.* import java.util.logging.Level import java.util.logging.Logger +/** + * Authors: Nihar Thakkar and others + */ + class DavResourceFinder( val context: Context, private val loginModel: LoginModel @@ -145,12 +149,13 @@ class DavResourceFinder( if (config.principal != null && service == Service.CALDAV) // query email address (CalDAV scheduling: calendar-user-address-set) try { - DavResource(httpClient.okHttpClient, config.principal!!, log).propfind(0, CalendarUserAddressSet.NAME) { response, _ -> + 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)) + log.info("myenail: ${uri.schemeSpecificPart}") config.email = uri.schemeSpecificPart } catch(e: URISyntaxException) { log.log(Level.WARNING, "Couldn't parse user address", e) @@ -173,7 +178,7 @@ 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!!.authState!!.accessToken!!, log) try { when (service) { Service.CARDDAV -> { @@ -186,7 +191,7 @@ class DavResourceFinder( } } Service.CALDAV -> { - davBase.propfind(0, + davBase.propfind(1, ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME, CalendarHomeSet.NAME, CurrentUserPrincipal.NAME @@ -296,7 +301,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!!.authState!!.accessToken!!, log).options { capabilities, _ -> if ((service == Service.CARDDAV && capabilities.contains("addressbook")) || (service == Service.CALDAV && capabilities.contains("calendar-access"))) provided = true @@ -382,7 +387,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!!.authState!!.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") diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt index b5a0cdfdfb0c4d2972ef01aa2415513b157e3353..53283baafd11254d288f9b5645b3a3e1e59768e7 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DefaultLoginCredentialsFragment.kt @@ -145,7 +145,7 @@ class DefaultLoginCredentialsFragment: Fragment() { } if (valid) - loginModel.credentials = Credentials(null, null, alias) + loginModel.credentials = Credentials(null, null, null, alias) } } diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt index 6fb468413d618581305e4ac7846089ecaa596f90..ae38a73bb799112f412501b25f0225e2b2214432 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/DetectConfigurationFragment.kt @@ -26,6 +26,10 @@ import java.lang.ref.WeakReference import java.util.logging.Level import kotlin.concurrent.thread +/** + * Authors: Nihar Thakkar and others + */ + class DetectConfigurationFragment: Fragment() { private lateinit var loginModel: LoginModel diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..1393d6f0d4d2c3e95f8fa0fa08fd5855f9c74382 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorFragment.kt @@ -0,0 +1,180 @@ +package foundation.e.accountmanager.ui.setup + +import android.app.AlertDialog +import android.content.Context +import android.os.* +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.net.ConnectivityManager + +import foundation.e.accountmanager.R +import androidx.lifecycle.ViewModelProviders +import org.json.JSONObject +import android.widget.Toast +import android.net.Uri +import android.widget.EditText +import android.widget.LinearLayout +import foundation.e.accountmanager.databinding.FragmentEeloAuthenticatorBinding +import foundation.e.dav4jvm.Constants +import java.net.IDN +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import foundation.e.accountmanager.model.Credentials +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.* +import kotlinx.android.synthetic.main.fragment_eelo_authenticator.view.* + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloAuthenticatorFragment : Fragment() { + + private lateinit var model: EeloAuthenticatorModel + private lateinit var loginModel: LoginModel + + val TOGGLE_BUTTON_CHECKED_KEY = "toggle_button_checked" + var toggleButtonState = false + + 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(EeloAuthenticatorModel::class.java) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + + val v = FragmentEeloAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + v.root.expand_collapse_button.setOnClickListener() { expandCollapse() } + + v.root.urlpwd_user_name.setOnFocusChangeListener() { v, hasFocus -> + if (!hasFocus ) { + if (v.urlpwd_user_name.text.toString().contains("@")) { + val dns = v.urlpwd_user_name.text.toString().substringAfter("@") + val pre_custom_url = "https://" + dns + v.urlpwd_server_uri.setText(pre_custom_url) + } else { + v.urlpwd_server_uri.setText("") + } + } + } + + v.login.setOnClickListener { login() } + + // code below is to draw toggle button in its correct state and show or hide server url input field + //add by Vincent, 18/02/2019 + if(savedInstanceState != null){ + toggleButtonState = savedInstanceState.getBoolean(TOGGLE_BUTTON_CHECKED_KEY, false) + } + + if(toggleButtonState == true) { + v.root.expand_collapse_button.setChecked(toggleButtonState) + v.root.urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + v.root.urlpwd_server_uri.setEnabled(true) + }else{ + v.root.urlpwd_server_uri_layout.setVisibility(View.GONE) + v.root.urlpwd_server_uri.setEnabled(false) + } + + return v.root + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(TOGGLE_BUTTON_CHECKED_KEY, toggleButtonState) + super.onSaveInstanceState(outState) + } + + private fun login() { + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + if ((urlpwd_user_name.text.toString() != "") && (urlpwd_password.text.toString() != "")) { + if (validate()) + requireFragmentManager().beginTransaction() + .replace(android.R.id.content, DetectConfigurationFragment(), null) + .addToBackStack(null) + .commit() + } else { + Toast.makeText(context, "Please enter a valid username and password", Toast.LENGTH_LONG).show() + } + } + + private fun validate(): Boolean { + var valid = false + + var serverUrl = model.baseUrl.value + + fun validateUrl() { + + if(toggleButtonState == true) { + serverUrl = view!!.urlpwd_server_uri.text.toString(); + } else { + serverUrl = model.baseUrl.value + } + + model.baseUrlError.value = null + try { + val uri = URI(serverUrl) + 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() + + val userName = view!!.urlpwd_user_name.text.toString() + val password = view!!.urlpwd_password.text.toString() + + if (loginModel.baseURI != null) { + valid = true + loginModel.credentials = Credentials(userName.toLowerCase(), password, null, null) + } + } + + } + + return valid + } + + override fun onDestroy() { + super.onDestroy() + } + + /** + * Show/Hide panel containing server's uri input field. + */ + private fun expandCollapse(){ + if(expand_collapse_button.isChecked) { + view!!.urlpwd_server_uri_layout.setVisibility(View.VISIBLE) + view!!.urlpwd_server_uri.setEnabled(true) + toggleButtonState = true; + } + else { + view!!.urlpwd_server_uri_layout.setVisibility(View.GONE) + view!!.urlpwd_server_uri.setEnabled(false) + toggleButtonState = false; + } + } + + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..693445351dbed70deb6fe7df18c1f8964252ad6e --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/EeloAuthenticatorModel.kt @@ -0,0 +1,54 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +/** + * Authors: Nihar Thakkar and others + */ + +class EeloAuthenticatorModel: 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 = foundation.e.accountmanager.Constants.EELO_SYNC_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/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..9b3a4a3409956fe3a708ca888f54faa474e738dd --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorFragment.kt @@ -0,0 +1,401 @@ +package foundation.e.accountmanager.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 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 android.net.ConnectivityManager +import android.widget.Toast + +/** + * Authors: Nihar Thakkar and others + */ + +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) + + if (!isNetworkAvailable()) { + Toast.makeText(context, "Please check your internet connection", Toast.LENGTH_LONG).show() + activity!!.finish() + } + + // Initialise the authorization service + authorizationService = AuthorizationService(context!!) + + val v = FragmentGoogleAuthenticatorBinding.inflate(inflater, container, false) + v.lifecycleOwner = this + v.model = model + + activity?.intent?.let { + if (!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 { + 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) + } + } + } + else { + val response = AuthorizationResponse.fromIntent(activity!!.intent) + val ex = AuthorizationException.fromIntent(activity!!.intent) + authState = AuthState(response, ex) + + if (response != null) { + exchangeAuthorizationCode(response) + } + 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()) + 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(): Boolean { + var valid = false + + fun validateUrl() { + model.baseUrlError.value = null + try { + val uri = URI(model.baseUrl.value.orEmpty()) + 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() + + val email = model.emailAddress.value + + if (loginModel.baseURI != null) { + valid = true + loginModel.credentials = Credentials(email, null, authState!!, null) + } + } + + } + + return valid + } + + override fun onDestroy() { + super.onDestroy() + authorizationService?.dispose() + } + + +} + diff --git a/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..c6dc6af32e74ce4c02a6c4c4062a6634b0ef2838 --- /dev/null +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/GoogleAuthenticatorModel.kt @@ -0,0 +1,54 @@ +package foundation.e.accountmanager.ui.setup + +import android.content.Intent +import android.net.Uri +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel + +/** + * Authors: Nihar Thakkar and others + */ + +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 = "https://apidata.googleusercontent.com/caldav/v2/$emailAddress/user" + 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/foundation/e/accountmanager/ui/setup/LoginActivity.kt b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt index 9fa991a2ceb291e6760591d6c82d9366c63d6e15..ec7e415b5db10a373483b434b380f33a5125cedd 100644 --- a/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt +++ b/app/src/main/java/foundation/e/accountmanager/ui/setup/LoginActivity.kt @@ -9,17 +9,17 @@ package foundation.e.accountmanager.ui.setup import android.os.Bundle -import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import foundation.e.accountmanager.App -import foundation.e.accountmanager.R import foundation.e.accountmanager.log.Logger import foundation.e.accountmanager.ui.UiUtils import java.util.* /** + * Authors: Nihar Thakkar and others + * * Activity to initially connect to a server and create an account. * Fields for server/user data can be pre-filled with extras in the Intent. */ @@ -42,6 +42,11 @@ 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" } private val loginFragmentLoader = ServiceLoader.load(ILoginCredentialsFragment::class.java)!! @@ -57,22 +62,36 @@ class LoginActivity: AppCompatActivity() { fragment = fragment ?: factory.getFragment(intent) if (fragment != null) { - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, fragment) - .commit() + when (intent.getStringExtra(SETUP_ACCOUNT_PROVIDER_TYPE)) { + ACCOUNT_PROVIDER_EELO -> { + supportFragmentManager.beginTransaction() + .replace(android.R.id.content, EeloAuthenticatorFragment()) + .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") } } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.activity_login, menu) - return true - } - - fun showHelp(item: MenuItem) { - UiUtils.launchUri(this, - App.homepageUrl(this).buildUpon().appendPath("tested-with").build()) + override fun onOptionsItemSelected(item: MenuItem?): Boolean { + when { + item?.itemId == android.R.id.home -> { + finish() + return true + } + } + return false } } diff --git a/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png b/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png new file mode 100644 index 0000000000000000000000000000000000000000..be7f21f840662fa3658879dd7a963de5640f4d31 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_account_provider_eelo.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png b/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png new file mode 100644 index 0000000000000000000000000000000000000000..2154694f964da71ead68a6ff4baa784a21e76a03 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_account_provider_google.png differ diff --git a/app/src/main/res/drawable/custom_url_button_value.xml b/app/src/main/res/drawable/custom_url_button_value.xml new file mode 100644 index 0000000000000000000000000000000000000000..fd4b1bbeb6a59950bf42ef0f6afd2ed27ec4e7d0 --- /dev/null +++ b/app/src/main/res/drawable/custom_url_button_value.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_expand_less.xml b/app/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000000000000000000000000000000000000..30a106fec9749c057e507837c9bf7a2dcb31e5dc --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_expand_more.xml b/app/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000000000000000000000000000000000000..77316f76e6b5c438b29976c1e704168e2d63c837 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_eelo_authenticator.xml b/app/src/main/res/layout/fragment_eelo_authenticator.xml new file mode 100644 index 0000000000000000000000000000000000000000..ce3f1c152ee52d7b7bf90d9d1c080c562687b299 --- /dev/null +++ b/app/src/main/res/layout/fragment_eelo_authenticator.xml @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +