diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 282c54b2c73c103f118fca9cf5320a9c67926985..e7a8de14bc19091d4bf8d8cbba1d0ff152aafe09 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -103,6 +103,7 @@ 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/at/bitfire/davdroid/DavService.kt b/app/src/main/java/at/bitfire/davdroid/DavService.kt index 29cf4d82f5953cc7eb801fd06e7470753d547dd6..fd6c3b68a070799c9262eabd689688ff0e89c572 100644 --- a/app/src/main/java/at/bitfire/davdroid/DavService.kt +++ b/app/src/main/java/at/bitfire/davdroid/DavService.kt @@ -1,118 +1,84 @@ -/*************************************************************************************************** - * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. - **************************************************************************************************/ - -package at.bitfire.davdroid - import android.accounts.Account -import android.app.IntentService import android.app.PendingIntent import android.content.ContentResolver -import android.content.Context import android.content.Intent import android.os.Binder import android.os.Bundle -import androidx.annotation.WorkerThread import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.room.Transaction +import at.bitfire.davdroid.ui.NotificationUtils import foundation.e.dav4jvm.DavResource import foundation.e.dav4jvm.Response import foundation.e.dav4jvm.UrlUtils import foundation.e.dav4jvm.exception.HttpException import foundation.e.dav4jvm.property.* -import at.bitfire.davdroid.db.* -import at.bitfire.davdroid.db.Collection -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.settings.AccountSettings -import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.settings.SettingsManager +import foundation.e.accountmanager.log.Logger +import foundation.e.accountmanager.model.* + +import at.bitfire.davdroid.ui.settings.AccountSettings import at.bitfire.davdroid.ui.DebugInfoActivity -import at.bitfire.davdroid.ui.NotificationUtils -import dagger.hilt.android.AndroidEntryPoint + import okhttp3.HttpUrl import okhttp3.OkHttpClient import java.lang.ref.WeakReference import java.util.* import java.util.logging.Level -import javax.inject.Inject -import kotlin.collections.* +import kotlin.concurrent.thread -@Suppress("DEPRECATION") -@AndroidEntryPoint -class DavService: IntentService("DavService") { +class DavService: android.app.Service() { companion object { - const val ACTION_REFRESH_COLLECTIONS = "refreshCollections" const val EXTRA_DAV_SERVICE_ID = "davServiceID" /** Initialize a forced synchronization. Expects intent data - to be an URI of this format: - contents://// + to be an URI of this format: + contents://// **/ const val ACTION_FORCE_SYNC = "forceSync" val DAV_COLLECTION_PROPERTIES = arrayOf( - ResourceType.NAME, - CurrentUserPrivilegeSet.NAME, - DisplayName.NAME, - Owner.NAME, - AddressbookDescription.NAME, SupportedAddressData.NAME, - CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, - Source.NAME + ResourceType.NAME, + CurrentUserPrivilegeSet.NAME, + DisplayName.NAME, + AddressbookDescription.NAME, SupportedAddressData.NAME, + CalendarDescription.NAME, CalendarColor.NAME, SupportedCalendarComponentSet.NAME, + Source.NAME ) - fun refreshCollections(context: Context, serviceId: Long) { - val intent = Intent(context, DavService::class.java) - intent.action = DavService.ACTION_REFRESH_COLLECTIONS - intent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, serviceId) - context.startService(intent) - } - } - @Inject lateinit var db: AppDatabase - @Inject lateinit var settings: SettingsManager + private val runningRefresh = HashSet() + private val refreshingStatusListeners = LinkedList>() - /** - * List of [Service] IDs for which the collections are currently refreshed - */ - private val runningRefresh = Collections.synchronizedSet(HashSet()) - /** - * Currently registered [RefreshingStatusListener]s, which will be notified - * when a collection refresh status changes - */ - private val refreshingStatusListeners = Collections.synchronizedList(LinkedList>()) + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + intent?.let { + val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) - @WorkerThread - override fun onHandleIntent(intent: Intent?) { - if (intent == null) - return - - val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1) - - when (intent.action) { - ACTION_REFRESH_COLLECTIONS -> - if (runningRefresh.add(id)) { - refreshingStatusListeners.forEach { listener -> - listener.get()?.onDavRefreshStatusChanged(id, true) + when (intent.action) { + ACTION_REFRESH_COLLECTIONS -> + if (runningRefresh.add(id)) { + refreshingStatusListeners.forEach { listener -> + listener.get()?.onDavRefreshStatusChanged(id, true) + } + thread { refreshCollections(id) } } - refreshCollections(id) - } - - ACTION_FORCE_SYNC -> { - val uri = intent.data!! - val authority = uri.authority!! - val account = Account( + ACTION_FORCE_SYNC -> { + val uri = intent.data!! + val authority = uri.authority!! + val account = Account( uri.pathSegments[1], uri.pathSegments[0] - ) - forceSync(authority, account) + ) + forceSync(authority, account) + } } - } + + return START_NOT_STICKY } @@ -132,17 +98,14 @@ class DavService: IntentService("DavService") { fun addRefreshingStatusListener(listener: RefreshingStatusListener, callImmediateIfRunning: Boolean) { refreshingStatusListeners += WeakReference(listener) if (callImmediateIfRunning) - synchronized(runningRefresh) { - for (id in runningRefresh) - listener.onDavRefreshStatusChanged(id, true) - } + runningRefresh.forEach { id -> listener.onDavRefreshStatusChanged(id, true) } } fun removeRefreshingStatusListener(listener: RefreshingStatusListener) { val iter = refreshingStatusListeners.iterator() while (iter.hasNext()) { val item = iter.next().get() - if (item == listener || item == null) + if (listener == item) iter.remove() } } @@ -165,8 +128,7 @@ class DavService: IntentService("DavService") { } private fun refreshCollections(serviceId: Long) { - val syncAllCollections = settings.getBoolean(Settings.SYNC_ALL_COLLECTIONS) - + val db = AppDatabase.getInstance(this) val homeSetDao = db.homeSetDao() val collectionDao = db.collectionDao() @@ -179,18 +141,11 @@ class DavService: IntentService("DavService") { /** * Checks if the given URL defines home sets and adds them to the home set list. * - * @param personal Whether this is the "outer" call of the recursion. - * - * *true* = found home sets belong to the current-user-principal; recurse if - * calendar proxies or group memberships are found - * - * *false* = found home sets don't directly belong to the current-user-principal; don't recurse - * * @throws java.io.IOException * @throws HttpException - * @throws at.bitfire.dav4jvm.exception.DavException + * @throws foundation.e.dav4jvm.exception.DavException */ - fun queryHomeSets(client: OkHttpClient, url: HttpUrl, personal: Boolean = true) { + fun queryHomeSets(client: OkHttpClient, url: HttpUrl, accessToken: String, recurse: Boolean = true) { val related = mutableSetOf() fun findRelated(root: HttpUrl, dav: Response) { @@ -223,7 +178,7 @@ class DavService: IntentService("DavService") { } } - val dav = DavResource(client, url) + val dav = DavResource(client, url, accessToken) when (service.type) { Service.TYPE_CARDDAV -> try { @@ -232,11 +187,11 @@ class DavService: IntentService("DavService") { for (href in homeSet.hrefs) dav.location.resolve(href)?.let { val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) + homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) } } - if (personal) + if (recurse) findRelated(dav.location, response) } } catch (e: HttpException) { @@ -252,11 +207,11 @@ class DavService: IntentService("DavService") { for (href in homeSet.hrefs) dav.location.resolve(href)?.let { val foundUrl = UrlUtils.withTrailingSlash(it) - homeSets[foundUrl] = HomeSet(0, service.id, personal, foundUrl) + homeSets[foundUrl] = HomeSet(0, service.id, foundUrl) } } - if (personal) + if (recurse) findRelated(dav.location, response) } } catch (e: HttpException) { @@ -268,157 +223,140 @@ class DavService: IntentService("DavService") { } } - // query related homesets (those that do not belong to the current-user-principal) for (resource in related) - queryHomeSets(client, resource, false) + queryHomeSets(client, resource, accessToken, false) } + @Transaction fun saveHomesets() { - // syncAll sets the ID of the new homeset to the ID of the old one when the URLs are matching DaoTools(homeSetDao).syncAll( - homeSetDao.getByService(serviceId), - homeSets, - { it.url }) + homeSetDao.getByService(serviceId), + homeSets, + { it.url }) } + @Transaction fun saveCollections() { - // syncAll sets the ID of the new collection to the ID of the old one when the URLs are matching DaoTools(collectionDao).syncAll( - collectionDao.getByService(serviceId), - collections, { it.url }) { new, old -> - // use old settings of "force read only" and "sync", regardless of detection results + collectionDao.getByService(serviceId), + collections, { it.url }) { new, old -> new.forceReadOnly = old.forceReadOnly new.sync = old.sync } } + fun saveResults() { + saveHomesets() + saveCollections() + } + try { Logger.log.info("Refreshing ${service.type} collections of service #$service") // cancel previous notification NotificationManagerCompat.from(this) - .cancel(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) + .cancel(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS) // create authenticating OkHttpClient (credentials taken from account settings) HttpClient.Builder(this, AccountSettings(this, account)) - .setForeground(true) - .build().use { client -> - val httpClient = client.okHttpClient - - // 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 (homeSetUrl, homeSet) = itHomeSets.next() - Logger.log.fine("Listing home set $homeSetUrl") + .setForeground(true) + .build().use { client -> + val httpClient = client.okHttpClient - try { - DavResource(httpClient, homeSetUrl).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.displayName = response[DisplayName::class.java]?.displayName - homeSet.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.refHomeSet = homeSet - info.confirmed = true - - // whether new collections are selected for synchronization by default (controlled by managed setting) - info.sync = syncAllCollections + val accessToken = service.accessToken - info.owner = response[Owner::class.java]?.href?.let { response.href.resolve(it) } - 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() + // refresh home set list (from principal) + service.principal?.let { principalUrl -> + Logger.log.fine("Querying principal $principalUrl for home sets") + queryHomeSets(httpClient, principalUrl, accessToken) } - } - // check/refresh unconfirmed collections - val collectionsIter = collections.entries.iterator() - while (collectionsIter.hasNext()) { - val currentCollection = collectionsIter.next() - val (url, info) = currentCollection - if (!info.confirmed) - try { - // this collection doesn't belong to a homeset anymore, otherwise it would have been confirmed - info.homeSetId = null + // 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}") - DavResource(httpClient, url).propfind(0, *DAV_COLLECTION_PROPERTIES) { response, _ -> + try { + 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.serviceId = info.serviceId // use same service ID as previous entry - collection.confirmed = true + 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 + } - // 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)) - collectionsIter.remove() - else - // update this collection in list - currentCollection.setValue(collection) + // 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) - collectionsIter.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 + } + } } - } - db.runInTransaction { - saveHomesets() - // use refHomeSet (if available) to determine homeset ID - for (collection in collections.values) - collection.refHomeSet?.let { homeSet -> - collection.homeSetId = homeSet.id - } - saveCollections() - } + saveResults() } catch(e: InvalidAccountException) { Logger.log.log(Level.SEVERE, "Invalid account", e) } catch(e: Exception) { Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e) - val debugIntent = DebugInfoActivity.IntentBuilder(this) - .withCause(e) - .withAccount(account) - .build() + val debugIntent = Intent(this, DebugInfoActivity::class.java) + debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e) + debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account) + val notify = NotificationUtils.newBuilder(this, NotificationUtils.CHANNEL_GENERAL) - .setSmallIcon(R.drawable.ic_sync_problem_notify) - .setContentTitle(getString(R.string.dav_service_refresh_failed)) - .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) - .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setSubText(account.name) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .build() + .setSmallIcon(R.drawable.ic_sync_problem_notify) + .setContentTitle(getString(R.string.dav_service_refresh_failed)) + .setContentText(getString(R.string.dav_service_refresh_couldnt_refresh)) + .setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT)) + .setSubText(account.name) + .setCategory(NotificationCompat.CATEGORY_ERROR) + .build() NotificationManagerCompat.from(this) - .notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) + .notify(serviceId.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify) } finally { runningRefresh.remove(serviceId) refreshingStatusListeners.mapNotNull { it.get() }.forEach { @@ -428,4 +366,4 @@ class DavService: IntentService("DavService") { } -} \ No newline at end of file +} diff --git a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt index fc56d4c69a723ddb8ba94a2a0291ac4b5359230b..683b624b4283758d80de51c6547a66e4d4809195 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/AppDatabase.kt @@ -3,272 +3,227 @@ **************************************************************************************************/ package at.bitfire.davdroid.db - -import android.accounts.AccountManager -import android.app.PendingIntent import android.content.Context -import android.content.Intent +import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteQueryBuilder -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.database.getStringOrNull -import androidx.room.* +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -import at.bitfire.davdroid.R -import at.bitfire.davdroid.TextTable -import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.ui.AccountsActivity -import at.bitfire.davdroid.ui.NotificationUtils -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext -import dagger.hilt.components.SingletonComponent -import java.io.Writer -import javax.inject.Singleton +import foundation.e.accountmanager.log.Logger @Suppress("ClassName") @Database(entities = [ Service::class, HomeSet::class, - Collection::class, - SyncStats::class, - WebDavDocument::class, - WebDavMount::class -], exportSchema = true, version = 11, autoMigrations = [ - AutoMigration(from = 9, to = 10), - AutoMigration(from = 10, to = 11) -]) + Collection::class +], version = 7) @TypeConverters(Converters::class) abstract class AppDatabase: RoomDatabase() { - @Module - @InstallIn(SingletonComponent::class) - object AppDatabaseModule { - @Provides - @Singleton - fun appDatabase(@ApplicationContext context: Context): AppDatabase = - Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") - .addMigrations(*migrations) - .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing - .addCallback(object: Callback() { - override fun onDestructiveMigration(db: SupportSQLiteDatabase) { - val nm = NotificationManagerCompat.from(context) - val launcherIntent = Intent(context, AccountsActivity::class.java) - val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_GENERAL) - .setSmallIcon(R.drawable.ic_warning_notify) - .setContentTitle(context.getString(R.string.database_destructive_migration_title)) - .setContentText(context.getString(R.string.database_destructive_migration_text)) - .setCategory(NotificationCompat.CATEGORY_ERROR) - .setContentIntent(PendingIntent.getActivity(context, 0, launcherIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - .setAutoCancel(true) - .build() - nm.notify(NotificationUtils.NOTIFY_DATABASE_CORRUPTED, notify) - - // remove all accounts because they're unfortunately useless without database - val am = AccountManager.get(context) - for (account in am.getAccountsByType(context.getString(R.string.account_type))) - am.removeAccount(account, null, null) - } - }) - .build() - } + abstract fun serviceDao(): ServiceDao + abstract fun homeSetDao(): HomeSetDao + abstract fun collectionDao(): CollectionDao companion object { - // migrations - - val migrations: Array = arrayOf( - object : Migration(8, 9) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE syncstats (" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "collectionId INTEGER NOT NULL REFERENCES collection(id) ON DELETE CASCADE," + - "authority TEXT NOT NULL," + - "lastSync INTEGER NOT NULL)") - db.execSQL("CREATE UNIQUE INDEX index_syncstats_collectionId_authority ON syncstats(collectionId, authority)") - - db.execSQL("CREATE INDEX index_collection_url ON collection(url)") - } - }, - - object : Migration(7, 8) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE homeset ADD COLUMN personal INTEGER NOT NULL DEFAULT 1") - db.execSQL("ALTER TABLE collection ADD COLUMN homeSetId INTEGER DEFAULT NULL REFERENCES homeset(id) ON DELETE SET NULL") - db.execSQL("ALTER TABLE collection ADD COLUMN owner TEXT DEFAULT NULL") - db.execSQL("CREATE INDEX index_collection_homeSetId_type ON collection(homeSetId, type)") - } - }, - - object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") - db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") - } - }, - - object : Migration(5, 6) { - override fun migrate(db: SupportSQLiteDatabase) { - val sql = arrayOf( - // migrate "services" to "service": rename columns, make id NOT NULL - "CREATE TABLE service(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "accountName TEXT NOT NULL," + - "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", - "DROP TABLE services", - - // migrate "homesets" to "homeset": rename columns, make id NOT NULL - "CREATE TABLE homeset(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "serviceId INTEGER NOT NULL," + - "url TEXT NOT NULL," + - "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + - ")", - "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", - "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", - "DROP TABLE homesets", - - // migrate "collections" to "collection": rename columns, make id NOT NULL - "CREATE TABLE collection(" + - "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + - "serviceId INTEGER NOT NULL," + - "type TEXT NOT NULL," + - "url TEXT NOT NULL," + - "privWriteContent INTEGER NOT NULL DEFAULT 1," + - "privUnbind INTEGER NOT NULL DEFAULT 1," + - "forceReadOnly INTEGER NOT NULL DEFAULT 0," + - "displayName TEXT DEFAULT NULL," + - "description TEXT DEFAULT NULL," + - "color INTEGER DEFAULT NULL," + - "timezone TEXT DEFAULT NULL," + - "supportsVEVENT INTEGER DEFAULT NULL," + - "supportsVTODO INTEGER DEFAULT NULL," + - "supportsVJOURNAL INTEGER DEFAULT NULL," + - "source TEXT DEFAULT NULL," + - "sync INTEGER NOT NULL DEFAULT 0," + - "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + - ")", - "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", - "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + - "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", - "DROP TABLE collections" - ) - sql.forEach { db.execSQL(it) } - } - }, - - object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") - - db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") - db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") - - // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column - } - }, + private var INSTANCE: AppDatabase? = null + + @Synchronized + fun getInstance(context: Context): AppDatabase { + INSTANCE?.let { return it } + + val db = Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "services.db") + .addMigrations( + Migration1_2, + Migration2_3, + Migration3_4, + Migration4_5, + Migration5_6, + Migration6_7 + ) + .fallbackToDestructiveMigration() // as a last fallback, recreate database instead of crashing + .build() + INSTANCE = db - object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") - } - }, + return db + } - object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - // We don't have access to the context in a Room migration now, so - // we will just drop those settings from old DAVx5 versions. - Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*") + } - /*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() - try { - db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> - while (cursor.moveToNext()) { - when (cursor.getString(0)) { - "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) - "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) - "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) - "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) + fun dump(sb: StringBuilder) { + val db = openHelper.readableDatabase + db.beginTransactionNonExclusive() - StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> - edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) - StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> - edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) - } + // iterate through all tables + db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> + while (cursorTables.moveToNext()) { + val table = cursorTables.getString(0) + sb.append(table).append("\n") + db.query("SELECT * FROM $table").use { cursor -> + // print columns + val cols = cursor.columnCount + sb.append("\t| ") + for (i in 0 until cols) + sb .append(" ") + .append(cursor.getColumnName(i)) + .append(" |") + sb.append("\n") + + // print rows + while (cursor.moveToNext()) { + sb.append("\t| ") + for (i in 0 until cols) { + sb.append(" ") + try { + val value = cursor.getString(i) + if (value != null) + sb.append(value + .replace("\r", "") + .replace("\n", "")) + else + sb.append("") + + } catch (e: SQLiteException) { + sb.append("") } + sb.append(" |") } - db.execSQL("DROP TABLE settings") - } finally { - edit.apply() - }*/ - } - }, - - object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") - db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") - db.execSQL("UPDATE collections SET type=(" + - "SELECT CASE service WHEN ? THEN ? ELSE ? END " + - "FROM services WHERE _id=collections.serviceID" + - ")", - arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) + sb.append("\n") + } + sb.append("----------\n") } } - ) - + db.endTransaction() + } } - // DAOs + // migrations - abstract fun serviceDao(): ServiceDao - abstract fun homeSetDao(): HomeSetDao - abstract fun collectionDao(): CollectionDao - abstract fun syncStatsDao(): SyncStatsDao - abstract fun webDavDocumentDao(): WebDavDocumentDao - abstract fun webDavMountDao(): WebDavMountDao + object Migration6_7: Migration(6, 7) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE homeset ADD COLUMN privBind INTEGER NOT NULL DEFAULT 1") + db.execSQL("ALTER TABLE homeset ADD COLUMN displayName TEXT DEFAULT NULL") + } + } + object Migration5_6: Migration(5, 6) { + override fun migrate(db: SupportSQLiteDatabase) { + val sql = arrayOf( + // migrate "services" to "service": rename columns, make id NOT NULL + "CREATE TABLE service(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "accountName TEXT NOT NULL," + + "accessToken TEXT ," + + "refreshToken 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, accessToken, refreshToken, type, principal) SELECT _id, accountName, accessToken, refreshToken, service, principal FROM services", + "DROP TABLE services", + + // migrate "homesets" to "homeset": rename columns, make id NOT NULL + "CREATE TABLE homeset(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "url TEXT NOT NULL," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE UNIQUE INDEX index_homeset_serviceId_url ON homeset(serviceId, url)", + "INSERT INTO homeset(id, serviceId, url) SELECT _id, serviceID, url FROM homesets", + "DROP TABLE homesets", + + // migrate "collections" to "collection": rename columns, make id NOT NULL + "CREATE TABLE collection(" + + "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT," + + "serviceId INTEGER NOT NULL," + + "type TEXT NOT NULL," + + "url TEXT NOT NULL," + + "privWriteContent INTEGER NOT NULL DEFAULT 1," + + "privUnbind INTEGER NOT NULL DEFAULT 1," + + "forceReadOnly INTEGER NOT NULL DEFAULT 0," + + "displayName TEXT DEFAULT NULL," + + "description TEXT DEFAULT NULL," + + "color INTEGER DEFAULT NULL," + + "timezone TEXT DEFAULT NULL," + + "supportsVEVENT INTEGER DEFAULT NULL," + + "supportsVTODO INTEGER DEFAULT NULL," + + "supportsVJOURNAL INTEGER DEFAULT NULL," + + "source TEXT DEFAULT NULL," + + "sync INTEGER NOT NULL DEFAULT 0," + + "FOREIGN KEY (serviceId) REFERENCES service(id) ON DELETE CASCADE" + + ")", + "CREATE INDEX index_collection_serviceId_type ON collection(serviceId,type)", + "INSERT INTO collection(id, serviceId, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync) " + + "SELECT _id, serviceID, type, url, privWriteContent, privUnbind, forceReadOnly, displayName, description, color, timezone, supportsVEVENT, supportsVTODO, source, sync FROM collections", + "DROP TABLE collections" + ) + sql.forEach { db.execSQL(it) } + } + } - // helpers + object Migration4_5: Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN privWriteContent INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privWriteContent=NOT readOnly") - fun dump(writer: Writer, ignoreTables: Array) { - val db = openHelper.readableDatabase - db.beginTransactionNonExclusive() + db.execSQL("ALTER TABLE collections ADD COLUMN privUnbind INTEGER DEFAULT 0 NOT NULL") + db.execSQL("UPDATE collections SET privUnbind=NOT readOnly") - // iterate through all tables - db.query(SQLiteQueryBuilder.buildQueryString(false, "sqlite_master", arrayOf("name"), "type='table'", null, null, null, null)).use { cursorTables -> - while (cursorTables.moveToNext()) { - val tableName = cursorTables.getString(0) - if (ignoreTables.contains(tableName)) { - writer.append("$tableName: ") - db.query("SELECT COUNT(*) FROM $tableName").use { cursor -> - if (cursor.moveToNext()) - writer.append("${cursor.getInt(0)} row(s), data not listed here\n\n") - } - } else { - writer.append("$tableName\n") - db.query("SELECT * FROM $tableName").use { cursor -> - val table = TextTable(*cursor.columnNames) - val cols = cursor.columnCount - // print rows - while (cursor.moveToNext()) { - val values = Array(cols) { idx -> cursor.getStringOrNull(idx) } - table.addLine(*values) + // there's no DROP COLUMN in SQLite, so just keep the "readOnly" column + } + } + + object Migration3_4: Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN forceReadOnly INTEGER DEFAULT 0 NOT NULL") + } + } + + object Migration2_3: Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + // We don't have access to the context in a Room migration now, so + // we will just drop those settings from old DAVx5 versions. + Logger.log.warning("Dropping settings distrustSystemCerts and overrideProxy*") + + /*val edit = PreferenceManager.getDefaultSharedPreferences(context).edit() + try { + db.query("settings", arrayOf("setting", "value"), null, null, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + when (cursor.getString(0)) { + "distrustSystemCerts" -> edit.putBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, cursor.getInt(1) != 0) + "overrideProxy" -> edit.putBoolean(App.OVERRIDE_PROXY, cursor.getInt(1) != 0) + "overrideProxyHost" -> edit.putString(App.OVERRIDE_PROXY_HOST, cursor.getString(1)) + "overrideProxyPort" -> edit.putInt(App.OVERRIDE_PROXY_PORT, cursor.getInt(1)) + + StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED -> + edit.putBoolean(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, cursor.getInt(1) != 0) + StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED -> + edit.putBoolean(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED, cursor.getInt(1) != 0) } - writer.append(table.toString()) } } - } - db.endTransaction() + db.execSQL("DROP TABLE settings") + } finally { + edit.apply() + }*/ + } + } + + object Migration1_2: Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE collections ADD COLUMN type TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE collections ADD COLUMN source TEXT DEFAULT NULL") + db.execSQL("UPDATE collections SET type=(" + + "SELECT CASE service WHEN ? THEN ? ELSE ? END " + + "FROM services WHERE _id=collections.serviceID" + + ")", + arrayOf("caldav", "CALENDAR", "ADDRESS_BOOK")) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt index 414c4d23ea233d8aac192281f2c0c1cee2237190..a14ff470feaf6b38f05f7db032b982689d815dd5 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Collection.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Collection.kt @@ -62,7 +62,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 { diff --git a/app/src/main/java/at/bitfire/davdroid/db/Service.kt b/app/src/main/java/at/bitfire/davdroid/db/Service.kt index 0287f6c215042fa5ceaf7b45194ee2ac4b68af70..a5500ff584baab917c9f5b4dd21bbdc3e1fc4a3b 100644 --- a/app/src/main/java/at/bitfire/davdroid/db/Service.kt +++ b/app/src/main/java/at/bitfire/davdroid/db/Service.kt @@ -20,6 +20,9 @@ data class Service( var accountName: String, var type: String, + var accessToken: String, + var refreshToken: String, + var principal: HttpUrl? ): IdEntity { diff --git a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt index 539779f5b8eca611d676d29ba512c555d5ee6c4e..d961278c30734886e6e857d4b254ea913a33c41f 100644 --- a/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/java/at/bitfire/davdroid/settings/AccountSettings.kt @@ -100,8 +100,11 @@ class AccountSettings( when (credentials.type) { Credentials.Type.UsernamePassword -> bundle.putString(KEY_USERNAME, credentials.userName) - Credentials.Type.OAuth -> + Credentials.Type.OAuth -> { bundle.putString(KEY_USERNAME, credentials.userName) + bundle.putString(KEY_ACCESS_TOKEN, credentials.accessToken) + bundle.putString(KEY_REFRESH_TOKEN, credentials.refreshToken) + } Credentials.Type.ClientCertificate -> bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias) } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt index 6564f3adbf78c5d9670460a22a9b50c79487f1cf..b01e83a8a002c08c04c7f55e6b7c57b32229904d 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/CalendarSyncManager.kt @@ -49,58 +49,54 @@ class CalendarSyncManager( account: Account, accountSettings: AccountSettings, extras: Bundle, - httpClient: HttpClient, authority: String, syncResult: SyncResult, localCalendar: LocalCalendar -): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localCalendar) { +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localCalendar) { override fun prepare(): Boolean { - collectionURL = (localCollection.name ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().accessToken) // if there are dirty exceptions for events, mark their master events as dirty, too localCollection.processDirtyExceptions() - // now find dirty events that have no instances and set them to deleted - localCollection.deleteDirtyEventsWithoutInstances() - return true } override fun queryCapabilities(): SyncState? = - remoteExceptionContext { - var syncState: SyncState? = null - it.propfind(0, MaxICalendarSize.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> - if (relation == Response.HrefRelation.SELF) { - response[MaxICalendarSize::class.java]?.maxSize?.let { maxSize -> - Logger.log.info("Calendar accepts events up to ${FileUtils.byteCountToDisplaySize(maxSize)}") - } - - response[SupportedReportSet::class.java]?.let { supported -> - hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) - } - syncState = syncState(response) + useRemoteCollection { + var syncState: SyncState? = null + it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + if (relation == Response.HrefRelation.SELF) { + response[SupportedReportSet::class.java]?.let { supported -> + hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } - } - Logger.log.info("Calendar supports Collection Sync: $hasCollectionSync") - syncState + syncState = syncState(response) + } } + Logger.log.info("Server supports Collection Sync: $hasCollectionSync") + syncState + } + override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync) - SyncAlgorithm.PROPFIND_REPORT - else - SyncAlgorithm.COLLECTION_SYNC + SyncAlgorithm.PROPFIND_REPORT + else + SyncAlgorithm.COLLECTION_SYNC - override fun generateUpload(resource: LocalEvent): RequestBody = localExceptionContext(resource) { + override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource) { val event = requireNotNull(resource.event) Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event) val os = ByteArrayOutputStream() event.write(os) - os.toByteArray().toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8) + RequestBody.create( + DavCalendar.MIME_ICALENDAR_UTF8, + os.toByteArray() + ) } override fun listAllRemote(callback: DavResponseCallback) { @@ -112,34 +108,49 @@ class CalendarSyncManager( limitStart = calendar.time } - return remoteExceptionContext { remote -> + return useRemoteCollection { remote -> Logger.log.info("Querying events since $limitStart") - remote.calendarQuery(Component.VEVENT, limitStart, null, callback) + remote.calendarQuery("VEVENT", limitStart, null, callback) } } override fun downloadRemote(bunch: List) { Logger.log.info("Downloading ${bunch.size} iCalendars: $bunch") - remoteExceptionContext { - it.multiget(bunch) { response, _ -> - responseExceptionContext(response) { - if (!response.isSuccess()) { - Logger.log.warning("Received non-successful multiget response for ${response.href}") - return@responseExceptionContext + if (bunch.size == 1) { + val remote = bunch.first() + // only one contact, use GET + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().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 } + ?: throw DavException("Received CalDAV GET response without ETag") + + response.body()!!.use { + processVEvent(resource.fileName(), eTag, it.charStream()) } + } + } + } else + // multiple iCalendars, use calendar-multi-get + useRemoteCollection { + it.multiget(bunch) { response, _ -> + useRemote(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@useRemote + } - val eTag = response[GetETag::class.java]?.eTag + val eTag = response[GetETag::class.java]?.eTag ?: throw DavException("Received multi-get response without ETag") - val scheduleTag = response[ScheduleTag::class.java]?.scheduleTag - val calendarData = response[CalendarData::class.java] - val iCal = calendarData?.iCalendar + val calendarData = response[CalendarData::class.java] + val iCal = calendarData?.iCalendar ?: throw DavException("Received multi-get response without address data") - processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, scheduleTag, StringReader(iCal)) + processVEvent(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(iCal)) + } } } - } } override fun postProcess() { @@ -148,7 +159,7 @@ class CalendarSyncManager( // helpers - private fun processVEvent(fileName: String, eTag: String, scheduleTag: String?, reader: Reader) { + private fun processVEvent(fileName: String, eTag: String, reader: Reader) { val events: List try { events = Event.eventsFromReader(reader) @@ -163,23 +174,22 @@ class CalendarSyncManager( // set default reminder for non-full-day events, if requested val defaultAlarmMinBefore = accountSettings.getDefaultAlarm() - if (defaultAlarmMinBefore != null && DateUtils.isDateTime(event.dtStart) && event.alarms.isEmpty()) { - val alarm = VAlarm(Duration.ofMinutes(-defaultAlarmMinBefore.toLong())) + if (defaultAlarmMinBefore != null && !event.isAllDay() && event.alarms.isEmpty()) { + val alarm = VAlarm(Dur(0, 0, -defaultAlarmMinBefore, 0)) Logger.log.log(Level.FINE, "${event.uid}: Adding default alarm", alarm) event.alarms += alarm } // update local event, if it exists - localExceptionContext(localCollection.findByName(fileName)) { local -> + useLocal(localCollection.findByName(fileName)) { local -> if (local != null) { Logger.log.log(Level.INFO, "Updating $fileName in local calendar", event) local.eTag = eTag - local.scheduleTag = scheduleTag local.update(event) syncResult.stats.numUpdates++ } else { Logger.log.log(Level.INFO, "Adding $fileName to local calendar", event) - localExceptionContext(LocalEvent(localCollection, event, fileName, eTag, scheduleTag, LocalResource.FLAG_REMOTELY_PRESENT)) { + useLocal(LocalEvent(localCollection, event, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { it.add() } syncResult.stats.numInserts++ @@ -190,6 +200,6 @@ class CalendarSyncManager( } override fun notifyInvalidResourceTitle(): String = - context.getString(R.string.sync_invalid_event) + context.getString(R.string.sync_invalid_event) } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt index cd64d1c7f03026b8c79e06c980ea52e04e8470d0..9fead6301a4b5d4b0ec184a960e99de5e9688d31 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncManager.kt @@ -46,7 +46,6 @@ import java.io.IOException import java.io.Reader import java.io.StringReader import java.util.logging.Level - /** * Synchronization manager for CardDAV collections; handles contacts and groups. * @@ -86,26 +85,22 @@ class ContactsSyncManager( context: Context, account: Account, accountSettings: AccountSettings, - httpClient: HttpClient, extras: Bundle, authority: String, syncResult: SyncResult, val provider: ContentProviderClient, localAddressBook: LocalAddressBook -): SyncManager(context, account, accountSettings, httpClient, extras, authority, syncResult, localAddressBook) { +): SyncManager(context, account, accountSettings, extras, authority, syncResult, localAddressBook) { companion object { infix fun Set.disjunct(other: Set) = (this - other) union (other - this) } private val readOnly = localAddressBook.readOnly + private val accessToken: String? = accountSettings.credentials().accessToken private var hasVCard4 = false - private var hasJCard = false - private val groupStrategy = when (accountSettings.getGroupMethod()) { - GroupMethod.GROUP_VCARDS -> VCard4Strategy(localAddressBook) - GroupMethod.CATEGORIES -> CategoriesStrategy(localAddressBook) - } + private val groupMethod = accountSettings.getGroupMethod() /** * Used to download images which are referenced by URL @@ -124,178 +119,227 @@ class ContactsSyncManager( } } - collectionURL = localCollection.url.toHttpUrlOrNull() ?: return false - davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL) + collectionURL = HttpUrl.parse(localCollection.url) ?: return false + davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL, accountSettings.credentials().accessToken) resourceDownloader = ResourceDownloader(davCollection.location) - Logger.log.info("Contact group strategy: ${groupStrategy::class.java.simpleName}") return true } override fun queryCapabilities(): SyncState? { - return remoteExceptionContext { + Logger.log.info("Contact group method: $groupMethod") + // in case of GROUP_VCARDs, treat groups as contacts in the local address book + localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS + + return useRemoteCollection { var syncState: SyncState? = null - it.propfind(0, MaxVCardSize.NAME, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> + it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME) { response, relation -> if (relation == Response.HrefRelation.SELF) { - response[MaxVCardSize::class.java]?.maxSize?.let { maxSize -> - Logger.log.info("Address book accepts vCards up to ${FileUtils.byteCountToDisplaySize(maxSize)}") - } - response[SupportedAddressData::class.java]?.let { supported -> hasVCard4 = supported.hasVCard4() - - // temporarily disable jCard because of https://github.com/nextcloud/server/issues/29693 - // hasJCard = supported.hasJCard() } + response[SupportedReportSet::class.java]?.let { supported -> hasCollectionSync = supported.reports.contains(SupportedReportSet.SYNC_COLLECTION) } + syncState = syncState(response) } } - // Logger.log.info("Server supports jCard: $hasJCard") - Logger.log.info("Address book supports vCard4: $hasVCard4") - Logger.log.info("Address book supports Collection Sync: $hasCollectionSync") + Logger.log.info("Server supports vCard/4: $hasVCard4") + Logger.log.info("Server supports Collection Sync: $hasCollectionSync") syncState } } override fun syncAlgorithm() = if (hasCollectionSync) - SyncAlgorithm.COLLECTION_SYNC - else - SyncAlgorithm.PROPFIND_REPORT + SyncAlgorithm.COLLECTION_SYNC + else + SyncAlgorithm.PROPFIND_REPORT override fun processLocallyDeleted() = - if (readOnly) { - for (group in localCollection.findDeletedGroups()) { - Logger.log.warning("Restoring locally deleted group (read-only address book!)") - localExceptionContext(group) { it.resetDeleted() } - } + if (readOnly) { + for (group in localCollection.findDeletedGroups()) { + Logger.log.warning("Restoring locally deleted group (read-only address book!)") + useLocal(group) { it.resetDeleted() } + } - for (contact in localCollection.findDeletedContacts()) { - Logger.log.warning("Restoring locally deleted contact (read-only address book!)") - localExceptionContext(contact) { it.resetDeleted() } - } + for (contact in localCollection.findDeletedContacts()) { + Logger.log.warning("Restoring locally deleted contact (read-only address book!)") + useLocal(contact) { it.resetDeleted() } + } - false - } else - // mirror deletions to remote collection (DELETE) - super.processLocallyDeleted() + false + } else + // mirror deletions to remote collection (DELETE) + super.processLocallyDeleted() override fun uploadDirty(): Boolean { if (readOnly) { for (group in localCollection.findDirtyGroups()) { Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)") - localExceptionContext(group) { it.clearDirty(null, null) } + useLocal(group) { it.clearDirty(null) } } for (contact in localCollection.findDirtyContacts()) { Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)") - localExceptionContext(contact) { it.clearDirty(null, null) } + useLocal(contact) { it.clearDirty(null) } } - } else - // we only need to handle changes in groups when the address book is read/write - groupStrategy.beforeUploadDirty() + } else { + if (groupMethod == GroupMethod.CATEGORIES) { + /* groups memberships are represented as contact CATEGORIES */ + + // groups with DELETED=1: set all members to dirty, then remove group + for (group in localCollection.findDeletedGroups()) { + Logger.log.fine("Finally removing group $group") + // useless because Android deletes group memberships as soon as a group is set to DELETED: + // group.markMembersDirty() + useLocal(group) { it.delete() } + } + + // groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group + for (group in localCollection.findDirtyGroups()) { + Logger.log.fine("Marking members of modified group $group as dirty") + useLocal(group) { + it.markMembersDirty() + it.clearDirty(null) + } + } + } else { + /* groups as separate VCards: there are group contacts and individual contacts */ + + // mark groups with changed members as dirty + val batch = BatchOperation(localCollection.provider!!) + for (contact in localCollection.findDirtyContacts()) + try { + Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}") + val cachedGroups = contact.getCachedGroupMemberships() + val currentGroups = contact.getGroupMemberships() + for (groupID in cachedGroups disjunct currentGroups) { + Logger.log.fine("Marking group as dirty: $groupID") + batch.enqueue(BatchOperation.Operation( + ContentProviderOperation.newUpdate(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID))) + .withValue(Groups.DIRTY, 1) + .withYieldAllowed(true) + )) + } + } catch(e: FileNotFoundException) { + } + batch.commit() + } + } // generate UID/file name for newly created contacts return super.uploadDirty() } - override fun generateUpload(resource: LocalAddress): RequestBody = - localExceptionContext(resource) { - val contact: Contact = when (resource) { - is LocalContact -> resource.getContact() - is LocalGroup -> resource.getContact() - else -> throw IllegalArgumentException("resource must be LocalContact or LocalGroup") + override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource) { + val contact: Contact + if (resource is LocalContact) { + contact = resource.contact!! + + if (groupMethod == GroupMethod.CATEGORIES) { + // add groups as CATEGORIES + for (groupID in resource.getGroupMemberships()) { + provider.query( + localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)), + arrayOf(Groups.TITLE), null, null, null + )?.use { cursor -> + if (cursor.moveToNext()) { + val title = cursor.getString(0) + if (!title.isNullOrEmpty()) + contact.categories.add(title) + } + } + } } + } else if (resource is LocalGroup) + contact = resource.contact!! + else + throw IllegalArgumentException("resource must be LocalContact or LocalGroup") - Logger.log.log(Level.FINE, "Preparing upload of vCard ${resource.fileName}", contact) + Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact) - val os = ByteArrayOutputStream() - val mimeType: MediaType - when { - hasJCard -> { - mimeType = DavAddressBook.MIME_JCARD - contact.writeJCard(os) - } - hasVCard4 -> { - mimeType = DavAddressBook.MIME_VCARD4 - contact.writeVCard(VCardVersion.V4_0, os) - } - else -> { - mimeType = DavAddressBook.MIME_VCARD3_UTF8 - contact.writeVCard(VCardVersion.V3_0, os) - } - } + val os = ByteArrayOutputStream() + contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os) - return@localExceptionContext(os.toByteArray().toRequestBody(mimeType)) - } + RequestBody.create( + if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8, + os.toByteArray() + ) + } override fun listAllRemote(callback: DavResponseCallback) = - remoteExceptionContext { - it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback) - } + useRemoteCollection { + it.propfind(1, ResourceType.NAME, GetETag.NAME, callback = callback) + } override fun downloadRemote(bunch: List) { - Logger.log.info("Downloading ${bunch.size} vCard(s): $bunch") - remoteExceptionContext { - val contentType: String? - val version: String? - when { - hasJCard -> { - contentType = DavUtils.MEDIA_TYPE_JCARD.toString() - version = VCardVersion.V4_0.version - } - hasVCard4 -> { - contentType = DavUtils.MEDIA_TYPE_VCARD.toString() - version = VCardVersion.V4_0.version - } - else -> { - contentType = DavUtils.MEDIA_TYPE_VCARD.toString() - version = null // 3.0 is the default version; don't request 3.0 explicitly because maybe some vCard3-only servers don't understand it + Logger.log.info("Downloading ${bunch.size} vCards: $bunch") + if (bunch.size == 1) { + val remote = bunch.first() + // only one contact, use GET + useRemote(DavResource(httpClient.okHttpClient, remote, accountSettings.credentials().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 } + ?: throw DavException("Received CardDAV GET response without ETag") + + response.body()!!.use { + processVCard(resource.fileName(), eTag, it.charStream(), resourceDownloader) + } } } - it.multiget(bunch, contentType, version) { response, _ -> - responseExceptionContext(response) { - if (!response.isSuccess()) { - Logger.log.warning("Received non-successful multiget response for ${response.href}") - return@responseExceptionContext - } - - val eTag = response[GetETag::class.java]?.eTag + } else + // multiple vCards, use addressbook-multi-get + useRemoteCollection { + it.multiget(bunch, hasVCard4) { response, _ -> + useRemote(response) { + if (!response.isSuccess()) { + Logger.log.warning("Received non-successful multiget response for ${response.href}") + return@useRemote + } + + val eTag = response[GetETag::class.java]?.eTag ?: throw DavException("Received multi-get response without ETag") - var isJCard = hasJCard // assume that server has sent what we have requested (we ask for jCard only when the server advertises it) - response[GetContentType::class.java]?.type?.let { type -> - isJCard = type.sameTypeAs(DavUtils.MEDIA_TYPE_JCARD) - } - - val addressData = response[AddressData::class.java] - val card = addressData?.card + val addressData = response[AddressData::class.java] + val vCard = addressData?.vCard ?: throw DavException("Received multi-get response without address data") - processCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(card), isJCard, resourceDownloader) + processVCard(DavUtils.lastSegmentOfUrl(response.href), eTag, StringReader(vCard), resourceDownloader) + } } } - } } override fun postProcess() { - groupStrategy.postProcess() + if (groupMethod == GroupMethod.CATEGORIES) { + /* VCard3 group handling: groups memberships are represented as contact CATEGORIES */ + + // remove empty groups + Logger.log.info("Removing empty groups") + localCollection.removeEmptyGroups() + + } else { + /* VCard4 group handling: there are group contacts and individual contacts */ + Logger.log.info("Assigning memberships of downloaded contact groups") + LocalGroup.applyPendingMemberships(localCollection) + } } // helpers - private fun processCard(fileName: String, eTag: String, reader: Reader, jCard: Boolean, downloader: Contact.Downloader) { + private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) { Logger.log.info("Processing CardDAV resource $fileName") val contacts = try { - Contact.fromReader(reader, jCard, downloader) + Contact.fromReader(reader, downloader) } catch (e: CannotParseException) { Logger.log.log(Level.SEVERE, "Received invalid vCard, ignoring", e) notifyInvalidResource(e, fileName) @@ -309,10 +353,14 @@ class ContactsSyncManager( Logger.log.warning("Received multiple vCards, using first one") val newData = contacts.first() - groupStrategy.verifyContactBeforeSaving(newData) + + if (groupMethod == GroupMethod.CATEGORIES && newData.group) { + Logger.log.warning("Received group vCard although group method is CATEGORIES. Saving as regular contact") + newData.group = false + } // update local contact, if it exists - localExceptionContext(localCollection.findByName(fileName)) { + useLocal(localCollection.findByName(fileName)) { var local = it if (local != null) { Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData) @@ -332,7 +380,7 @@ class ContactsSyncManager( syncResult.stats.numUpdates++ } else { - // group has become an individual contact or vice versa, delete and create with new type + // group has become an individual contact or vice versa local.delete() local = null } @@ -341,13 +389,13 @@ class ContactsSyncManager( if (local == null) { if (newData.group) { Logger.log.log(Level.INFO, "Creating local group", newData) - localExceptionContext(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group -> + useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { group -> group.add() local = group } } else { Logger.log.log(Level.INFO, "Creating local contact", newData) - localExceptionContext(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact -> + useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT)) { contact -> contact.add() local = contact } @@ -355,8 +403,24 @@ class ContactsSyncManager( syncResult.stats.numInserts++ } + if (groupMethod == GroupMethod.CATEGORIES) + (local as? LocalContact)?.let { localContact -> + // VCard3: update group memberships from CATEGORIES + val batch = BatchOperation(provider) + Logger.log.log(Level.FINE, "Removing contact group memberships") + localContact.removeGroupMemberships(batch) + + for (category in localContact.contact!!.categories) { + val groupID = localCollection.findOrCreateGroup(category) + Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)") + localContact.addToGroup(batch, groupID) + } + + batch.commit() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) - // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed (local as? LocalContact)?.updateHashCode(null) } } @@ -365,29 +429,38 @@ class ContactsSyncManager( // downloader helper class private inner class ResourceDownloader( - val baseUrl: HttpUrl + val baseUrl: HttpUrl ): Contact.Downloader { override fun download(url: String, accepts: String): ByteArray? { - val httpUrl = url.toHttpUrlOrNull() + val httpUrl = HttpUrl.parse(url) if (httpUrl == null) { Logger.log.log(Level.SEVERE, "Invalid external resource URL", url) return null } // authenticate only against a certain host, and only upon request - val client = HttpClient.Builder(context, baseUrl.host, accountSettings.credentials()) - .followRedirects(true) // allow redirects - .build() + val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials()) + + // allow redirects + builder.followRedirects(true) + 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() + return response.body()?.bytes() else Logger.log.warning("Couldn't download external resource") } catch(e: IOException) { @@ -400,6 +473,6 @@ class ContactsSyncManager( } override fun notifyInvalidResourceTitle(): String = - context.getString(R.string.sync_invalid_contact) + context.getString(R.string.sync_invalid_contact) } diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt index 74c403ba6a3188a1b0f0cccb05c1d1b2a2b65f8d..7fd4cf34f0146e75096745a943bd93d46bb441b0 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/TasksSyncManager.kt @@ -57,7 +57,7 @@ class TasksSyncManager( override fun prepare(): Boolean { collectionURL = (localCollection.syncId ?: return false).toHttpUrlOrNull() ?: return false - davCollection = DavCalendar(httpClient.okHttpClient, collectionURL) + davCollection = DavCalendar(httpClient.okHttpClient, collectionURL, accountSettings.credentials().accessToken) return true } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt index 2e37a4e521392d8a869748c3e267b7c35434b0a9..a776c8c4afb4d73a235f784519c308c47a54035f 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt @@ -4,58 +4,48 @@ package at.bitfire.davdroid.ui.setup +import DavService import android.accounts.Account import android.accounts.AccountManager +import android.app.Application import android.content.ContentResolver -import android.content.Context import android.content.Intent import android.os.Bundle import android.provider.CalendarContract -import android.text.Editable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.lifecycle.* -import at.bitfire.davdroid.DavService +import at.bitfire.davdroid.Constants + import at.bitfire.davdroid.InvalidAccountException -import at.bitfire.davdroid.R -import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding import at.bitfire.davdroid.db.AppDatabase -import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.db.HomeSet import at.bitfire.davdroid.db.Service import at.bitfire.davdroid.log.Logger -import at.bitfire.davdroid.resource.TaskUtils +import foundation.e.accountmanager.R +import foundation.e.accountmanager.databinding.LoginAccountDetailsBinding +import foundation.e.accountmanager.model.Credentials +import at.bitfire.davdroid.resource.LocalTaskList import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.settings.Settings -import at.bitfire.davdroid.settings.SettingsManager -import at.bitfire.davdroid.syncadapter.AccountUtils -import at.bitfire.davdroid.ui.account.AccountActivity import foundation.e.ical4android.TaskProvider import foundation.e.vcard4android.GroupMethod - import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.launch import java.util.logging.Level -import javax.inject.Inject +import kotlin.concurrent.thread -@AndroidEntryPoint -class AccountDetailsFragment : Fragment() { +class AccountDetailsFragment: Fragment() { - @Inject lateinit var settings: SettingsManager - - val loginModel by activityViewModels() - val model by viewModels() + private lateinit var loginModel: LoginModel + private lateinit var model: AccountDetailsModel + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + loginModel = ViewModelProviders.of(requireActivity()).get(LoginModel::class.java) + model = ViewModelProviders.of(this).get(AccountDetailsModel::class.java) + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val v = LoginAccountDetailsBinding.inflate(inflater, container, false) @@ -64,24 +54,16 @@ class AccountDetailsFragment : Fragment() { val config = loginModel.configuration ?: throw IllegalStateException() - // default account name - model.name.value = - config.calDAV?.emails?.firstOrNull() - ?: loginModel.credentials?.userName - ?: loginModel.credentials?.certificateAlias - ?: loginModel.baseURI?.host + model.name.value = config.calDAV?.email ?: + loginModel.credentials?.userName ?: + loginModel.credentials?.certificateAlias // CardDAV-specific + val settings = Settings.getInstance(requireActivity()) v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE - if (settings.containsKey(AccountSettings.KEY_CONTACT_GROUP_METHOD)) + if (settings.has(AccountSettings.KEY_CONTACT_GROUP_METHOD)) v.contactGroupMethod.isEnabled = false - // CalDAV-specific - config.calDAV?.let { - val accountNameAdapter = ArrayAdapter(requireActivity(), android.R.layout.simple_list_item_1, it.emails) - v.accountName.setAdapter(accountNameAdapter) - } - v.createAccount.setOnClickListener { val name = model.name.value if (name.isNullOrBlank()) @@ -101,20 +83,14 @@ class AccountDetailsFragment : Fragment() { v.createAccount.visibility = View.GONE model.createAccount( - name, - loginModel.credentials, - config, - GroupMethod.valueOf(groupMethodName) - ).observe(viewLifecycleOwner, Observer { success -> - if (success) { - // close Create account activity + name, + loginModel.credentials!!, + config, + GroupMethod.valueOf(groupMethodName) + ).observe(this, Observer { success -> + if (success) requireActivity().finish() - // open Account activity for created account - val intent = Intent(requireActivity(), AccountActivity::class.java) - val account = Account(name, getString(R.string.account_type)) - intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account) - startActivity(intent) - } else { + else { Snackbar.make(requireActivity().findViewById(android.R.id.content), R.string.login_account_not_created, Snackbar.LENGTH_LONG).show() v.createAccountProgress.visibility = View.GONE @@ -140,49 +116,42 @@ class AccountDetailsFragment : Fragment() { } - @HiltViewModel - class AccountDetailsModel @Inject constructor( - @ApplicationContext val context: Context, - val db: AppDatabase, - val settingsManager: SettingsManager - ) : ViewModel() { + class AccountDetailsModel( + application: Application + ): AndroidViewModel(application) { val name = MutableLiveData() val nameError = MutableLiveData() - val showApostropheWarning = MutableLiveData(false) - fun validateAccountName(s: Editable) { - showApostropheWarning.value = s.toString().contains('\'') - nameError.value = null - } - - fun createAccount(name: String, credentials: Credentials?, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { + fun createAccount(name: String, credentials: Credentials, config: DavResourceFinder.Configuration, groupMethod: GroupMethod): LiveData { val result = MutableLiveData() - viewModelScope.launch(Dispatchers.Default + NonCancellable) { + val context = getApplication() + thread { val account = Account(name, context.getString(R.string.account_type)) // create Android account val userData = AccountSettings.initialUserData(credentials) Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) - if (!AccountUtils.createAccount(context, account, userData, credentials?.password)) { + val accountManager = AccountManager.get(context) + if (!accountManager.addAccountExplicitly(account, credentials.password, userData)) { result.postValue(false) - return@launch + return@thread } // add entries for account to service DB Logger.log.log(Level.INFO, "Writing account configuration to database", config) + val db = AppDatabase.getInstance(context) try { val accountSettings = AccountSettings(context, account) - val defaultSyncInterval = settingsManager.getLong(Settings.DEFAULT_SYNC_INTERVAL) val refreshIntent = Intent(context, DavService::class.java) refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS - val addrBookAuthority = context.getString(R.string.address_books_authority) if (config.cardDAV != null) { // insert CardDAV service - val id = insertService(name, Service.TYPE_CARDDAV, config.cardDAV) + + val id = insertService(db, name, credentials.accessToken!!, credentials.refreshToken!!, Service.TYPE_CARDDAV, config.cardDAV) // initial CardDAV account settings accountSettings.setGroupMethod(groupMethod) @@ -191,52 +160,52 @@ class AccountDetailsFragment : Fragment() { refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) context.startService(refreshIntent) - // set default sync interval and enable sync regardless of permissions - ContentResolver.setIsSyncable(account, addrBookAuthority, 1) - accountSettings.setSyncInterval(addrBookAuthority, defaultSyncInterval) + // 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) } else - ContentResolver.setIsSyncable(account, addrBookAuthority, 0) + ContentResolver.setIsSyncable(account, context.getString(R.string.address_books_authority), 0) if (config.calDAV != null) { // insert CalDAV service - val id = insertService(name, Service.TYPE_CALDAV, config.calDAV) + val id = insertService(db, name, credentials.accessToken!!, credentials.refreshToken!!, Service.TYPE_CALDAV, config.calDAV) // start CalDAV service detection (refresh collections) refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id) context.startService(refreshIntent) - // set default sync interval and enable sync regardless of permissions - ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 1) - accountSettings.setSyncInterval(CalendarContract.AUTHORITY, defaultSyncInterval) + // calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_calendars.xml + accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL) - val taskProvider = TaskUtils.currentProvider(context) - if (taskProvider != null) { - ContentResolver.setIsSyncable(account, taskProvider.authority, 1) - accountSettings.setSyncInterval(taskProvider.authority, defaultSyncInterval) - // further changes will be handled by TasksWatcher on app start or when tasks app is (un)installed + // 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) } - } else + } else { ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0) + ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0) + } } catch(e: InvalidAccountException) { Logger.log.log(Level.SEVERE, "Couldn't access account settings", e) result.postValue(false) - return@launch + return@thread } result.postValue(true) } return result } - private fun insertService(accountName: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { + private fun insertService(db: AppDatabase, accountName: String, accessToken: String, refreshToken: String, type: String, info: DavResourceFinder.Configuration.ServiceInfo): Long { // insert service - val service = Service(0, accountName, type, info.principal) + val service = Service(0, accountName, accessToken, refreshToken, type, info.principal) val serviceId = db.serviceDao().insertOrReplace(service) // insert home sets val homeSetDao = db.homeSetDao() for (homeSet in info.homeSets) { - homeSetDao.insertOrReplace(HomeSet(0, serviceId, true, homeSet)) + homeSetDao.insertOrReplace(HomeSet(0, serviceId, homeSet)) } // insert collections @@ -251,4 +220,4 @@ class AccountDetailsFragment : Fragment() { } -} \ No newline at end of file +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt index a082f3fad55add941df30a8ef1f5f43db39d6861..7e0f2b42bbcbdb3cfb55b084010a29b65544fbe3 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt +++ b/app/src/main/java/at/bitfire/davdroid/ui/setup/GoogleAuthenticatorFragment.kt @@ -1,5 +1,6 @@ package at.bitfire.davdroid.ui.setup +import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -122,7 +123,9 @@ class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenRespon authorizationService?.createCustomTabsIntentBuilder()!! .build()) - activity?.finish() + requireActivity().setResult(Activity.RESULT_OK) + requireActivity().finish() + } private fun createPostAuthorizationIntent( @@ -169,8 +172,6 @@ class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenRespon override fun onTokenRequestCompleted(response: TokenResponse?, ex: AuthorizationException?) { authState?.update(response, ex) - progress_bar.visibility = View.GONE - auth_token_success_text_view.visibility = View.VISIBLE getAccountInfo() } @@ -320,7 +321,7 @@ class GoogleAuthenticatorFragment : Fragment(), AuthorizationService.TokenRespon fun validateUrl() { model.baseUrlError.value = null try { - val uri = URI("https://www.google.com/calendar/dav/$emailAddress/events") + val uri = URI("https://apidata.googleusercontent.com/caldav/v2/$emailAddress/events") if (uri.scheme.equals("http", true) || uri.scheme.equals("https", true)) { valid = true loginModel.baseURI = uri diff --git a/app/src/main/res/layout/fragment_google_authenticator.xml b/app/src/main/res/layout/fragment_google_authenticator.xml index 317a6952ac1c15a9b7700e41e437b4fbcfefa77f..d46d68f4045112892fe58dc245cf7cd33e574d49 100644 --- a/app/src/main/res/layout/fragment_google_authenticator.xml +++ b/app/src/main/res/layout/fragment_google_authenticator.xml @@ -19,13 +19,7 @@ android:layout_height="wrap_content" android:layout_centerInParent="true" /> - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 476888c8a754a6958bb2a7568257b7800d3a8c69..fb9ddeec5d40b2bb92774b76fb73c02f134e0d3e 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,7 @@ - - + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000000000000000000000000000000000..80b730f3673e959d84c48dbe457b923bcda888a3 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index c402e3feb9efa131b521651be72c2a40e2b73c80..0abd7b9aef1eeb349120f5ea7cc09164fec65018 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..3cacca24f6bbd89db3d0ca96d6171d22e498780c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7ac653ef931da6b73605e7dae066f6f490954 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..0bec04edce7b671e73026860a203a9530a93fd89 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..2717d05c911ea085137abb8cced628e0af447acc Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 2aa7185580e3d47e30abd92c4694d9877e21e66d..7f7b37920902820d13dd6a6ef777349a9a12ab25 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..76499e569a47cb0879b32919382c62830629a7fa Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..526a405c5764aa12d527092c21affa398a3572ff Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 01b53f26ed5b841272e24d2a269ce66c48f113d8..fba2ba0473f7325e4b862a902f1a19f8c76e8132 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..495a066b0598b59f0ceafa6f2774e352dbc56be5 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..4baae2652887ca25f7e51d45b774a72f207ec193 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index f50fed52275f4387fb1b7ac33354aee4e813482b..387ff43694164b6b5f43348873a49772b5491069 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d12ad0a079346f39e2459278c5a7e21a992092 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..0c826f57c67d457b14b57d77886401695a586001 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000000000000000000000000000000000..73fab6619b2d19bb5d3d6df7ba51fdb58be027fb --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #7CB342 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76bd7dd904bedb816d02e2d05a6c93015c716e5b..b65c718f8f5accb819e556b89c2bf8e4d366f458 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,9 @@ - DAVx⁵ + Account Manager + WebDAV + https://www.davx5.com/ davx5app Google @@ -12,7 +14,7 @@ Account does not exist (anymore) bitfire.at.davdroid at.bitfire.davdroid.address_book - DAVx⁵ Address book + WebDav Address book at.bitfire.davdroid.addressbooks Address books This field is required @@ -270,7 +272,7 @@ Login with URL and client certificate Select certificate Login - Create account + Add account Account name Use of apostrophes (\'), have been reported to cause problems on some devices. Use your email address as account name because Android will use the account name as ORGANIZER field for events you create. You can\'t have two accounts with the same name. diff --git a/app/src/main/res/xml/account_authenticator.xml b/app/src/main/res/xml/account_authenticator.xml index 85eaa203324c6da6ddcfe9e282e59fe55685847e..f32fccaba60b047bc4a2bbb2ad8f315468ca107c 100644 --- a/app/src/main/res/xml/account_authenticator.xml +++ b/app/src/main/res/xml/account_authenticator.xml @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/app/src/main/res/xml/account_authenticator_address_book.xml b/app/src/main/res/xml/account_authenticator_address_book.xml index 6305f31e7429ed7d7db27bfc90a66ce469e78951..e23f971eaf78c62ae3d833c34134ab8a1066c68d 100644 --- a/app/src/main/res/xml/account_authenticator_address_book.xml +++ b/app/src/main/res/xml/account_authenticator_address_book.xml @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/dav4jvm b/dav4jvm index 3e2a8eec827030ee17e81d58bcb97198d13edfff..1d8e599727224bdcaeefd20cd0cc8cdf53f65df2 160000 --- a/dav4jvm +++ b/dav4jvm @@ -1 +1 @@ -Subproject commit 3e2a8eec827030ee17e81d58bcb97198d13edfff +Subproject commit 1d8e599727224bdcaeefd20cd0cc8cdf53f65df2