diff --git a/app/build.gradle b/app/build.gradle index ddf107f9..b3276b71 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,9 +7,9 @@ */ apply plugin: 'com.android.application' -apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' android { compileSdkVersion rootProject.ext.compileSdkVersion @@ -21,8 +21,8 @@ android { minSdkVersion 19 targetSdkVersion 29 - versionCode 114 - versionName "1.16.2" + versionCode 200 + versionName "2.0.0" buildConfigField "boolean", "customCerts", "true" } @@ -134,6 +134,8 @@ dependencies { implementation "org.jetbrains.anko:anko-commons:0.10.4" implementation "com.etesync:journalmanager:1.1.1" + def etebaseVersion = '0.2.0' + implementation "com.etebase:client:$etebaseVersion" def acraVersion = '5.3.0' implementation "ch.acra:acra-mail:$acraVersion" @@ -141,9 +143,16 @@ dependencies { def supportVersion = '1.0.0' implementation "androidx.legacy:legacy-support-core-ui:$supportVersion" implementation "androidx.core:core:$supportVersion" - implementation "androidx.fragment:fragment:$supportVersion" implementation "androidx.appcompat:appcompat:1.1.0" implementation "androidx.cardview:cardview:1.0.0" + // KTX extensions + implementation "androidx.core:core-ktx:1.3.1" + implementation "androidx.fragment:fragment-ktx:1.2.5" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0" + + implementation 'com.google.android.material:material:1.2.0-beta01' implementation "androidx.legacy:legacy-preference-v14:$supportVersion" implementation 'com.github.yukuku:ambilwarna:2.0.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 45c699a0..992ca263 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,7 +127,7 @@ + android:exported="true"> @@ -233,6 +233,14 @@ android:exported="false" android:parentActivityName=".ui.AccountsActivity"> + + { - val db = dbHelper.writableDatabase - val collections = LinkedList() - val cursor = db.query(ServiceDB.Collections._TABLE, null, null, null, null, null, null) - while (cursor.moveToNext()) { - val values = ContentValues() - DatabaseUtils.cursorRowToContentValues(cursor, values) - collections.add(CollectionInfo.fromDB(values)) - } - - db.close() - cursor.close() - return collections - } - fun migrateServices(dbHelper: ServiceDB.OpenHelper) { val db = dbHelper.readableDatabase val data = this.data diff --git a/app/src/main/java/com/etesync/syncadapter/Constants.java b/app/src/main/java/com/etesync/syncadapter/Constants.java index 04dedb82..9c008ab0 100644 --- a/app/src/main/java/com/etesync/syncadapter/Constants.java +++ b/app/src/main/java/com/etesync/syncadapter/Constants.java @@ -32,9 +32,10 @@ public class Constants { public static final Uri dashboard = webUri.buildUpon().appendEncodedPath("dashboard/").build(); public static final Uri faqUri = webUri.buildUpon().appendEncodedPath("faq/").build(); public static final Uri helpUri = webUri.buildUpon().appendEncodedPath("user-guide/android/").build(); - public static final Uri forgotPassword = webUri.buildUpon().appendEncodedPath("accounts/password/reset/").build(); + public static final Uri forgotPassword = faqUri.buildUpon().fragment("forgot-password").build(); public static final Uri serviceUrl = Uri.parse((DEBUG_REMOTE_URL == null) ? "https://api.etesync.com/" : DEBUG_REMOTE_URL); + public static final String etebaseServiceUrl = (DEBUG_REMOTE_URL == null) ? "https://api.etebase.com/partner/etesync/" : DEBUG_REMOTE_URL; public static final String PRODID_BASE = "-//EteSync//" + BuildConfig.APPLICATION_ID + "/" + BuildConfig.VERSION_NAME; @@ -43,4 +44,8 @@ public class Constants { public final static String KEY_ACCOUNT = "account", KEY_COLLECTION_INFO = "collectionInfo"; + + public final static String ETEBASE_TYPE_ADDRESS_BOOK = "etebase.vcard"; + public final static String ETEBASE_TYPE_CALENDAR = "etebase.vevent"; + public final static String ETEBASE_TYPE_TASKS = "etebase.vtodo"; } diff --git a/app/src/main/java/com/etesync/syncadapter/EtebaseLocalCache.kt b/app/src/main/java/com/etesync/syncadapter/EtebaseLocalCache.kt new file mode 100644 index 00000000..b51c2756 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/EtebaseLocalCache.kt @@ -0,0 +1,166 @@ +package com.etesync.syncadapter + +import android.content.Context +import com.etebase.client.* +import com.etebase.client.Collection +import okhttp3.OkHttpClient +import java.io.File +import java.util.* + +/* +File structure: +cache_dir/ + user1/ <--- the name of the user + stoken <-- the stokens of the collection fetch + cols/ + UID1/ - The uid of the first col + ... + UID2/ - The uid of the second col + col <-- the col itself + stoken <-- the stoken of the items fetch + items/ + item_uid1 <-- the item with uid 1 + item_uid2 + ... + */ +class EtebaseLocalCache private constructor(context: Context, username: String) { + private val filesDir: File = File(context.filesDir, username) + private val colsDir: File + + init { + colsDir = File(filesDir, "cols") + colsDir.mkdirs() + } + + private fun getCollectionItemsDir(colUid: String): File { + val colsDir = File(filesDir, "cols") + val colDir = File(colsDir, colUid) + return File(colDir, "items") + } + + fun clearUserCache() { + filesDir.deleteRecursively() + } + + fun saveStoken(stoken: String) { + val stokenFile = File(filesDir, "stoken") + stokenFile.writeText(stoken) + } + + fun loadStoken(): String? { + val stokenFile = File(filesDir, "stoken") + return if (stokenFile.exists()) stokenFile.readText() else null + } + + + fun collectionSaveStoken(colUid: String, stoken: String) { + val colDir = File(colsDir, colUid) + val stokenFile = File(colDir, "stoken") + stokenFile.writeText(stoken) + } + + fun collectionLoadStoken(colUid: String): String? { + val colDir = File(colsDir, colUid) + val stokenFile = File(colDir, "stoken") + return if (stokenFile.exists()) stokenFile.readText() else null + } + + fun collectionList(colMgr: CollectionManager, withDeleted: Boolean = false): List { + return colsDir.list().map { + val colDir = File(colsDir, it) + val colFile = File(colDir, "col") + val content = colFile.readBytes() + colMgr.cacheLoad(content) + }.filter { withDeleted || !it.isDeleted }.map{ + CachedCollection(it, it.meta) + } + } + + fun collectionGet(colMgr: CollectionManager, colUid: String): CachedCollection? { + val colDir = File(colsDir, colUid) + val colFile = File(colDir, "col") + if (!colFile.exists()) { + return null + } + val content = colFile.readBytes() + return colMgr.cacheLoad(content).let { + CachedCollection(it, it.meta) + } + } + + fun collectionSet(colMgr: CollectionManager, collection: Collection) { + val colDir = File(colsDir, collection.uid) + colDir.mkdirs() + val colFile = File(colDir, "col") + colFile.writeBytes(colMgr.cacheSaveWithContent(collection)) + val itemsDir = getCollectionItemsDir(collection.uid) + itemsDir.mkdirs() + } + + fun collectionUnset(colMgr: CollectionManager, colUid: String) { + val colDir = File(colsDir, colUid) + colDir.deleteRecursively() + } + + fun itemList(itemMgr: ItemManager, colUid: String, withDeleted: Boolean = false): List { + val itemsDir = getCollectionItemsDir(colUid) + return itemsDir.list().map { + val itemFile = File(itemsDir, it) + val content = itemFile.readBytes() + itemMgr.cacheLoad(content) + }.filter { withDeleted || !it.isDeleted }.map { + CachedItem(it, it.meta, it.contentString) + } + } + + fun itemGet(itemMgr: ItemManager, colUid: String, itemUid: String): CachedItem? { + val itemsDir = getCollectionItemsDir(colUid) + val itemFile = File(itemsDir, itemUid) + if (!itemFile.exists()) { + return null + } + val content = itemFile.readBytes() + return itemMgr.cacheLoad(content).let { + CachedItem(it, it.meta, it.contentString) + } + } + + fun itemSet(itemMgr: ItemManager, colUid: String, item: Item) { + val itemsDir = getCollectionItemsDir(colUid) + val itemFile = File(itemsDir, item.uid) + itemFile.writeBytes(itemMgr.cacheSaveWithContent(item)) + } + + fun itemUnset(itemMgr: ItemManager, colUid: String, itemUid: String) { + val itemsDir = getCollectionItemsDir(colUid) + val itemFile = File(itemsDir, itemUid) + itemFile.delete() + } + + companion object { + private val localCacheCache: HashMap = HashMap() + + fun getInstance(context: Context, username: String): EtebaseLocalCache { + synchronized(localCacheCache) { + val cached = localCacheCache.get(username) + if (cached != null) { + return cached + } else { + val ret = EtebaseLocalCache(context, username) + localCacheCache.set(username, ret) + return ret + } + } + } + + // FIXME: If we ever cache this we need to cache bust on changePassword + fun getEtebase(context: Context, httpClient: OkHttpClient, settings: AccountSettings): Account { + val client = Client.create(httpClient, settings.uri?.toString()) + return Account.restore(client, settings.etebaseSession!!, null) + } + } +} + +data class CachedCollection(val col: Collection, val meta: CollectionMetadata) + +data class CachedItem(val item: Item, val meta: ItemMetadata, val content: String) \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/HttpClient.kt b/app/src/main/java/com/etesync/syncadapter/HttpClient.kt index c663d3f2..c3574f66 100644 --- a/app/src/main/java/com/etesync/syncadapter/HttpClient.kt +++ b/app/src/main/java/com/etesync/syncadapter/HttpClient.kt @@ -68,7 +68,6 @@ class HttpClient private constructor( ) { private var certManager: CustomCertManager? = null private var certificateAlias: String? = null - private var cache: Cache? = null private val orig = sharedClient.newBuilder() diff --git a/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.kt b/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.kt index 72fbf6ad..9fd97fd6 100644 --- a/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.kt +++ b/app/src/main/java/com/etesync/syncadapter/model/CollectionInfo.kt @@ -48,23 +48,6 @@ class CollectionInfo : com.etesync.journalmanager.model.CollectionInfo() { return info } - fun fromDB(values: ContentValues): CollectionInfo { - val info = CollectionInfo() - info.id = values.getAsLong(Collections.ID)!! - info.serviceID = values.getAsInteger(Collections.SERVICE_ID)!! - - info.uid = values.getAsString(Collections.URL) - info.displayName = values.getAsString(Collections.DISPLAY_NAME) - info.description = values.getAsString(Collections.DESCRIPTION) - - info.color = values.getAsInteger(Collections.COLOR) - - info.timeZone = values.getAsString(Collections.TIME_ZONE) - - info.selected = values.getAsInteger(Collections.SYNC) != 0 - return info - } - fun fromJson(json: String): CollectionInfo { return GsonBuilder().excludeFieldsWithoutExposeAnnotation().create().fromJson(json, CollectionInfo::class.java) } diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalAddressBook.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalAddressBook.kt index a7cf378e..b4788739 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalAddressBook.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalAddressBook.kt @@ -19,7 +19,9 @@ import android.provider.ContactsContract.CommonDataKinds.GroupMembership import android.provider.ContactsContract.Groups import android.provider.ContactsContract.RawContacts import at.bitfire.vcard4android.* +import com.etebase.client.CollectionAccessLevel import com.etesync.syncadapter.App +import com.etesync.syncadapter.CachedCollection import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity @@ -70,6 +72,37 @@ class LocalAddressBook( return addressBook } + fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, cachedCollection: CachedCollection): LocalAddressBook { + val col = cachedCollection.col + val accountManager = AccountManager.get(context) + + val account = Account(accountName(mainAccount, cachedCollection), App.addressBookAccountType) + val userData = initialUserData(mainAccount, col.uid) + Logger.log.log(Level.INFO, "Creating local address book $account", userData) + if (!accountManager.addAccountExplicitly(account, null, userData)) + throw IllegalStateException("Couldn't create address book account") + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + // Android < 7 seems to lose the initial user data sometimes, so set it a second time + // https://forums.bitfire.at/post/11644 + userData.keySet().forEach { key -> + accountManager.setUserData(account, key, userData.getString(key)) + } + } + + + val addressBook = LocalAddressBook(context, account, provider) + ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1) + ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + + val values = ContentValues(2) + values.put(ContactsContract.Settings.SHOULD_SYNC, 1) + values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1) + addressBook.settings = values + addressBook.readOnly = col.accessLevel == CollectionAccessLevel.ReadOnly + + return addressBook + } fun find(context: Context, provider: ContentProviderClient?, mainAccount: Account?) = AccountManager.get(context) .getAccountsByType(App.addressBookAccountType) @@ -103,6 +136,13 @@ class LocalAddressBook( return sb.toString() } + fun accountName(mainAccount: Account, cachedCollection: CachedCollection): String { + val col = cachedCollection.col + val meta = cachedCollection.meta + val displayName = meta.name + return "${displayName} (${mainAccount.name} ${col.uid.substring(0, 2)})" + } + fun initialUserData(mainAccount: Account, url: String): Bundle { val bundle = Bundle(3) bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name) @@ -181,6 +221,37 @@ class LocalAddressBook( ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) } + + fun update(cachedCollection: CachedCollection) { + val col = cachedCollection.col + val newAccountName = accountName(mainAccount, cachedCollection) + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) { + val accountManager = AccountManager.get(context) + val future = accountManager.renameAccount(account, newAccountName, { + try { + // update raw contacts to new account name + if (provider != null) { + val values = ContentValues(1) + values.put(RawContacts.ACCOUNT_NAME, newAccountName) + provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", + arrayOf(account.name, account.type)) + } + } catch (e: RemoteException) { + Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e) + } + }, null) + account = future.result + } + + readOnly = col.accessLevel == CollectionAccessLevel.ReadOnly + Logger.log.info("Address book write permission? = ${!readOnly}") + + // make sure it will still be synchronized when contacts are updated + ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true) + } + fun delete() { val accountManager = AccountManager.get(context) @@ -279,6 +350,16 @@ class LocalAddressBook( } } + override fun findByFilename(filename: String): LocalAddress? { + val found = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(filename)).firstOrNull() + + if (found != null) { + return found + } else { + return queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(filename)).firstOrNull() + } + } + fun findGroupById(id: Long): LocalGroup = queryGroups("${Groups._ID}=?", arrayOf(id.toString())).firstOrNull() ?: throw FileNotFoundException() diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalCalendar.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalCalendar.kt index e72862a3..8017e033 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalCalendar.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalCalendar.kt @@ -18,9 +18,13 @@ import android.os.RemoteException import android.provider.CalendarContract import android.provider.CalendarContract.* import at.bitfire.ical4android.* +import com.etebase.client.CollectionAccessLevel +import com.etesync.syncadapter.CachedCollection import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.resource.LocalEvent.Companion.COLUMN_UID import org.apache.commons.lang3.StringUtils +import org.dmfs.tasks.contract.TaskContract import java.util.* import java.util.logging.Level @@ -31,7 +35,21 @@ class LocalCalendar private constructor( ): AndroidCalendar(account, provider, LocalEvent.Factory, id), LocalCollection { companion object { - val defaultColor = -0x743cb6 // light green 500 + val defaultColor = -0x743cb6 // light green 500 - should be "8BC349"? + + fun parseColor(color_: String?): Int { + if (color_.isNullOrBlank()) { + return defaultColor + } + val color = color_.replaceFirst("^#".toRegex(), "") + if (color.length == 8) { + return (color.substring(0, 6).toLong(16) or (color.substring(6, 8).toLong(16) shl 24)).toInt() + } else if (color.length == 6) { + return (color.toLong(16) or (0xFF000000)).toInt() + } else { + return defaultColor + } + } val COLUMN_CTAG = Calendars.CAL_SYNC1 @@ -50,6 +68,21 @@ class LocalCalendar private constructor( return AndroidCalendar.create(account, provider, values) } + fun create(account: Account, provider: ContentProviderClient, cachedCollection: CachedCollection): Uri { + val values = valuesFromCachedCollection(cachedCollection, true) + + // ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash. + values.put(Calendars.ACCOUNT_NAME, account.name) + values.put(Calendars.ACCOUNT_TYPE, account.type) + values.put(Calendars.OWNER_ACCOUNT, account.name) + + // flag as visible & synchronizable at creation, might be changed by user at any time + values.put(Calendars.VISIBLE, 1) + values.put(Calendars.SYNC_EVENTS, 1) + + return AndroidCalendar.create(account, provider, values) + } + fun findByName(account: Account, provider: ContentProviderClient, factory: Factory, name: String): LocalCalendar? = AndroidCalendar.find(account, provider, factory, Calendars.NAME + "==?", arrayOf(name)).firstOrNull() @@ -85,6 +118,30 @@ class LocalCalendar private constructor( values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", ")) return values } + + private fun valuesFromCachedCollection(cachedCollection: CachedCollection, withColor: Boolean): ContentValues { + val values = ContentValues() + val col = cachedCollection.col + val meta = cachedCollection.meta + values.put(Calendars.NAME, col.uid) + values.put(Calendars.CALENDAR_DISPLAY_NAME, meta.name) + + if (withColor) + values.put(Calendars.CALENDAR_COLOR, parseColor(meta.color)) + + if (col.accessLevel == CollectionAccessLevel.ReadOnly) + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ) + else { + values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) + values.put(Calendars.CAN_MODIFY_TIME_ZONE, 1) + values.put(Calendars.CAN_ORGANIZER_RESPOND, 1) + } + + values.put(Calendars.ALLOWED_REMINDERS, Reminders.METHOD_ALERT) + values.put(Calendars.ALLOWED_AVAILABILITY, StringUtils.join(intArrayOf(Reminders.AVAILABILITY_TENTATIVE, Reminders.AVAILABILITY_FREE, Reminders.AVAILABILITY_BUSY), ",")) + values.put(Calendars.ALLOWED_ATTENDEE_TYPES, StringUtils.join(intArrayOf(CalendarContract.Attendees.TYPE_OPTIONAL, CalendarContract.Attendees.TYPE_REQUIRED, CalendarContract.Attendees.TYPE_RESOURCE), ", ")) + return values + } } override val url: String? @@ -93,6 +150,9 @@ class LocalCalendar private constructor( fun update(journalEntity: JournalEntity, updateColor: Boolean) = update(valuesFromCollectionInfo(journalEntity, updateColor)) + fun update(cachedCollection: CachedCollection, updateColor: Boolean) = + update(valuesFromCachedCollection(cachedCollection, updateColor)) + override fun findDeleted() = queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null) @@ -122,7 +182,10 @@ class LocalCalendar private constructor( = queryEvents(null, null) override fun findByUid(uid: String): LocalEvent? - = queryEvents(Events._SYNC_ID + " =? ", arrayOf(uid)).firstOrNull() + = queryEvents(COLUMN_UID + " =? ", arrayOf(uid)).firstOrNull() + + override fun findByFilename(filename: String): LocalEvent? + = queryEvents(Events._SYNC_ID + " =? ", arrayOf(filename)).firstOrNull() fun processDirtyExceptions() { // process deleted exceptions diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalCollection.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalCollection.kt index 8e9d7a45..1bcee902 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalCollection.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalCollection.kt @@ -17,6 +17,7 @@ interface LocalCollection> { fun findAll(): List fun findByUid(uid: String): T? + fun findByFilename(filename: String): T? fun count(): Long diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalContact.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalContact.kt index 2306c948..b6c9d6a3 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalContact.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalContact.kt @@ -52,7 +52,7 @@ class LocalContact : AndroidContact, LocalAddress { override// The same now val uuid: String? - get() = fileName + get() = contact?.uid override val isLocalOnly: Boolean get() = TextUtils.isEmpty(eTag) @@ -88,9 +88,11 @@ class LocalContact : AndroidContact, LocalAddress { addressBook.provider?.update(rawContactSyncURI(), values, null, null) } - override fun clearDirty(eTag: String) { + override fun clearDirty(eTag: String?) { val values = ContentValues(3) - values.put(AndroidContact.COLUMN_ETAG, eTag) + if (eTag != null) { + values.put(AndroidContact.COLUMN_ETAG, eTag) + } values.put(ContactsContract.RawContacts.DIRTY, 0) if (LocalContact.HASH_HACK) { @@ -105,15 +107,26 @@ class LocalContact : AndroidContact, LocalAddress { this.eTag = eTag } - override fun prepareForUpload() { + override fun legacyPrepareForUpload(fileName_: String?) { val uid = UUID.randomUUID().toString() val values = ContentValues(2) - values.put(AndroidContact.COLUMN_FILENAME, uid) + val fileName = fileName_ ?: uid + values.put(AndroidContact.COLUMN_FILENAME, fileName) + values.put(AndroidContact.COLUMN_UID, uid) + addressBook.provider?.update(rawContactSyncURI(), values, null, null) + + this.fileName = fileName + } + + override fun prepareForUpload(fileName: String, uid: String) { + val values = ContentValues(2) + values.put(AndroidContact.COLUMN_FILENAME, fileName) values.put(AndroidContact.COLUMN_UID, uid) addressBook.provider?.update(rawContactSyncURI(), values, null, null) - fileName = uid + contact?.uid = uid + this.fileName = fileName } override fun populateData(mimeType: String, row: ContentValues) { diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalEvent.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalEvent.kt index 810db86e..dbe3ae44 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalEvent.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalEvent.kt @@ -38,14 +38,14 @@ class LocalEvent : AndroidEvent, LocalResource { private var saveAsDirty = false // When true, the resource will be saved as dirty - private var fileName: String? = null + override var fileName: String? = null var eTag: String? = null var weAreOrganizer = true override val content: String get() { - Logger.log.log(Level.FINE, "Preparing upload of event " + fileName!!, event) + Logger.log.log(Level.FINE, "Preparing upload of event $fileName} ${event}") val os = ByteArrayOutputStream() event?.write(os) @@ -58,7 +58,7 @@ class LocalEvent : AndroidEvent, LocalResource { override// Now the same val uuid: String? - get() = fileName + get() = event?.uid constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?) : super(calendar, event) { this.fileName = fileName @@ -133,7 +133,7 @@ class LocalEvent : AndroidEvent, LocalResource { /* custom queries */ - override fun prepareForUpload() { + override fun legacyPrepareForUpload(fileName_: String?) { var uid: String? = null val c = calendar.provider.query(eventSyncURI(), arrayOf(COLUMN_UID), null, null, null) if (c.moveToNext()) @@ -142,30 +142,42 @@ class LocalEvent : AndroidEvent, LocalResource { uid = UUID.randomUUID().toString() c.close() - val newFileName = uid + val fileName = fileName_ ?: uid val values = ContentValues(2) - values.put(Events._SYNC_ID, newFileName) + values.put(Events._SYNC_ID, fileName) values.put(COLUMN_UID, uid) calendar.provider.update(eventSyncURI(), values, null, null) - fileName = newFileName + this.fileName = fileName val event = this.event if (event != null) event.uid = uid } + override fun prepareForUpload(fileName: String, uid: String) { + val values = ContentValues(2) + values.put(Events._SYNC_ID, fileName) + values.put(COLUMN_UID, uid) + calendar.provider.update(eventSyncURI(), values, null, null) + + event?.uid = uid + this.fileName = fileName + } + override fun resetDeleted() { val values = ContentValues(1) values.put(CalendarContract.Events.DELETED, 0) calendar.provider.update(eventSyncURI(), values, null, null) } - override fun clearDirty(eTag: String) { + override fun clearDirty(eTag: String?) { val values = ContentValues(2) values.put(CalendarContract.Events.DIRTY, 0) - values.put(COLUMN_ETAG, eTag) + if (eTag != null) { + values.put(COLUMN_ETAG, eTag) + } if (event != null) values.put(COLUMN_SEQUENCE, event?.sequence) calendar.provider.update(eventSyncURI(), values, null, null) diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalGroup.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalGroup.kt index b9ffaaa0..6af2801a 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalGroup.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalGroup.kt @@ -120,13 +120,15 @@ class LocalGroup : AndroidGroup, LocalAddress { return values } - override fun clearDirty(eTag: String) { + override fun clearDirty(eTag: String?) { val id = requireNotNull(id) val values = ContentValues(2) values.put(Groups.DIRTY, 0) this.eTag = eTag - values.put(AndroidGroup.COLUMN_ETAG, eTag) + if (eTag != null) { + values.put(AndroidGroup.COLUMN_ETAG, eTag) + } update(values) // update cached group memberships @@ -154,15 +156,26 @@ class LocalGroup : AndroidGroup, LocalAddress { batch.commit() } - override fun prepareForUpload() { + override fun legacyPrepareForUpload(fileName_: String?) { val uid = UUID.randomUUID().toString() val values = ContentValues(2) - values.put(AndroidGroup.COLUMN_FILENAME, uid) + val fileName = fileName_ ?: uid + values.put(AndroidGroup.COLUMN_FILENAME, fileName) values.put(AndroidGroup.COLUMN_UID, uid) update(values) - fileName = uid + this.fileName = fileName + } + + override fun prepareForUpload(fileName: String, uid: String) { + val values = ContentValues(2) + values.put(AndroidGroup.COLUMN_FILENAME, fileName) + values.put(AndroidGroup.COLUMN_UID, uid) + addressBook.provider?.update(groupSyncUri(), values, null, null) + + contact?.uid = uid + this.fileName = fileName } override fun resetDeleted() { diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalResource.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalResource.kt index e275fa49..38aa6b9e 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalResource.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalResource.kt @@ -10,6 +10,7 @@ package com.etesync.syncadapter.resource interface LocalResource { val uuid: String? + val fileName: String? /** True if doesn't exist on server yet, false otherwise. */ val isLocalOnly: Boolean @@ -19,9 +20,11 @@ interface LocalResource { fun delete(): Int - fun prepareForUpload() + fun legacyPrepareForUpload(fileName: String?) - fun clearDirty(eTag: String) + fun prepareForUpload(fileName: String, uid: String) + + fun clearDirty(eTag: String?) fun resetDeleted() } diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalTask.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalTask.kt index 9588a29d..d39d8d4b 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalTask.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalTask.kt @@ -31,7 +31,7 @@ class LocalTask : AndroidTask, LocalResource { private var saveAsDirty = false // When true, the resource will be saved as dirty - private var fileName: String? = null + override var fileName: String? = null var eTag: String? = null override val content: String @@ -49,7 +49,7 @@ class LocalTask : AndroidTask, LocalResource { override// Now the same val uuid: String? - get() = fileName + get() = task?.uid constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?) : super(taskList, task) { @@ -96,7 +96,7 @@ class LocalTask : AndroidTask, LocalResource { /* custom queries */ - override fun prepareForUpload() { + override fun legacyPrepareForUpload(fileName_: String?) { var uid: String? = null val c = taskList.provider.client.query(taskSyncURI(), arrayOf(COLUMN_UID), null, null, null) if (c.moveToNext()) @@ -106,27 +106,40 @@ class LocalTask : AndroidTask, LocalResource { c.close() + val fileName = fileName_ ?: uid val values = ContentValues(2) - values.put(TaskContract.Tasks._SYNC_ID, uid) + values.put(TaskContract.Tasks._SYNC_ID, fileName) values.put(COLUMN_UID, uid) taskList.provider.client.update(taskSyncURI(), values, null, null) - fileName = uid + this.fileName = fileName val task = this.task if (task != null) task.uid = uid } + override fun prepareForUpload(fileName: String, uid: String) { + val values = ContentValues(2) + values.put(TaskContract.Tasks._SYNC_ID, fileName) + values.put(COLUMN_UID, uid) + taskList.provider.client.update(taskSyncURI(), values, null, null) + + task?.uid = uid + this.fileName = fileName + } + override fun resetDeleted() { val values = ContentValues(1) values.put(TaskContract.Tasks._DELETED, 0) taskList.provider.client.update(taskSyncURI(), values, null, null) } - override fun clearDirty(eTag: String) { + override fun clearDirty(eTag: String?) { val values = ContentValues(2) values.put(TaskContract.Tasks._DIRTY, 0) - values.put(COLUMN_ETAG, eTag) + if (eTag != null) { + values.put(COLUMN_ETAG, eTag) + } if (task != null) values.put(COLUMN_SEQUENCE, task?.sequence) taskList.provider.client.update(taskSyncURI(), values, null, null) diff --git a/app/src/main/java/com/etesync/syncadapter/resource/LocalTaskList.kt b/app/src/main/java/com/etesync/syncadapter/resource/LocalTaskList.kt index e061781b..251f9506 100644 --- a/app/src/main/java/com/etesync/syncadapter/resource/LocalTaskList.kt +++ b/app/src/main/java/com/etesync/syncadapter/resource/LocalTaskList.kt @@ -19,6 +19,7 @@ import at.bitfire.ical4android.AndroidTaskListFactory import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName +import com.etesync.syncadapter.CachedCollection import com.etesync.syncadapter.model.JournalEntity import org.dmfs.tasks.contract.TaskContract.TaskLists import org.dmfs.tasks.contract.TaskContract.Tasks @@ -54,6 +55,14 @@ class LocalTaskList private constructor( return create(account, provider, values) } + fun create(account: Account, provider: TaskProvider, cachedCollection: CachedCollection): Uri { + val values = valuesFromCachedCollection(cachedCollection, true) + values.put(TaskLists.OWNER, account.name) + values.put(TaskLists.SYNC_ENABLED, 1) + values.put(TaskLists.VISIBLE, 1) + return create(account, provider, values) + } + fun findByName(account: Account, provider: TaskProvider, factory: Factory, name: String): LocalTaskList? = AndroidTaskList.find(account, provider, factory, TaskLists._SYNC_ID + "==?", arrayOf(name)).firstOrNull() @@ -70,6 +79,18 @@ class LocalTaskList private constructor( return values } + private fun valuesFromCachedCollection(cachedCollection: CachedCollection, withColor: Boolean): ContentValues { + val col = cachedCollection.col + val meta = cachedCollection.meta + val values = ContentValues(3) + values.put(TaskLists._SYNC_ID, col.uid) + values.put(TaskLists.LIST_NAME, meta.name) + + if (withColor) + values.put(TaskLists.LIST_COLOR, LocalCalendar.parseColor(meta.color)) + + return values + } } override val url: String? @@ -78,6 +99,9 @@ class LocalTaskList private constructor( fun update(journalEntity: JournalEntity, updateColor: Boolean) = update(valuesFromCollectionInfo(journalEntity, updateColor)) + fun update(cachedCollection: CachedCollection, updateColor: Boolean) = + update(valuesFromCachedCollection(cachedCollection, updateColor)) + override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null) override fun findDirty(limit: Int?): List { @@ -101,7 +125,10 @@ class LocalTaskList private constructor( = queryTasks(Tasks._SYNC_ID + " IS NULL", null) override fun findByUid(uid: String): LocalTask? - = queryTasks(Tasks._SYNC_ID + " =? ", arrayOf(uid)).firstOrNull() + = queryTasks(LocalTask.COLUMN_UID + " =? ", arrayOf(uid)).firstOrNull() + + override fun findByFilename(filename: String): LocalTask? + = queryTasks(Tasks._SYNC_ID + " =? ", arrayOf(filename)).firstOrNull() override fun count(): Long { try { diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt index 9233eaab..67e7a300 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt @@ -15,10 +15,7 @@ import android.content.* import android.os.Bundle import android.provider.ContactsContract import at.bitfire.vcard4android.ContactsStorageException -import com.etesync.syncadapter.AccountSettings -import com.etesync.syncadapter.App -import com.etesync.syncadapter.Constants -import com.etesync.syncadapter.R +import com.etesync.syncadapter.* import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity @@ -36,9 +33,6 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { private class AddressBooksSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { - override val syncErrorTitle = R.string.sync_error_contacts - override val notificationManager = SyncNotification(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC) - override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) if (contactsProvider == null) { @@ -53,7 +47,11 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run() - updateLocalAddressBooks(contactsProvider, account) + if (settings.isLegacy) { + legacyUpdateLocalAddressBooks(contactsProvider, account) + } else { + updateLocalAddressBooks(contactsProvider, account, settings) + } contactsProvider.release() @@ -63,15 +61,57 @@ class AddressBooksSyncAdapterService : SyncAdapterService() { val syncExtras = Bundle(extras) syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true) syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true) + syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue) ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras) } Logger.log.info("Address book sync complete") } + private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val remote = HashMap() + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + val collections: List + synchronized(etebaseLocalCache) { + val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() + val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) + val colMgr = etebase.collectionManager + + collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_ADDRESS_BOOK } + } + + for (collection in collections) { + remote[collection.col.uid] = collection + } + + val local = LocalAddressBook.find(context, provider, account) + + // delete obsolete local calendar + for (addressBook in local) { + val url = addressBook.url + val collection = remote[url] + if (collection == null) { + Logger.log.fine("Deleting obsolete local addressBook $url") + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.fine("Updating local addressBook $url") + addressBook.update(collection) + // we already have a local addressBook for this remote collection, don't take into consideration anymore + remote.remove(url) + } + } + + // create new local calendars + for (url in remote.keys) { + val cachedCollection = remote[url]!! + Logger.log.info("Adding local calendar list $cachedCollection") + LocalAddressBook.create(context, provider, account, cachedCollection) + } + } @Throws(ContactsStorageException::class, AuthenticatorException::class, OperationCanceledException::class, IOException::class) - private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) { + private fun legacyUpdateLocalAddressBooks(provider: ContentProviderClient, account: Account) { val context = context val data = (getContext().applicationContext as App).data val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.ADDRESS_BOOK) diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt index b65b942a..ad266529 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt @@ -17,6 +17,7 @@ import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.Event import at.bitfire.ical4android.InvalidCalendarException import at.bitfire.vcard4android.ContactsStorageException +import com.etebase.client.Item import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.Constants import com.etesync.syncadapter.R @@ -43,7 +44,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra get() = context.getString(R.string.sync_error_calendar, account.name) override val syncSuccessfullyTitle: String - get() = context.getString(R.string.sync_successfully_calendar, info.displayName, + get() = context.getString(R.string.sync_successfully_calendar, localCalendar().displayName, account.name) init { @@ -59,7 +60,9 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra if (!super.prepare()) return false - journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!) + if (isLegacy) { + journal = JournalEntryManager(httpClient.okHttpClient, remote, localCalendar().name!!) + } return true } @@ -77,6 +80,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra return localCollection as LocalCalendar } + override fun processItem(item: Item) { + val local = localCollection!!.findByFilename(item.uid) + + if (!item.isDeleted) { + val inputReader = StringReader(String(item.content)) + + val events = Event.eventsFromReader(inputReader) + if (events.size == 0) { + Logger.log.warning("Received VCard without data, ignoring") + return + } else if (events.size > 1) { + Logger.log.warning("Received multiple VCALs, using first one") + } + + val event = events[0] + processEvent(item, event, local) + } else { + if (local != null) { + Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") + local.delete() + } else { + Logger.log.warning("Tried deleting a non-existent record: " + item.uid) + } + } + } + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class) override fun processSyncEntryImpl(cEntry: SyncEntry) { val inputReader = StringReader(cEntry.content) @@ -93,7 +122,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra val local = localCollection!!.findByUid(event.uid!!) if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { - processEvent(event, local) + legacyProcessEvent(event, local) } else { if (local != null) { Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") @@ -105,8 +134,8 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra } @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class) - override fun createLocalEntries() { - super.createLocalEntries() + override fun prepareLocal() { + super.prepareLocal() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { createInviteAttendeesNotification() @@ -128,7 +157,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra private fun createInviteAttendeesNotification(event: Event, icsContent: String) { val intent = EventEmailInvitation(context, account).createIntent(event, icsContent) if (intent != null) { - val notificationHelper = SyncNotification(context, event.uid!!, event.uid!!.hashCode()) + val notificationHelper = SyncNotification(context, icsContent, event.hashCode()) notificationHelper.notify( context.getString( R.string.sync_calendar_attendees_notification_title, event.summary), @@ -138,8 +167,26 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra } } + private fun processEvent(item: Item, newData: Event, _localEvent: LocalEvent?): LocalEvent { + var localEvent = _localEvent + // delete local event, if it exists + if (localEvent != null) { + Logger.log.info("Updating " + newData.uid + " in local calendar") + localEvent.eTag = item.etag + localEvent.update(newData) + syncResult.stats.numUpdates++ + } else { + Logger.log.info("Adding " + newData.uid + " to local calendar") + localEvent = LocalEvent(localCalendar(), newData, item.uid, item.etag) + localEvent.add() + syncResult.stats.numInserts++ + } + + return localEvent + } + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class) - private fun processEvent(newData: Event, _localEvent: LocalEvent?): LocalEvent { + private fun legacyProcessEvent(newData: Event, _localEvent: LocalEvent?): LocalEvent { var localEvent = _localEvent // delete local event, if it exists if (localEvent != null) { diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt index c377a886..8dc159e9 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt @@ -13,10 +13,7 @@ import android.os.Bundle import android.provider.CalendarContract import at.bitfire.ical4android.AndroidCalendar import at.bitfire.ical4android.CalendarStorageException -import com.etesync.syncadapter.AccountSettings -import com.etesync.syncadapter.App -import com.etesync.syncadapter.Constants -import com.etesync.syncadapter.R +import com.etesync.syncadapter.* import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity @@ -32,9 +29,6 @@ class CalendarsSyncAdapterService : SyncAdapterService() { private class SyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { - override val syncErrorTitle = R.string.sync_error_calendar - override val notificationManager = SyncNotification(context, "journals-calendar", Constants.NOTIFICATION_CALENDAR_SYNC) - override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { val settings = AccountSettings(context, account) if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) @@ -42,7 +36,11 @@ class CalendarsSyncAdapterService : SyncAdapterService() { RefreshCollections(account, CollectionInfo.Type.CALENDAR).run() - updateLocalCalendars(provider, account, settings) + if (settings.isLegacy) { + legacyUpdateLocalCalendars(provider, account, settings) + } else { + updateLocalCalendars(provider, account, settings) + } val principal = settings.uri?.toHttpUrlOrNull()!! @@ -56,8 +54,52 @@ class CalendarsSyncAdapterService : SyncAdapterService() { Logger.log.info("Calendar sync complete") } - @Throws(CalendarStorageException::class) private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val remote = HashMap() + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + val collections: List + synchronized(etebaseLocalCache) { + val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() + val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) + val colMgr = etebase.collectionManager + + collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_CALENDAR } + } + + for (collection in collections) { + remote[collection.col.uid] = collection + } + + val local = AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null) + + val updateColors = settings.manageCalendarColors + + // delete obsolete local calendar + for (calendar in local) { + val url = calendar.name + val collection = remote[url] + if (collection == null) { + Logger.log.fine("Deleting obsolete local calendar $url") + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.fine("Updating local calendar $url") + calendar.update(collection, updateColors) + // we already have a local calendar for this remote collection, don't take into consideration anymore + remote.remove(url) + } + } + + // create new local calendars + for (url in remote.keys) { + val cachedCollection = remote[url]!! + Logger.log.info("Adding local calendar list $cachedCollection") + LocalCalendar.create(account, provider, cachedCollection) + } + } + + @Throws(CalendarStorageException::class) + private fun legacyUpdateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { val data = (context.applicationContext as App).data val service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.CALENDAR) diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt index 52dda953..43be2773 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt @@ -27,9 +27,6 @@ class ContactsSyncAdapterService : SyncAdapterService() { private class ContactsSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { - override val syncErrorTitle = R.string.sync_error_contacts - override val notificationManager = SyncNotification(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC) - override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { val addressBook = LocalAddressBook(context, account, provider) diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt index 8bc2a875..e58f864b 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt @@ -19,6 +19,7 @@ import at.bitfire.vcard4android.ContactsStorageException import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.JournalEntryManager import com.etesync.journalmanager.model.SyncEntry +import com.etebase.client.Item import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.Constants import com.etesync.syncadapter.HttpClient @@ -77,7 +78,9 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra } } - journal = JournalEntryManager(httpClient.okHttpClient, remote, localAddressBook.url) + if (isLegacy) { + journal = JournalEntryManager(httpClient.okHttpClient, remote, localAddressBook.url) + } return true } @@ -127,6 +130,32 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra return localCollection as LocalAddressBook } + override fun processItem(item: Item) { + val local = localCollection!!.findByFilename(item.uid) + + if (!item.isDeleted) { + val inputReader = StringReader(String(item.content)) + + val contacts = Contact.fromReader(inputReader, resourceDownloader) + if (contacts.size == 0) { + Logger.log.warning("Received VCard without data, ignoring") + return + } else if (contacts.size > 1) { + Logger.log.warning("Received multiple VCALs, using first one") + } + + val contact = contacts[0] + processContact(item, contact, local) + } else { + if (local != null) { + Logger.log.info("Removing local record which has been deleted on the server") + local.delete() + } else { + Logger.log.warning("Tried deleting a non-existent record: " + item.uid) + } + } + } + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class) override fun processSyncEntryImpl(cEntry: SyncEntry) { val inputReader = StringReader(cEntry.content) @@ -142,7 +171,7 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra val local = localCollection!!.findByUid(contact.uid!!) if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { - processContact(contact, local) + legacyProcessContact(contact, local) } else { if (local != null) { Logger.log.info("Removing local record which has been deleted on the server") @@ -153,8 +182,65 @@ constructor(context: Context, account: Account, settings: AccountSettings, extra } } + private fun processContact(item: Item, newData: Contact, _local: LocalAddress?): LocalAddress { + var local = _local + val uuid = newData.uid + // update local contact, if it exists + if (local != null) { + Logger.log.log(Level.INFO, "Updating $uuid in local address book") + + if (local is LocalGroup && newData.group) { + // update group + val group: LocalGroup = local + group.eTag = item.etag + group.update(newData) + syncResult.stats.numUpdates++ + + } else if (local is LocalContact && !newData.group) { + // update contact + val contact: LocalContact = local + contact.eTag = item.etag + contact.update(newData) + syncResult.stats.numUpdates++ + + } else { + // group has become an individual contact or vice versa + try { + local.delete() + local = null + } catch (e: CalendarStorageException) { + // CalendarStorageException is not used by LocalGroup and LocalContact + } + + } + } + + if (local == null) { + if (newData.group) { + Logger.log.log(Level.INFO, "Creating local group", item.uid) + val group = LocalGroup(localAddressBook(), newData, item.uid, item.etag) + group.add() + + local = group + } else { + Logger.log.log(Level.INFO, "Creating local contact", item.uid) + val contact = LocalContact(localAddressBook(), newData, item.uid, item.etag) + contact.add() + + local = contact + } + syncResult.stats.numInserts++ + } + + if (LocalContact.HASH_HACK && local is LocalContact) + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + local.updateHashCode(null) + + return local + } + @Throws(IOException::class, ContactsStorageException::class) - private fun processContact(newData: Contact, _local: LocalAddress?): LocalAddress { + private fun legacyProcessContact(newData: Contact, _local: LocalAddress?): LocalAddress { var local = _local val uuid = newData.uid // update local contact, if it exists diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt index 45c228d2..63b2273b 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt @@ -22,11 +22,14 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.util.Pair import at.bitfire.ical4android.CalendarStorageException import at.bitfire.vcard4android.ContactsStorageException +import com.etebase.client.FetchOptions +import com.etebase.client.exceptions.ConnectionException +import com.etebase.client.exceptions.TemporaryServerErrorException +import com.etebase.client.exceptions.UnauthorizedException import com.etesync.syncadapter.* import com.etesync.journalmanager.Crypto import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.JournalManager -import com.etesync.syncadapter.* import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity @@ -96,8 +99,8 @@ abstract class SyncAdapterService : Service() { } abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) { - abstract val syncErrorTitle: Int - abstract val notificationManager: SyncNotification + private val syncErrorTitle: Int = R.string.sync_error_generic + private val notificationManager = SyncNotification(context, "refresh-collections", Constants.NOTIFICATION_REFRESH_COLLECTIONS) abstract fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) @@ -117,6 +120,12 @@ abstract class SyncAdapterService : Service() { } catch (e: Exceptions.ServiceUnavailableException) { syncResult.stats.numIoExceptions++ syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY + } catch (e: TemporaryServerErrorException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY + } catch (e: ConnectionException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY } catch (e: Exceptions.IgnorableHttpException) { // Ignore } catch (e: Exception) { @@ -132,7 +141,7 @@ abstract class SyncAdapterService : Service() { val detailsIntent = notificationManager.detailsIntent detailsIntent.putExtra(Constants.KEY_ACCOUNT, account) - if (e !is Exceptions.UnauthorizedException) { + if (e !is Exceptions.UnauthorizedException && e !is UnauthorizedException) { detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) } @@ -208,30 +217,70 @@ abstract class SyncAdapterService : Service() { val settings = AccountSettings(context, account) val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() - val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!) - - var journals = journalFetcher.list(journalsManager, settings, serviceType) - - if (journals.isEmpty()) { - journals = LinkedList() - try { - val info = CollectionInfo.defaultForServiceType(serviceType) - val uid = JournalManager.Journal.genUid() - info.uid = uid - val crypto = Crypto.CryptoManager(info.version, settings.password(), uid) - val journal = JournalManager.Journal(crypto, info.toJson(), uid) - journalsManager.create(journal) - journals.add(Pair(journal, info)) - } catch (e: Exceptions.AssociateNotAllowedException) { - // Skip for now + if (settings.isLegacy) { + val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!) + + var journals = journalFetcher.list(journalsManager, settings, serviceType) + + if (journals.isEmpty()) { + journals = LinkedList() + try { + val info = CollectionInfo.defaultForServiceType(serviceType) + val uid = JournalManager.Journal.genUid() + info.uid = uid + val crypto = Crypto.CryptoManager(info.version, settings.password(), uid) + val journal = JournalManager.Journal(crypto, info.toJson(), uid) + journalsManager.create(journal) + journals.add(Pair(journal, info)) + } catch (e: Exceptions.AssociateNotAllowedException) { + // Skip for now + } + } + + legacySaveCollections(journals) + + httpClient.close() + return + } + + + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + synchronized(etebaseLocalCache) { + val cacheAge = 5 * 1000 // 5 seconds - it's just a hack for burst fetching + val now = System.currentTimeMillis() + val lastCollectionsFetch = collectionLastFetchMap[account.name] ?: 0 + + if (abs(now - lastCollectionsFetch) <= cacheAge) { + return@synchronized + } + + val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) + val colMgr = etebase.collectionManager + var stoken = etebaseLocalCache.loadStoken() + var done = false + while (!done) { + val colList = colMgr.list(FetchOptions().stoken(stoken)) + for (col in colList.data) { + etebaseLocalCache.collectionSet(colMgr, col) + } + + for (col in colList.removedMemberships) { + etebaseLocalCache.collectionUnset(colMgr, col.uid()) + } + + stoken = colList.stoken + done = colList.isDone + if (stoken != null) { + etebaseLocalCache.saveStoken(stoken) + } } + collectionLastFetchMap[account.name] = now } - saveCollections(journals) httpClient.close() } - private fun saveCollections(journals: Iterable>) { + private fun legacySaveCollections(journals: Iterable>) { val data = (context.applicationContext as App).data val service = JournalModel.Service.fetchOrCreate(data, account.name, serviceType) @@ -269,5 +318,6 @@ abstract class SyncAdapterService : Service() { companion object { val journalFetcher = CachedJournalFetcher() + var collectionLastFetchMap = HashMap() } } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt index b6f230e0..0722be42 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt @@ -16,6 +16,11 @@ import android.os.Bundle import at.bitfire.ical4android.CalendarStorageException import at.bitfire.ical4android.InvalidCalendarException import at.bitfire.vcard4android.ContactsStorageException +import com.etebase.client.* +import com.etebase.client.exceptions.ConnectionException +import com.etebase.client.exceptions.HttpException +import com.etebase.client.exceptions.TemporaryServerErrorException +import com.etebase.client.exceptions.UnauthorizedException import com.etesync.syncadapter.* import com.etesync.syncadapter.Constants.KEY_ACCOUNT import com.etesync.journalmanager.Crypto @@ -25,10 +30,13 @@ import com.etesync.journalmanager.model.SyncEntry import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.* import com.etesync.journalmanager.model.SyncEntry.Actions.ADD +import com.etesync.syncadapter.HttpClient +import com.etesync.syncadapter.R import com.etesync.syncadapter.resource.* import com.etesync.syncadapter.ui.AccountsActivity import com.etesync.syncadapter.ui.DebugInfoActivity import com.etesync.syncadapter.ui.ViewCollectionActivity +import com.etesync.syncadapter.ui.etebase.CollectionActivity import org.jetbrains.anko.defaultSharedPreferences import java.io.Closeable import java.io.FileNotFoundException @@ -41,21 +49,35 @@ import kotlin.concurrent.withLock abstract class SyncManager> @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class) constructor(protected val context: Context, protected val account: Account, protected val settings: AccountSettings, protected val extras: Bundle, protected val authority: String, protected val syncResult: SyncResult, journalUid: String, protected val serviceType: CollectionInfo.Type, accountName: String): Closeable { + // FIXME: remove all of the lateinit once we remove legacy (and make immutable) + // RemoteEntries and the likes are probably also just relevant for legacy + protected val isLegacy: Boolean = settings.isLegacy protected val notificationManager: SyncNotification - protected val info: CollectionInfo + protected lateinit var info: CollectionInfo protected var localCollection: LocalCollection? = null protected var httpClient: HttpClient + protected lateinit var etebaseLocalCache: EtebaseLocalCache + protected lateinit var etebase: com.etebase.client.Account + protected lateinit var colMgr: CollectionManager + protected lateinit var itemMgr: ItemManager + protected lateinit var cachedCollection: CachedCollection + + // Sync counters + private var syncItemsTotal = 0 + private var syncItemsDeleted = 0 + private var syncItemsChanged = 0 + protected var journal: JournalEntryManager? = null private var _journalEntity: JournalEntity? = null private var numDiscarded = 0 - private val crypto: Crypto.CryptoManager + private lateinit var crypto: Crypto.CryptoManager - private val data: MyEntityDataStore + private lateinit var data: MyEntityDataStore /** * remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works. @@ -89,21 +111,31 @@ constructor(protected val context: Context, protected val account: Account, prot // create HttpClient with given logger httpClient = HttpClient.Builder(context, settings).setForeground(false).build() - data = (context.applicationContext as App).data - val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType) - info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info + if (isLegacy) { + data = (context.applicationContext as App).data + val serviceEntity = JournalModel.Service.fetchOrCreate(data, accountName, serviceType) + info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info - // dismiss previous error notifications - notificationManager = SyncNotification(context, journalUid, notificationId()) - notificationManager.cancel() + Logger.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version)) - Logger.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version)) - - if (journalEntity.encryptedKey != null) { - crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey) + if (journalEntity.encryptedKey != null) { + crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey) + } else { + crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid!!) + } } else { - crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid!!) + etebaseLocalCache = EtebaseLocalCache.getInstance(context, accountName) + etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) + colMgr = etebase.collectionManager + synchronized(etebaseLocalCache) { + cachedCollection = etebaseLocalCache.collectionGet(colMgr, journalUid)!! + } + itemMgr = colMgr.getItemManager(cachedCollection.col) } + + // dismiss previous error notifications + notificationManager = SyncNotification(context, journalUid, notificationId()) + notificationManager.cancel() } protected abstract fun notificationId(): Int @@ -114,6 +146,10 @@ constructor(protected val context: Context, protected val account: Account, prot @TargetApi(21) fun performSync() { + syncItemsTotal = 0 + syncItemsDeleted = 0 + syncItemsChanged = 0 + var syncPhase = R.string.sync_phase_prepare try { Logger.log.info("Sync phase: " + context.getString(syncPhase)) @@ -128,48 +164,101 @@ constructor(protected val context: Context, protected val account: Account, prot Logger.log.info("Sync phase: " + context.getString(syncPhase)) prepareFetch() - do { - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_fetch_entries - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - fetchEntries() - - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_apply_remote_entries - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - applyRemoteEntries() - } while (remoteEntries!!.size == MAX_FETCH) - - do { - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_prepare_local - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - prepareLocal() - - /* Create journal entries out of local changes. */ - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_create_local_entries - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - createLocalEntries() - - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_apply_local_entries - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - /* FIXME: Skipping this now, because we already override with remote. - applyLocalEntries(); - */ - - if (Thread.interrupted()) - throw InterruptedException() - syncPhase = R.string.sync_phase_push_entries - Logger.log.info("Sync phase: " + context.getString(syncPhase)) - pushEntries() - } while (localEntries!!.size == MAX_PUSH) + if (isLegacy) { + do { + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_fetch_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + fetchEntries() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_apply_remote_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + applyRemoteEntries() + } while (remoteEntries!!.size == MAX_FETCH) + + do { + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_prepare_local + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + prepareLocal() + + /* Create journal entries out of local changes. */ + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_create_local_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + createLocalEntries() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_apply_local_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + /* FIXME: Skipping this now, because we already override with remote. + applyLocalEntries(); + */ + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_push_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + pushEntries() + } while (localEntries!!.size == MAX_PUSH) + } else { + var itemList: ItemListResponse? + var stoken = synchronized(etebaseLocalCache) { + etebaseLocalCache.collectionLoadStoken(cachedCollection.col.uid) + } + // Push local changes + var chunkPushItems: List + do { + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_prepare_local + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + prepareLocal() + + /* Create push items out of local changes. */ + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_create_local_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + chunkPushItems = createPushItems() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_push_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + pushItems(chunkPushItems) + } while (chunkPushItems.size == MAX_PUSH) + + do { + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_fetch_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + itemList = fetchItems(stoken) + if (itemList == null) { + break + } + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_apply_remote_entries + Logger.log.info("Sync phase: " + context.getString(syncPhase)) + applyRemoteItems(itemList) + + stoken = itemList.stoken + if (stoken != null) { + synchronized(etebaseLocalCache) { + etebaseLocalCache.collectionSaveStoken(cachedCollection.col.uid, stoken) + } + } + } while (!itemList!!.isDone) + } /* Cleanup and finalize changes */ if (Thread.interrupted()) @@ -187,6 +276,11 @@ constructor(protected val context: Context, protected val account: Account, prot } catch (e: SSLHandshakeException) { syncResult.stats.numIoExceptions++ + notificationManager.setThrowable(e) + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + notificationManager.notify(syncErrorTitle, context.getString(syncPhase)) + } catch (e: FileNotFoundException) { notificationManager.setThrowable(e) val detailsIntent = notificationManager.detailsIntent detailsIntent.putExtra(KEY_ACCOUNT, account) @@ -197,15 +291,21 @@ constructor(protected val context: Context, protected val account: Account, prot } catch (e: Exceptions.ServiceUnavailableException) { syncResult.stats.numIoExceptions++ syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY + } catch (e: TemporaryServerErrorException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY + } catch (e: ConnectionException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY } catch (e: InterruptedException) { // Restart sync if interrupted syncResult.fullSyncRequested = true } catch (e: Exceptions.IgnorableHttpException) { // Ignore } catch (e: Exception) { - if (e is Exceptions.UnauthorizedException) { + if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) { syncResult.stats.numAuthExceptions++ - } else if (e is Exceptions.HttpException) { + } else if (e is Exceptions.HttpException || e is HttpException) { syncResult.stats.numParseExceptions++ } else if (e is CalendarStorageException || e is ContactsStorageException) { syncResult.databaseError = true @@ -241,38 +341,28 @@ constructor(protected val context: Context, protected val account: Account, prot private fun notifyUserOnSync() { val changeNotification = context.defaultSharedPreferences.getBoolean(App.CHANGE_NOTIFICATION, true) - if (remoteEntries!!.isEmpty() || !changeNotification) { + + if (!changeNotification || (syncItemsTotal == 0)) { return } + val notificationHelper = SyncNotification(context, System.currentTimeMillis().toString(), notificationId()) - - var deleted = 0 - var added = 0 - var changed = 0 - for (entry in remoteEntries!!) { - val cEntry = SyncEntry.fromJournalEntry(crypto, entry) - val action = cEntry.action - when (action) { - ADD -> added++ - SyncEntry.Actions.DELETE -> deleted++ - SyncEntry.Actions.CHANGE -> changed++ - } - } - val resources = context.resources - val intent = ViewCollectionActivity.newIntent(context, account, info) + val intent = if (isLegacy) { + ViewCollectionActivity.newIntent(context, account, info) + } else { + CollectionActivity.newIntent(context, account, cachedCollection.col.uid) + } notificationHelper.notify(syncSuccessfullyTitle, String.format(context.getString(R.string.sync_successfully_modified), resources.getQuantityString(R.plurals.sync_successfully, - remoteEntries!!.size, remoteEntries!!.size)), + syncItemsTotal, syncItemsTotal)), String.format(context.getString(R.string.sync_successfully_modified_full), resources.getQuantityString(R.plurals.sync_successfully, - added, added), + syncItemsChanged, syncItemsChanged), resources.getQuantityString(R.plurals.sync_successfully, - changed, changed), - resources.getQuantityString(R.plurals.sync_successfully, - deleted, deleted)), + syncItemsDeleted, syncItemsDeleted)), intent) } @@ -287,6 +377,25 @@ constructor(protected val context: Context, protected val account: Account, prot return true } + protected abstract fun processItem(item: Item) + + private fun persistItem(item: Item) { + synchronized(etebaseLocalCache) { + // FIXME: it's terrible that we are fetching and decrypting the item here - we really don't have to + val cached = etebaseLocalCache.itemGet(itemMgr, cachedCollection.col.uid, item.uid) + if (cached?.item?.etag != item.etag) { + syncItemsTotal++ + + if (item.isDeleted) { + syncItemsDeleted++ + } else { + syncItemsChanged++ + } + etebaseLocalCache.itemSet(itemMgr, cachedCollection.col.uid, item) + } + } + } + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class) protected abstract fun processSyncEntryImpl(cEntry: SyncEntry) @@ -317,38 +426,54 @@ constructor(protected val context: Context, protected val account: Account, prot throw e } } + + when (syncEntry.action) { + ADD -> syncItemsChanged++ + SyncEntry.Actions.DELETE -> syncItemsDeleted++ + SyncEntry.Actions.CHANGE -> syncItemsChanged++ + } } - @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.HttpException::class, InvalidCalendarException::class, InterruptedException::class) - protected fun applyLocalEntries() { - // FIXME: Need a better strategy - // We re-apply local entries so our changes override whatever was written in the remote. - val strTotal = localEntries!!.size.toString() + @Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class) + protected fun prepareFetch() { + if (isLegacy) { + remoteCTag = journalEntity.getLastUid(data) + } else { + remoteCTag = cachedCollection.col.stoken + } + } + + private fun fetchItems(stoken: String?): ItemListResponse? { + if (remoteCTag != stoken) { + val ret = itemMgr.list(FetchOptions().stoken(stoken)) + Logger.log.info("Fetched items. Done=${ret.isDone}") + return ret + } else { + Logger.log.info("Skipping fetch because local stoken == lastStoken (${remoteCTag})") + return null + } + } + + private fun applyRemoteItems(itemList: ItemListResponse) { + val items = itemList.data + // Process new vcards from server + val size = items.size var i = 0 - for (entry in localEntries!!) { + for (item in items) { if (Thread.interrupted()) { throw InterruptedException() } i++ - Logger.log.info("Processing (" + i.toString() + "/" + strTotal + ") " + entry.toString()) + Logger.log.info("Processing (${i}/${size}) UID=${item.uid} Etag=${item.etag}") - val cEntry = SyncEntry.fromJournalEntry(crypto, entry) - if (cEntry.isAction(SyncEntry.Actions.DELETE)) { - continue - } - Logger.log.info("Processing resource for journal entry") - processSyncEntry(cEntry) + processItem(item) + persistItem(item) } } - @Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class) - protected fun prepareFetch() { - remoteCTag = journalEntity.getLastUid(data) - } - @Throws(Exceptions.HttpException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.IntegrityException::class) - protected fun fetchEntries() { + private fun fetchEntries() { val count = data.count(EntryEntity::class.java).where(EntryEntity.JOURNAL.eq(journalEntity)).get().value() if (remoteCTag != null && count == 0) { // If we are updating an existing installation with no saved journal, we need to add @@ -377,11 +502,13 @@ constructor(protected val context: Context, protected val account: Account, prot } @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class, InterruptedException::class) - protected fun applyRemoteEntries() { + private fun applyRemoteEntries() { // Process new vcards from server val strTotal = remoteEntries!!.size.toString() var i = 0 + syncItemsTotal += remoteEntries!!.size + for (entry in remoteEntries!!) { if (Thread.interrupted()) { throw InterruptedException() @@ -406,7 +533,7 @@ constructor(protected val context: Context, protected val account: Account, prot } @Throws(Exceptions.HttpException::class, IOException::class, ContactsStorageException::class, CalendarStorageException::class) - protected fun pushEntries() { + private fun pushEntries() { // upload dirty contacts var pushed = 0 // FIXME: Deal with failure (someone else uploaded before we go here) @@ -456,8 +583,128 @@ constructor(protected val context: Context, protected val account: Account, prot } } + private fun pushItems(chunkPushItems_: List) { + var chunkPushItems = chunkPushItems_ + // upload dirty contacts + var pushed = 0 + try { + if (!chunkPushItems.isEmpty()) { + val items = chunkPushItems + itemMgr.batch(items.toTypedArray()) + + // Persist the items + synchronized(etebaseLocalCache) { + val colUid = cachedCollection.col.uid + + for (item in items) { + etebaseLocalCache.itemSet(itemMgr, colUid, item) + } + } + + pushed += items.size + } + } finally { + // FIXME: A bit fragile, we assume the order in createPushItems + var left = pushed + for (local in localDeleted!!) { + if (pushed-- <= 0) { + break + } + local.delete() + } + if (left > 0) { + localDeleted = localDeleted?.drop(left) + chunkPushItems = chunkPushItems.drop(left - pushed) + } + + left = pushed + var i = 0 + for (local in localDirty) { + if (pushed-- <= 0) { + break + } + Logger.log.info("Added/changed resource with filename: " + local.fileName) + local.clearDirty(chunkPushItems[i].etag) + i++ + } + if (left > 0) { + localDirty = localDirty.drop(left) + chunkPushItems.drop(left) + } + + if (pushed > 0) { + Logger.log.severe("Unprocessed localentries left, this should never happen!") + } + } + } + + private fun itemUpdateMtime(item: Item) { + val meta = item.meta + meta.setMtime(System.currentTimeMillis()) + item.meta = meta + } + + private fun createPushItems(): List { + val ret = LinkedList() + val colUid = cachedCollection.col.uid + + synchronized(etebaseLocalCache) { + for (local in localDeleted!!) { + val item = etebaseLocalCache.itemGet(itemMgr, colUid, local.fileName!!)!!.item + itemUpdateMtime(item) + item.delete() + ret.add(item) + + if (ret.size == MAX_PUSH) { + return ret + } + } + } + + synchronized(etebaseLocalCache) { + for (local in localDirty) { + val cacheItem = if (local.fileName != null) etebaseLocalCache.itemGet(itemMgr, colUid, local.fileName!!) else null + val item: Item + if (cacheItem != null) { + item = cacheItem.item + itemUpdateMtime(item) + } else { + val uid = UUID.randomUUID().toString() + val meta = ItemMetadata() + meta.name = uid + meta.mtime = System.currentTimeMillis() + item = itemMgr.create(meta, "") + + local.prepareForUpload(item.uid, uid) + } + + try { + item.setContent(local.content) + } catch (e: Exception) { + Logger.log.warning("Failed creating local entry ${local.uuid}") + if (local is LocalContact) { + Logger.log.warning("Contact with title ${local.contact?.displayName}") + } else if (local is LocalEvent) { + Logger.log.warning("Event with title ${local.event?.summary}") + } else if (local is LocalTask) { + Logger.log.warning("Task with title ${local.task?.summary}") + } + throw e + } + + ret.add(item) + + if (ret.size == MAX_PUSH) { + return ret + } + } + } + + return ret + } + @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class) - protected open fun createLocalEntries() { + private fun createLocalEntries() { localEntries = LinkedList() // Not saving, just creating a fake one until we load it from a local db @@ -510,7 +757,7 @@ constructor(protected val context: Context, protected val account: Account, prot /** */ @Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class) - protected fun prepareLocal() { + protected open fun prepareLocal() { localDeleted = processLocallyDeleted() localDirty = localCollection!!.findDirty(MAX_PUSH) // This is done after fetching the local dirty so all the ones we are using will be prepared @@ -527,7 +774,8 @@ constructor(protected val context: Context, protected val account: Account, prot val localList = localCollection!!.findDeleted() val ret = ArrayList(localList.size) - if (journalEntity.isReadOnly) { + val readOnly = (isLegacy && journalEntity.isReadOnly) || (!isLegacy && (cachedCollection.col.accessLevel == CollectionAccessLevel.ReadOnly)) + if (readOnly) { for (local in localList) { Logger.log.info("Restoring locally deleted resource on a read only collection: ${local.uuid}") local.resetDeleted() @@ -541,8 +789,11 @@ constructor(protected val context: Context, protected val account: Account, prot if (local.uuid != null) { Logger.log.info(local.uuid + " has been deleted locally -> deleting from server") } else { - Logger.log.fine("Entry deleted before ever syncing - genarting a UUID") - local.prepareForUpload() + if (isLegacy) { + // It's done later for non-legacy + Logger.log.fine("Entry deleted before ever syncing - genarting a UUID") + local.legacyPrepareForUpload(null) + } } ret.add(local) @@ -556,20 +807,22 @@ constructor(protected val context: Context, protected val account: Account, prot @Throws(CalendarStorageException::class, ContactsStorageException::class) protected open fun prepareDirty() { - if (journalEntity.isReadOnly) { + val readOnly = (isLegacy && journalEntity.isReadOnly) || (!isLegacy && (cachedCollection.col.accessLevel == CollectionAccessLevel.ReadOnly)) + if (readOnly) { for (local in localDirty) { Logger.log.info("Restoring locally modified resource on a read only collection: ${local.uuid}") if (local.uuid == null) { // If it was only local, delete. local.delete() } else { - local.clearDirty(local.uuid!!) + local.clearDirty(null) } numDiscarded++ } localDirty = LinkedList() - } else { + } else if (isLegacy) { + // It's done later for non-legacy // assign file names and UIDs to new entries Logger.log.info("Looking for local entries without a uuid") for (local in localDirty) { @@ -578,7 +831,7 @@ constructor(protected val context: Context, protected val account: Account, prot } Logger.log.fine("Found local record without file name; generating file name/UID if necessary") - local.prepareForUpload() + local.legacyPrepareForUpload(null) } } } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncNotification.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncNotification.kt index 11f55848..0c478331 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncNotification.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncNotification.kt @@ -11,6 +11,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import at.bitfire.ical4android.CalendarStorageException import at.bitfire.vcard4android.ContactsStorageException +import com.etebase.client.exceptions.* import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.Constants import com.etesync.syncadapter.R @@ -27,7 +28,8 @@ class SyncNotification(internal val context: Context, internal val notificationT internal val notificationManager: NotificationManagerCompat lateinit var detailsIntent: Intent internal set - internal var messageString: Int = 0 + internal var messageInt: Int = 0 + internal var messageString: String? = null private var throwable: Throwable? = null @@ -37,30 +39,33 @@ class SyncNotification(internal val context: Context, internal val notificationT fun setThrowable(e: Throwable) { throwable = e - if (e is Exceptions.UnauthorizedException) { + if (e is Exceptions.UnauthorizedException || e is UnauthorizedException) { Logger.log.log(Level.SEVERE, "Not authorized anymore", e) - messageString = R.string.sync_error_unauthorized + messageInt = R.string.sync_error_unauthorized } else if (e is Exceptions.UserInactiveException) { Logger.log.log(Level.SEVERE, "User inactive") - messageString = R.string.sync_error_user_inactive - } else if (e is Exceptions.ServiceUnavailableException) { + messageInt = R.string.sync_error_user_inactive + } else if (e is Exceptions.ServiceUnavailableException || e is TemporaryServerErrorException) { Logger.log.log(Level.SEVERE, "Service unavailable") - messageString = R.string.sync_error_unavailable + messageInt = R.string.sync_error_unavailable } else if (e is Exceptions.ReadOnlyException) { Logger.log.log(Level.SEVERE, "Journal is read only", e) - messageString = R.string.sync_error_journal_readonly - } else if (e is Exceptions.HttpException) { + messageInt = R.string.sync_error_journal_readonly + } else if (e is PermissionDeniedException) { + Logger.log.log(Level.SEVERE, "Permission denied", e) + messageString = context.getString(R.string.sync_error_permission_denied, e.localizedMessage) + } else if (e is Exceptions.HttpException || e is ServerErrorException) { Logger.log.log(Level.SEVERE, "HTTP Exception during sync", e) - messageString = R.string.sync_error_http_dav + messageInt = R.string.sync_error_http_dav } else if (e is CalendarStorageException || e is ContactsStorageException || e is SQLiteException) { Logger.log.log(Level.SEVERE, "Couldn't access local storage", e) - messageString = R.string.sync_error_local_storage + messageInt = R.string.sync_error_local_storage } else if (e is Exceptions.IntegrityException) { Logger.log.log(Level.SEVERE, "Integrity error", e) - messageString = R.string.sync_error_integrity + messageInt = R.string.sync_error_integrity } else { Logger.log.log(Level.SEVERE, "Unknown sync error", e) - messageString = R.string.sync_error + messageInt = R.string.sync_error } detailsIntent = Intent(context, NotificationHandlerActivity::class.java) @@ -69,7 +74,7 @@ class SyncNotification(internal val context: Context, internal val notificationT } fun notify(title: String, state: String) { - val message = context.getString(messageString, state) + val message = messageString ?: context.getString(messageInt, state) notify(title, message, null, detailsIntent) } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncAdapterService.kt index 313153c0..0da5ade1 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncAdapterService.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncAdapterService.kt @@ -18,10 +18,7 @@ import android.os.Bundle import at.bitfire.ical4android.AndroidTaskList import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.ProviderName -import com.etesync.syncadapter.AccountSettings -import com.etesync.syncadapter.App -import com.etesync.syncadapter.Constants -import com.etesync.syncadapter.R +import com.etesync.syncadapter.* import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity @@ -42,9 +39,6 @@ class TasksSyncAdapterService: SyncAdapterService() { context: Context, private val name: ProviderName ): SyncAdapter(context) { - override val syncErrorTitle = R.string.sync_error_tasks - override val notificationManager = SyncNotification(context, "journals-tasks", Constants.NOTIFICATION_TASK_SYNC) - override fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { val taskProvider = TaskProvider.fromProviderClient(context, provider, name) @@ -63,8 +57,11 @@ class TasksSyncAdapterService: SyncAdapterService() { RefreshCollections(account, CollectionInfo.Type.TASKS).run() - updateLocalTaskLists(taskProvider, account, accountSettings) - + if (accountSettings.isLegacy) { + legacyUpdateLocalTaskLists(taskProvider, account, accountSettings) + } else { + updateLocalTaskLists(taskProvider, account, accountSettings) + } val principal = accountSettings.uri?.toHttpUrlOrNull()!! for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) { @@ -78,6 +75,50 @@ class TasksSyncAdapterService: SyncAdapterService() { } private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { + val remote = HashMap() + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + val collections: List + synchronized(etebaseLocalCache) { + val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() + val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) + val colMgr = etebase.collectionManager + + collections = etebaseLocalCache.collectionList(colMgr).filter { it.meta.collectionType == Constants.ETEBASE_TYPE_TASKS } + } + + for (collection in collections) { + remote[collection.col.uid] = collection + } + + val local = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null) + + val updateColors = settings.manageCalendarColors + + // delete obsolete local calendar + for (taskList in local) { + val url = taskList.syncId + val collection = remote[url] + if (collection == null) { + Logger.log.fine("Deleting obsolete local taskList $url") + taskList.delete() + } else { + // remote CollectionInfo found for this local collection, update data + Logger.log.fine("Updating local taskList $url") + taskList.update(collection, updateColors) + // we already have a local taskList for this remote collection, don't take into consideration anymore + remote.remove(url) + } + } + + // create new local calendars + for (url in remote.keys) { + val cachedCollection = remote[url]!! + Logger.log.info("Adding local calendar list $cachedCollection") + LocalTaskList.create(account, provider, cachedCollection) + } + } + + private fun legacyUpdateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) { val data = (context.applicationContext as App).data var service = JournalModel.Service.fetchOrCreate(data, account.name, CollectionInfo.Type.TASKS) diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncManager.kt index 70de515e..ccebb0a7 100644 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/TasksSyncManager.kt @@ -13,6 +13,7 @@ import android.content.Context import android.content.SyncResult import android.os.Bundle import at.bitfire.ical4android.Task +import com.etebase.client.Item import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.Constants import com.etesync.syncadapter.R @@ -43,7 +44,7 @@ class TasksSyncManager( get() = context.getString(R.string.sync_error_tasks, account.name) override val syncSuccessfullyTitle: String - get() = context.getString(R.string.sync_successfully_tasks, info.displayName, + get() = context.getString(R.string.sync_successfully_tasks, localTaskList().name!!, account.name) init { @@ -58,7 +59,9 @@ class TasksSyncManager( if (!super.prepare()) return false - journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!) + if (isLegacy) { + journal = JournalEntryManager(httpClient.okHttpClient, remote, localTaskList().url!!) + } return true } @@ -68,6 +71,32 @@ class TasksSyncManager( return localCollection as LocalTaskList } + override fun processItem(item: Item) { + val local = localCollection!!.findByFilename(item.uid) + + if (!item.isDeleted) { + val inputReader = StringReader(String(item.content)) + + val tasks = Task.tasksFromReader(inputReader) + if (tasks.size == 0) { + Logger.log.warning("Received VCard without data, ignoring") + return + } else if (tasks.size > 1) { + Logger.log.warning("Received multiple VCALs, using first one") + } + + val task = tasks[0] + processTask(item, task, local) + } else { + if (local != null) { + Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") + local.delete() + } else { + Logger.log.warning("Tried deleting a non-existent record: " + item.uid) + } + } + } + override fun processSyncEntryImpl(cEntry: SyncEntry) { val inputReader = StringReader(cEntry.content) @@ -83,7 +112,7 @@ class TasksSyncManager( val local = localCollection!!.findByUid(event.uid!!) if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { - processTask(event, local) + legacyProcessTask(event, local) } else { if (local != null) { Logger.log.info("Removing local record #" + local.id + " which has been deleted on the server") @@ -94,7 +123,25 @@ class TasksSyncManager( } } - private fun processTask(newData: Task, _localTask: LocalTask?): LocalTask { + private fun processTask(item: Item, newData: Task, _localTask: LocalTask?): LocalTask { + var localTask = _localTask + // delete local Task, if it exists + if (localTask != null) { + Logger.log.info("Updating " + item.uid + " in local calendar") + localTask.eTag = item.etag + localTask.update(newData) + syncResult.stats.numUpdates++ + } else { + Logger.log.info("Adding " + item.uid + " to local calendar") + localTask = LocalTask(localTaskList(), newData, item.uid, item.etag) + localTask.add() + syncResult.stats.numInserts++ + } + + return localTask + } + + private fun legacyProcessTask(newData: Task, _localTask: LocalTask?): LocalTask { var localTask = _localTask // delete local Task, if it exists if (localTask != null) { diff --git a/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.kt b/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.kt index 1cbf7b14..24cf6c30 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.kt +++ b/app/src/main/java/com/etesync/syncadapter/ui/AccountActivity.kt @@ -25,20 +25,27 @@ import android.widget.* import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat -import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS import at.bitfire.vcard4android.ContactsStorageException -import com.etesync.syncadapter.* +import com.etebase.client.CollectionAccessLevel +import com.etebase.client.CollectionManager +import com.etebase.client.Utils +import com.etebase.client.exceptions.EtebaseException import com.etesync.journalmanager.Crypto import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.JournalAuthenticator +import com.etesync.syncadapter.* +import com.etesync.syncadapter.Constants.* import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.MyEntityDataStore import com.etesync.syncadapter.model.ServiceEntity import com.etesync.syncadapter.resource.LocalAddressBook import com.etesync.syncadapter.resource.LocalCalendar import com.etesync.syncadapter.syncadapter.requestSync +import com.etesync.syncadapter.ui.etebase.CollectionActivity +import com.etesync.syncadapter.ui.etebase.InvitationsActivity import com.etesync.syncadapter.ui.setup.SetupUserInfoFragment import com.etesync.syncadapter.utils.HintManager import com.etesync.syncadapter.utils.ShowcaseBuilder @@ -52,6 +59,7 @@ import java.util.logging.Level class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks, Refreshable { private lateinit var account: Account + private lateinit var settings: AccountSettings private var accountInfo: AccountInfo? = null internal var listCalDAV: ListView? = null @@ -64,22 +72,30 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe private val onItemClickListener = AdapterView.OnItemClickListener { parent, view, position, _ -> val list = parent as ListView val adapter = list.adapter as ArrayAdapter<*> - val journalEntity = adapter.getItem(position) as JournalEntity - val info = journalEntity.getInfo() + val info = adapter.getItem(position) as CollectionListItemInfo - startActivity(ViewCollectionActivity.newIntent(this@AccountActivity, account, info)) + if (settings.isLegacy) { + startActivity(ViewCollectionActivity.newIntent(this@AccountActivity, account, info.legacyInfo!!)) + } else { + startActivity(CollectionActivity.newIntent(this@AccountActivity, account, info.uid)) + } } private val formattedFingerprint: String? get() { try { - val settings = AccountSettings(this, account) - return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey) + if (settings.isLegacy) { + val settings = AccountSettings(this, account) + return Crypto.AsymmetricCryptoManager.getPrettyKeyFingerprint(settings.keyPair!!.publicKey) + } else { + val etebase = EtebaseLocalCache.getEtebase(this, HttpClient.sharedClient, settings) + val invitationManager = etebase.invitationManager + return Utils.prettyFingerprint(invitationManager.pubkey) + } } catch (e: Exception) { e.printStackTrace() - return null + return e.localizedMessage } - } override fun onCreate(savedInstanceState: Bundle?) { @@ -87,6 +103,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe account = intent.getParcelableExtra(EXTRA_ACCOUNT) title = account.name + settings = AccountSettings(this, account) setContentView(R.layout.activity_account) @@ -131,13 +148,19 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe HintManager.setHintSeen(this, HINT_VIEW_COLLECTION, true) } - if (!SetupUserInfoFragment.hasUserInfo(this, account)) { - SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null) + if (settings.isLegacy) { + if (!SetupUserInfoFragment.hasUserInfo(this, account)) { + SetupUserInfoFragment.newInstance(account).show(supportFragmentManager, null) + } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.activity_account, menu) + if (settings.isLegacy) { + val invitations = menu.findItem(R.id.invitations) + invitations.setVisible(false) + } return true } @@ -167,6 +190,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe .setPositiveButton(android.R.string.yes) { _, _ -> }.create() dialog.show() } + R.id.invitations -> { + val intent = InvitationsActivity.newIntent(this, account) + startActivity(intent) + } else -> return super.onOptionsItemSelected(item) } return true @@ -195,19 +222,31 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe val info: CollectionInfo when (item.itemId) { R.id.create_calendar -> { - info = CollectionInfo() - info.enumType = CollectionInfo.Type.CALENDAR - startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + if (settings.isLegacy) { + info = CollectionInfo() + info.enumType = CollectionInfo.Type.CALENDAR + startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + } else { + startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_CALENDAR)) + } } R.id.create_tasklist -> { - info = CollectionInfo() - info.enumType = CollectionInfo.Type.TASKS - startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + if (settings.isLegacy) { + info = CollectionInfo() + info.enumType = CollectionInfo.Type.TASKS + startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + } else { + startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_TASKS)) + } } R.id.create_addressbook -> { - info = CollectionInfo() - info.enumType = CollectionInfo.Type.ADDRESS_BOOK - startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + if (settings.isLegacy) { + info = CollectionInfo() + info.enumType = CollectionInfo.Type.ADDRESS_BOOK + startActivity(CreateCollectionActivity.newIntent(this@AccountActivity, account, info)) + } else { + startActivity(CollectionActivity.newCreateCollectionIntent(this@AccountActivity, account, ETEBASE_TYPE_ADDRESS_BOOK)) + } } R.id.install_tasksorg -> { installPackage(tasksOrgPackage) @@ -227,10 +266,9 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe internal var taskdav: ServiceInfo? = null class ServiceInfo { - internal var id: Long = 0 internal var refreshing: Boolean = false - internal var journals: List? = null + internal var infos: List? = null } } @@ -254,7 +292,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe listCardDAV!!.setAlpha(if (info.carddav!!.refreshing) 0.5f else 1f) val adapter = CollectionListAdapter(this, account) - adapter.addAll(info.carddav!!.journals!!) + adapter.addAll(info.carddav!!.infos!!) listCardDAV!!.adapter = adapter listCardDAV!!.onItemClickListener = onItemClickListener } @@ -268,7 +306,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe listCalDAV!!.setAlpha(if (info.caldav!!.refreshing) 0.5f else 1f) val adapter = CollectionListAdapter(this, account) - adapter.addAll(info.caldav!!.journals!!) + adapter.addAll(info.caldav!!.infos!!) listCalDAV!!.adapter = adapter listCalDAV!!.onItemClickListener = onItemClickListener } @@ -282,7 +320,7 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe listTaskDAV!!.setAlpha(if (info.taskdav!!.refreshing) 0.5f else 1f) val adapter = CollectionListAdapter(this, account) - adapter.addAll(info.taskdav!!.journals!!) + adapter.addAll(info.taskdav!!.infos!!) listTaskDAV!!.adapter = adapter listTaskDAV!!.onItemClickListener = onItemClickListener @@ -342,50 +380,121 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe forceLoad() } + private fun getLegacyJournals(data: MyEntityDataStore, serviceEntity: ServiceEntity): List { + return JournalEntity.getJournals(data, serviceEntity).map { + val info = it.info + val isAdmin = it.isOwner(account.name) + CollectionListItemInfo(it.uid, info.enumType!!, info.displayName!!, info.description ?: "", info.color, it.isReadOnly, isAdmin, info) + } + } + + private fun getCollections(etebaseLocalCache: EtebaseLocalCache, colMgr: CollectionManager, type: CollectionInfo.Type): List { + val strType = when (type) { + CollectionInfo.Type.ADDRESS_BOOK -> ETEBASE_TYPE_ADDRESS_BOOK + CollectionInfo.Type.CALENDAR -> ETEBASE_TYPE_CALENDAR + CollectionInfo.Type.TASKS -> ETEBASE_TYPE_TASKS + } + + synchronized(etebaseLocalCache) { + return etebaseLocalCache.collectionList(colMgr).map { + val meta = it.meta + + if (strType != meta.collectionType) { + return@map null + } + + val accessLevel = it.col.accessLevel + val isReadOnly = accessLevel == CollectionAccessLevel.ReadOnly + val isAdmin = accessLevel == CollectionAccessLevel.Admin + + val metaColor = meta.color + val color = if (!metaColor.isNullOrBlank()) LocalCalendar.parseColor(metaColor) else null + CollectionListItemInfo(it.col.uid, type, meta.name, meta.description + ?: "", color, isReadOnly, isAdmin, null) + }.filterNotNull() + } + } + override fun loadInBackground(): AccountInfo { val info = AccountInfo() + val settings: AccountSettings + try { + settings = AccountSettings(context, account) + } catch (e: InvalidAccountException) { + return info + } + if (settings.isLegacy) { + val data = (context.applicationContext as App).data + + for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) { + val id = serviceEntity.id.toLong() + val service = serviceEntity.type!! + when (service) { + CollectionInfo.Type.ADDRESS_BOOK -> { + info.carddav = AccountInfo.ServiceInfo() + info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority) + info.carddav!!.infos = getLegacyJournals(data, serviceEntity) + + val accountManager = AccountManager.get(context) + for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) { + val addressBook = LocalAddressBook(context, addrBookAccount, null) + try { + if (account == addressBook.mainAccount) + info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY) + } catch (e: ContactsStorageException) { + } - val data = (context.applicationContext as App).data - - for (serviceEntity in data.select(ServiceEntity::class.java).where(ServiceEntity.ACCOUNT.eq(account.name)).get()) { - val id = serviceEntity.id.toLong() - val service = serviceEntity.type!! - when (service) { - CollectionInfo.Type.ADDRESS_BOOK -> { - info.carddav = AccountInfo.ServiceInfo() - info.carddav!!.id = id - info.carddav!!.refreshing = davService != null && davService!!.isRefreshing(id) || ContentResolver.isSyncActive(account, App.addressBooksAuthority) - info.carddav!!.journals = JournalEntity.getJournals(data, serviceEntity) - - val accountManager = AccountManager.get(context) - for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) { - val addressBook = LocalAddressBook(context, addrBookAccount, null) - try { - if (account == addressBook.mainAccount) - info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY) - } catch (e: ContactsStorageException) { } - } - } - CollectionInfo.Type.CALENDAR -> { - info.caldav = AccountInfo.ServiceInfo() - info.caldav!!.id = id - info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) || - ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) - info.caldav!!.journals = JournalEntity.getJournals(data, serviceEntity) - } - CollectionInfo.Type.TASKS -> { - info.taskdav = AccountInfo.ServiceInfo() - info.taskdav!!.id = id - info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) || - OPENTASK_PROVIDERS.any { - ContentResolver.isSyncActive(account, it.authority) - } - info.taskdav!!.journals = JournalEntity.getJournals(data, serviceEntity) + CollectionInfo.Type.CALENDAR -> { + info.caldav = AccountInfo.ServiceInfo() + info.caldav!!.refreshing = davService != null && davService!!.isRefreshing(id) || + ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) + info.caldav!!.infos = getLegacyJournals(data, serviceEntity) + } + CollectionInfo.Type.TASKS -> { + info.taskdav = AccountInfo.ServiceInfo() + info.taskdav!!.refreshing = davService != null && davService!!.isRefreshing(id) || + OPENTASK_PROVIDERS.any { + ContentResolver.isSyncActive(account, it.authority) + } + info.taskdav!!.infos = getLegacyJournals(data, serviceEntity) + } } } + return info + } + + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + val httpClient = HttpClient.Builder(context).build().okHttpClient + val etebase = EtebaseLocalCache.getEtebase(context, httpClient, settings) + val colMgr = etebase.collectionManager + + info.carddav = AccountInfo.ServiceInfo() + info.carddav!!.refreshing = ContentResolver.isSyncActive(account, App.addressBooksAuthority) + info.carddav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.ADDRESS_BOOK) + + val accountManager = AccountManager.get(context) + for (addrBookAccount in accountManager.getAccountsByType(App.addressBookAccountType)) { + val addressBook = LocalAddressBook(context, addrBookAccount, null) + try { + if (account == addressBook.mainAccount) + info.carddav!!.refreshing = info.carddav!!.refreshing or ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY) + } catch (e: ContactsStorageException) { + } + } + + info.caldav = AccountInfo.ServiceInfo() + info.caldav!!.refreshing = ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) + info.caldav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.CALENDAR) + + info.taskdav = AccountInfo.ServiceInfo() + info.taskdav!!.refreshing = OPENTASK_PROVIDERS.any { + ContentResolver.isSyncActive(account, it.authority) + } + info.taskdav!!.infos = getCollections(etebaseLocalCache, colMgr, CollectionInfo.Type.TASKS) + return info } } @@ -393,15 +502,16 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe /* LIST ADAPTERS */ - class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter(context, R.layout.account_collection_item) { + data class CollectionListItemInfo(val uid: String, val enumType: CollectionInfo.Type, val displayName: String, val description: String, val color: Int?, val isReadOnly: Boolean, val isAdmin: Boolean, val legacyInfo: CollectionInfo?) + + class CollectionListAdapter(context: Context, private val account: Account) : ArrayAdapter(context, R.layout.account_collection_item) { override fun getView(position: Int, _v: View?, parent: ViewGroup): View { var v = _v if (v == null) v = LayoutInflater.from(context).inflate(R.layout.account_collection_item, parent, false) - val journalEntity = getItem(position) - val info = journalEntity!!.info + val info = getItem(position)!! var tv = v!!.findViewById(R.id.title) as TextView tv.text = if (TextUtils.isEmpty(info.displayName)) info.uid else info.displayName @@ -422,10 +532,10 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe } val readOnly = v.findViewById(R.id.read_only) - readOnly.visibility = if (journalEntity.isReadOnly) View.VISIBLE else View.GONE + readOnly.visibility = if (info.isReadOnly) View.VISIBLE else View.GONE val shared = v.findViewById(R.id.shared) - val isOwner = journalEntity.isOwner(account.name) + val isOwner = info.isAdmin shared.visibility = if (isOwner) View.GONE else View.VISIBLE return v @@ -437,17 +547,32 @@ class AccountActivity : BaseActivity(), Toolbar.OnMenuItemClickListener, PopupMe private fun deleteAccount() { val accountManager = AccountManager.get(this) val settings = AccountSettings(this@AccountActivity, account) - val authToken = settings.authToken - val principal = settings.uri?.toHttpUrlOrNull() doAsync { - try { - val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient - val journalAuthenticator = JournalAuthenticator(httpClient, principal!!) - journalAuthenticator.invalidateAuthToken(authToken) - } catch (e: Exceptions.HttpException) { - // Ignore failures for now - Logger.log.warning(e.toString()) + if (settings.isLegacy) { + val authToken = settings.authToken + val principal = settings.uri?.toHttpUrlOrNull() + + try { + val httpClient = HttpClient.Builder(this@AccountActivity, null, authToken).build().okHttpClient + val journalAuthenticator = JournalAuthenticator(httpClient, principal!!) + journalAuthenticator.invalidateAuthToken(authToken) + } catch (e: Exceptions.HttpException) { + // Ignore failures for now + Logger.log.warning(e.toString()) + } + } else { + val etebaseLocalCache = EtebaseLocalCache.getInstance(this@AccountActivity, account.name) + etebaseLocalCache.clearUserCache() + + try { + val httpClient = HttpClient.Builder(this@AccountActivity).build() + val etebase = EtebaseLocalCache.getEtebase(this@AccountActivity, httpClient.okHttpClient, settings) + etebase.logout() + } catch(e: EtebaseException) { + // Ignore failures for now + Logger.log.warning(e.toString()) + } } } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/AccountListFragment.kt b/app/src/main/java/com/etesync/syncadapter/ui/AccountListFragment.kt index 52abf562..78c580ab 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/AccountListFragment.kt +++ b/app/src/main/java/com/etesync/syncadapter/ui/AccountListFragment.kt @@ -32,7 +32,7 @@ import com.etesync.syncadapter.R class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks>, AdapterView.OnItemClickListener { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - listAdapter = AccountListAdapter(context!!) + listAdapter = AccountListAdapter(requireContext()) return inflater.inflate(R.layout.account_list, container, false) } @@ -58,7 +58,7 @@ class AccountListFragment : ListFragment(), LoaderManager.LoaderCallbacks> { - return AccountLoader(context!!) + return AccountLoader(requireContext()) } override fun onLoadFinished(loader: Loader>, accounts: Array) { diff --git a/app/src/main/java/com/etesync/syncadapter/ui/AccountSettingsActivity.kt b/app/src/main/java/com/etesync/syncadapter/ui/AccountSettingsActivity.kt index e190a5a5..67065f6d 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/AccountSettingsActivity.kt +++ b/app/src/main/java/com/etesync/syncadapter/ui/AccountSettingsActivity.kt @@ -17,12 +17,13 @@ import android.os.Bundle import android.provider.CalendarContract import android.text.TextUtils import android.view.MenuItem +import android.widget.Toast import androidx.core.app.NavUtils +import androidx.fragment.app.Fragment import androidx.loader.app.LoaderManager import androidx.loader.content.AsyncTaskLoader import androidx.loader.content.Loader import androidx.preference.* -import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.TaskProvider.Companion.OPENTASK_PROVIDERS import com.etesync.syncadapter.* import com.etesync.syncadapter.Constants.KEY_ACCOUNT @@ -43,7 +44,8 @@ class AccountSettingsActivity : BaseActivity() { supportActionBar!!.setDisplayHomeAsUpEnabled(true) if (savedInstanceState == null) { - val frag = AccountSettingsFragment() + val settings = AccountSettings(this, account) + val frag: Fragment = if (settings.isLegacy) LegacyAccountSettingsFragment() else AccountSettingsFragment() frag.arguments = intent.extras supportFragmentManager.beginTransaction() .replace(android.R.id.content, frag) @@ -60,139 +62,226 @@ class AccountSettingsActivity : BaseActivity() { } else return false } +} - class AccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks { - internal lateinit var account: Account +class AccountSettingsFragment() : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks { + internal lateinit var account: Account - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - account = arguments?.getParcelable(KEY_ACCOUNT)!! + account = arguments?.getParcelable(KEY_ACCOUNT)!! - loaderManager.initLoader(0, arguments, this) - } + loaderManager.initLoader(0, arguments, this) + } - override fun onCreatePreferences(bundle: Bundle, s: String) { - addPreferencesFromResource(R.xml.settings_account) - } + override fun onCreatePreferences(bundle: Bundle, s: String) { + addPreferencesFromResource(R.xml.settings_account) + } - override fun onCreateLoader(id: Int, args: Bundle?): Loader { - return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account) - } + override fun onCreateLoader(id: Int, args: Bundle?): Loader { + return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account) + } - override fun onLoadFinished(loader: Loader, settings: AccountSettings?) { - if (settings == null) { - activity!!.finish() - return - } + override fun onLoadFinished(loader: Loader, settings: AccountSettings?) { + if (settings == null) { + activity!!.finish() + return + } + // Category: dashboard + val prefManageAccount = findPreference("manage_account") + prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> + Toast.makeText(requireContext(), "Not yet supported", Toast.LENGTH_LONG).show() + true + } - // Category: dashboard - val prefManageAccount = findPreference("manage_account") - prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> - WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build()) - true - } + // Category: encryption + val prefEncryptionPassword = findPreference("password") + prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> + startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account)) + true + } - // category: authentication - val prefPassword = findPreference("password") as EditTextPreference - prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val credentials = if (newValue != null) LoginCredentials(settings.uri, account.name, newValue as String) else null - LoginCredentialsChangeFragment.newInstance(account, credentials!!).show(fragmentManager!!, null) + val prefSync = findPreference("sync_interval") as ListPreference + val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval + if (syncInterval != null) { + prefSync.value = syncInterval.toString() + if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY) + prefSync.setSummary(R.string.settings_sync_summary_manually) + else + prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry) + prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val newInterval = java.lang.Long.parseLong(newValue as String) + settings.setSyncInterval(App.addressBooksAuthority, newInterval) + settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval) + OPENTASK_PROVIDERS.forEach { + settings.setSyncInterval(it.authority, newInterval) + } loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) false } + } else { + prefSync.isEnabled = false + prefSync.setSummary(R.string.settings_sync_summary_not_available) + } - // Category: encryption - val prefEncryptionPassword = findPreference("encryption_password") - prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> - startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account)) - true - } + val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat + prefWifiOnly.isChecked = settings.syncWifiOnly + prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> + settings.setSyncWiFiOnly(wifiOnly as Boolean) + loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) + false + } - val prefSync = findPreference("sync_interval") as ListPreference - val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval - if (syncInterval != null) { - prefSync.value = syncInterval.toString() - if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY) - prefSync.setSummary(R.string.settings_sync_summary_manually) - else - prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry) - prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val newInterval = java.lang.Long.parseLong(newValue as String) - settings.setSyncInterval(App.addressBooksAuthority, newInterval) - settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval) - OPENTASK_PROVIDERS.forEach { - settings.setSyncInterval(it.authority, newInterval) - } - loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) - false - } - } else { - prefSync.isEnabled = false - prefSync.setSummary(R.string.settings_sync_summary_not_available) - } + val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference + val onlySSID = settings.syncWifiOnlySSID + prefWifiOnlySSID.text = onlySSID + if (onlySSID != null) + prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID) + else + prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off) + prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val ssid = newValue as String + settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null + loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) + false + } + } - val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat - prefWifiOnly.isChecked = settings.syncWifiOnly - prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> - settings.setSyncWiFiOnly(wifiOnly as Boolean) - loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) - false - } + override fun onLoaderReset(loader: Loader) {} - val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference - val onlySSID = settings.syncWifiOnlySSID - prefWifiOnlySSID.text = onlySSID - if (onlySSID != null) - prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID) - else - prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off) - prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val ssid = newValue as String - settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null - loaderManager.restartLoader(0, arguments, this@AccountSettingsFragment) - false - } - } +} + + + +class LegacyAccountSettingsFragment : PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks { + internal lateinit var account: Account - override fun onLoaderReset(loader: Loader) {} + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + account = arguments?.getParcelable(KEY_ACCOUNT)!! + + loaderManager.initLoader(0, arguments, this) } + override fun onCreatePreferences(bundle: Bundle, s: String) { + addPreferencesFromResource(R.xml.settings_account_legacy) + } - private class AccountSettingsLoader(context: Context, internal val account: Account) : AsyncTaskLoader(context), SyncStatusObserver { - internal lateinit var listenerHandle: Any + override fun onCreateLoader(id: Int, args: Bundle?): Loader { + return AccountSettingsLoader(context!!, args!!.getParcelable(KEY_ACCOUNT) as Account) + } - override fun onStartLoading() { - forceLoad() - listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) + override fun onLoadFinished(loader: Loader, settings: AccountSettings?) { + if (settings == null) { + activity!!.finish() + return } - override fun onStopLoading() { - ContentResolver.removeStatusChangeListener(listenerHandle) + // Category: dashboard + val prefManageAccount = findPreference("manage_account") + prefManageAccount.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> + WebViewActivity.openUrl(activity!!, Constants.dashboard.buildUpon().appendQueryParameter("email", account.name).build()) + true } - override fun abandon() { - onStopLoading() + // category: authentication + val prefPassword = findPreference("password") as EditTextPreference + prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val credentials = if (newValue != null) LoginCredentials(settings.uri, account.name, newValue as String) else null + LoginCredentialsChangeFragment.newInstance(account, credentials!!).show(fragmentManager!!, null) + loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment) + false } - override fun loadInBackground(): AccountSettings? { - val settings: AccountSettings - try { - settings = AccountSettings(context, account) - } catch (e: InvalidAccountException) { - return null - } + // Category: encryption + val prefEncryptionPassword = findPreference("encryption_password") + prefEncryptionPassword.onPreferenceClickListener = Preference.OnPreferenceClickListener { _ -> + startActivity(ChangeEncryptionPasswordActivity.newIntent(activity!!, account)) + true + } - return settings + val prefSync = findPreference("sync_interval") as ListPreference + val syncInterval = settings.getSyncInterval(CalendarContract.AUTHORITY) // Calendar is the baseline interval + if (syncInterval != null) { + prefSync.value = syncInterval.toString() + if (syncInterval == AccountSettings.SYNC_INTERVAL_MANUALLY) + prefSync.setSummary(R.string.settings_sync_summary_manually) + else + prefSync.summary = getString(R.string.settings_sync_summary_periodically, prefSync.entry) + prefSync.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val newInterval = java.lang.Long.parseLong(newValue as String) + settings.setSyncInterval(App.addressBooksAuthority, newInterval) + settings.setSyncInterval(CalendarContract.AUTHORITY, newInterval) + OPENTASK_PROVIDERS.forEach { + settings.setSyncInterval(it.authority, newInterval) + } + loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment) + false + } + } else { + prefSync.isEnabled = false + prefSync.setSummary(R.string.settings_sync_summary_not_available) } - override fun onStatusChanged(which: Int) { - Logger.log.fine("Reloading account settings") - forceLoad() + val prefWifiOnly = findPreference("sync_wifi_only") as SwitchPreferenceCompat + prefWifiOnly.isChecked = settings.syncWifiOnly + prefWifiOnly.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, wifiOnly -> + settings.setSyncWiFiOnly(wifiOnly as Boolean) + loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment) + false } + val prefWifiOnlySSID = findPreference("sync_wifi_only_ssid") as EditTextPreference + val onlySSID = settings.syncWifiOnlySSID + prefWifiOnlySSID.text = onlySSID + if (onlySSID != null) + prefWifiOnlySSID.summary = getString(R.string.settings_sync_wifi_only_ssid_on, onlySSID) + else + prefWifiOnlySSID.setSummary(R.string.settings_sync_wifi_only_ssid_off) + prefWifiOnlySSID.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> + val ssid = newValue as String + settings.syncWifiOnlySSID = if (!TextUtils.isEmpty(ssid)) ssid else null + loaderManager.restartLoader(0, arguments, this@LegacyAccountSettingsFragment) + false + } } + override fun onLoaderReset(loader: Loader) {} } + +private class AccountSettingsLoader(context: Context, internal val account: Account) : AsyncTaskLoader(context), SyncStatusObserver { + internal lateinit var listenerHandle: Any + + override fun onStartLoading() { + forceLoad() + listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this) + } + + override fun onStopLoading() { + ContentResolver.removeStatusChangeListener(listenerHandle) + } + + override fun abandon() { + onStopLoading() + } + + override fun loadInBackground(): AccountSettings? { + val settings: AccountSettings + try { + settings = AccountSettings(context, account) + } catch (e: InvalidAccountException) { + return null + } + + return settings + } + + override fun onStatusChanged(which: Int) { + Logger.log.fine("Reloading account settings") + forceLoad() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/ui/ChangeEncryptionPasswordActivity.kt b/app/src/main/java/com/etesync/syncadapter/ui/ChangeEncryptionPasswordActivity.kt index 983efee1..86fa3fc5 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/ChangeEncryptionPasswordActivity.kt +++ b/app/src/main/java/com/etesync/syncadapter/ui/ChangeEncryptionPasswordActivity.kt @@ -15,6 +15,7 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog +import com.etebase.client.Client import com.etesync.syncadapter.AccountSettings import com.etesync.syncadapter.HttpClient import com.etesync.syncadapter.R @@ -53,7 +54,7 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() { AlertDialog.Builder(this) .setTitle(R.string.wrong_encryption_password) .setIcon(R.drawable.ic_error_dark) - .setMessage(getString(R.string.wrong_encryption_password_content, e.localizedMessage)) + .setMessage(e.localizedMessage) .setPositiveButton(android.R.string.ok) { _, _ -> // dismiss }.show() @@ -62,6 +63,45 @@ open class ChangeEncryptionPasswordActivity : BaseActivity() { fun changePasswordDo(old_password: String, new_password: String) { val settings = AccountSettings(this, account) + if (settings.isLegacy) { + legacyChangePasswordDo(settings, old_password, new_password) + return + } + + doAsync { + val httpClient = HttpClient.Builder(this@ChangeEncryptionPasswordActivity).setForeground(true).build().okHttpClient + + try { + Logger.log.info("Loging in with old password") + val client = Client.create(httpClient, settings.uri?.toString()) + val etebase = com.etebase.client.Account.login(client, account.name, old_password) + Logger.log.info("Login successful") + + etebase.changePassword(new_password) + + settings.etebaseSession = etebase.save(null) + + uiThread { + progress.dismiss() + AlertDialog.Builder(this@ChangeEncryptionPasswordActivity) + .setTitle(R.string.change_encryption_password_success_title) + .setMessage(R.string.change_encryption_password_success_body) + .setPositiveButton(android.R.string.ok) { _, _ -> + this@ChangeEncryptionPasswordActivity.finish() + }.show() + + requestSync(applicationContext, account) + } + } catch (e: Exception) { + uiThread { + changePasswordError(e) + } + return@doAsync + } + } + } + + fun legacyChangePasswordDo(settings: AccountSettings, old_password: String, new_password: String) { doAsync { val httpClient = HttpClient.Builder(this@ChangeEncryptionPasswordActivity, settings).setForeground(false).build().okHttpClient diff --git a/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.kt b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.kt index a8cc1e0e..46c50987 100644 --- a/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.kt +++ b/app/src/main/java/com/etesync/syncadapter/ui/CollectionMembersListFragment.kt @@ -106,13 +106,13 @@ class CollectionMembersListFragment : ListFragment(), AdapterView.OnItemClickLis override fun onItemClick(parent: AdapterView<*>, view: View, position: Int, id: Long) { val member = listAdapter?.getItem(position) as JournalManager.Member - AlertDialog.Builder(activity!!) + AlertDialog.Builder(requireActivity()) .setIcon(R.drawable.ic_info_dark) .setTitle(R.string.collection_members_remove_title) .setMessage(getString(R.string.collection_members_remove, member.user)) .setPositiveButton(android.R.string.yes) { dialog, which -> val frag = RemoveMemberFragment.newInstance(account, info, member.user!!) - frag.show(fragmentManager!!, null) + frag.show(requireFragmentManager(), null) } .setNegativeButton(android.R.string.no) { dialog, which -> }.show() } diff --git a/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionActivity.kt b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionActivity.kt new file mode 100644 index 00000000..f3dbf5f7 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionActivity.kt @@ -0,0 +1,170 @@ +package com.etesync.syncadapter.ui.etebase + +import android.accounts.Account +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.fragment.app.commit +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.observe +import com.etebase.client.CollectionManager +import com.etebase.client.CollectionMetadata +import com.etesync.syncadapter.* +import com.etesync.syncadapter.ui.BaseActivity +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread + +class CollectionActivity() : BaseActivity() { + private lateinit var account: Account + private val model: AccountViewModel by viewModels() + private val collectionModel: CollectionViewModel by viewModels() + private val itemsModel: ItemsViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + account = intent.extras!!.getParcelable(EXTRA_ACCOUNT)!! + val colUid = intent.extras!!.getString(EXTRA_COLLECTION_UID) + val colType = intent.extras!!.getString(EXTRA_COLLECTION_TYPE) + + setContentView(R.layout.etebase_collection_activity) + + if (savedInstanceState == null) { + model.loadAccount(this, account) + if (colUid != null) { + model.observe(this) { + collectionModel.loadCollection(it, colUid) + collectionModel.observe(this) { cachedCollection -> + itemsModel.loadItems(it, cachedCollection) + } + } + supportFragmentManager.commit { + replace(R.id.fragment_container, ViewCollectionFragment()) + } + } else if (colType != null) { + model.observe(this) { + doAsync { + val meta = CollectionMetadata(colType, "") + val cachedCollection = CachedCollection(it.colMgr.create(meta, ""), meta) + uiThread { + supportFragmentManager.commit { + replace(R.id.fragment_container, EditCollectionFragment(cachedCollection, true)) + } + } + } + } + } + } + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + companion object { + private val EXTRA_ACCOUNT = "account" + private val EXTRA_COLLECTION_UID = "collectionUid" + private val EXTRA_COLLECTION_TYPE = "collectionType" + + fun newIntent(context: Context, account: Account, colUid: String): Intent { + val intent = Intent(context, CollectionActivity::class.java) + intent.putExtra(EXTRA_ACCOUNT, account) + intent.putExtra(EXTRA_COLLECTION_UID, colUid) + return intent + } + + fun newCreateCollectionIntent(context: Context, account: Account, colType: String): Intent { + val intent = Intent(context, CollectionActivity::class.java) + intent.putExtra(EXTRA_ACCOUNT, account) + intent.putExtra(EXTRA_COLLECTION_TYPE, colType) + return intent + } + } +} + +class AccountViewModel : ViewModel() { + private val holder = MutableLiveData() + + fun loadAccount(context: Context, account: Account) { + doAsync { + val settings = AccountSettings(context, account) + val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) + val httpClient = HttpClient.Builder(context).setForeground(true).build().okHttpClient + val etebase = EtebaseLocalCache.getEtebase(context, httpClient, settings) + val colMgr = etebase.collectionManager + uiThread { + holder.value = AccountHolder( + account, + etebaseLocalCache, + etebase, + colMgr + ) + } + } + } + + fun observe(owner: LifecycleOwner, observer: (AccountHolder) -> Unit) = + holder.observe(owner, observer) + + val value: AccountHolder? + get() = holder.value +} + +data class AccountHolder(val account: Account, val etebaseLocalCache: EtebaseLocalCache, val etebase: com.etebase.client.Account, val colMgr: CollectionManager) + +class CollectionViewModel : ViewModel() { + private val collection = MutableLiveData() + + fun loadCollection(accountHolder: AccountHolder, colUid: String) { + doAsync { + val etebaseLocalCache = accountHolder.etebaseLocalCache + val colMgr = accountHolder.colMgr + val cachedCollection = synchronized(etebaseLocalCache) { + etebaseLocalCache.collectionGet(colMgr, colUid)!! + } + uiThread { + collection.value = cachedCollection + } + } + } + + fun observe(owner: LifecycleOwner, observer: (CachedCollection) -> Unit) = + collection.observe(owner, observer) + + val value: CachedCollection? + get() = collection.value +} + +class ItemsViewModel : ViewModel() { + private val cachedItems = MutableLiveData>() + + fun loadItems(accountCollectionHolder: AccountHolder, cachedCollection: CachedCollection) { + doAsync { + val col = cachedCollection.col + val itemMgr = accountCollectionHolder.colMgr.getItemManager(col) + val items = accountCollectionHolder.etebaseLocalCache.itemList(itemMgr, col.uid, withDeleted = true) + uiThread { + cachedItems.value = items + } + } + } + + fun observe(owner: LifecycleOwner, observer: (List) -> Unit) = + cachedItems.observe(owner, observer) + + val value: List? + get() = cachedItems.value +} + + +class LoadingViewModel : ViewModel() { + private val loading = MutableLiveData() + + fun setLoading(value: Boolean) { + loading.value = value + } + + fun observe(owner: LifecycleOwner, observer: (Boolean) -> Unit) = + loading.observe(owner, observer) +} \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionItemFragment.kt b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionItemFragment.kt new file mode 100644 index 00000000..3e59b6ba --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionItemFragment.kt @@ -0,0 +1,532 @@ +package com.etesync.syncadapter.ui.etebase + +import android.content.Context +import android.os.Bundle +import android.provider.CalendarContract +import android.provider.ContactsContract +import android.text.format.DateFormat +import android.text.format.DateUtils +import android.view.* +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.fragment.app.activityViewModels +import androidx.viewpager.widget.ViewPager +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.ical4android.Task +import at.bitfire.ical4android.TaskProvider +import at.bitfire.vcard4android.Contact +import com.etesync.syncadapter.CachedCollection +import com.etesync.syncadapter.CachedItem +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.R +import com.etesync.syncadapter.resource.* +import com.etesync.syncadapter.ui.BaseActivity +import com.etesync.syncadapter.utils.EventEmailInvitation +import com.etesync.syncadapter.utils.TaskProviderHandling +import com.google.android.material.tabs.TabLayout +import ezvcard.util.PartialDate +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread +import java.io.IOException +import java.io.StringReader +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.Future + +class CollectionItemFragment(private val cachedItem: CachedItem) : Fragment() { + private val model: AccountViewModel by activityViewModels() + private val collectionModel: CollectionViewModel by activityViewModels() + + private var emailInvitationEvent: Event? = null + private var emailInvitationEventString: String? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val ret = inflater.inflate(R.layout.journal_item_activity, container, false) + setHasOptionsMenu(true) + + if (savedInstanceState == null) { + collectionModel.observe(this) { + (activity as? BaseActivity?)?.supportActionBar?.title = it.meta.name + if (container != null) { + initUi(inflater, ret, it) + } + } + } + + return ret + } + + private fun initUi(inflater: LayoutInflater, v: View, cachedCollection: CachedCollection) { + val viewPager = v.findViewById(R.id.viewpager) + viewPager.adapter = TabsAdapter(childFragmentManager, this, requireContext(), cachedCollection, cachedItem) + + val tabLayout = v.findViewById(R.id.tabs) + tabLayout.setupWithViewPager(viewPager) + + v.findViewById(R.id.journal_list_item).visibility = View.GONE + } + + fun allowSendEmail(event: Event?, icsContent: String) { + emailInvitationEvent = event + emailInvitationEventString = icsContent + activity?.invalidateOptionsMenu() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.collection_item_fragment, menu) + menu.setGroupVisible(R.id.journal_item_menu_event_invite, emailInvitationEvent != null) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val accountHolder = model.value!! + when (item.itemId) { + R.id.on_send_event_invite -> { + val account = accountHolder.account + val intent = EventEmailInvitation(requireContext(), account).createIntent(emailInvitationEvent!!, emailInvitationEventString!!) + startActivity(intent) + } + R.id.on_restore_item -> { + restoreItem(accountHolder) + } + } + return super.onOptionsItemSelected(item) + } + + fun restoreItem(accountHolder: AccountHolder) { + // FIXME: This code makes the assumption that providers are all available. May not be true for tasks, and potentially others too. + val context = requireContext() + val account = accountHolder.account + val cachedCol = collectionModel.value!! + when (cachedCol.meta.collectionType) { + Constants.ETEBASE_TYPE_CALENDAR -> { + val provider = context.contentResolver.acquireContentProviderClient(CalendarContract.CONTENT_URI)!! + val localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory, cachedCol.col.uid)!! + val event = Event.eventsFromReader(StringReader(cachedItem.content))[0] + var localEvent = localCalendar.findByUid(event.uid!!) + if (localEvent != null) { + localEvent.updateAsDirty(event) + } else { + localEvent = LocalEvent(localCalendar, event, event.uid, null) + localEvent.addAsDirty() + } + } + Constants.ETEBASE_TYPE_TASKS -> { + TaskProviderHandling.getWantedTaskSyncProvider(context)?.let { + val provider = TaskProvider.acquire(context, it)!! + val localTaskList = LocalTaskList.findByName(account, provider, LocalTaskList.Factory, cachedCol.col.uid)!! + val task = Task.tasksFromReader(StringReader(cachedItem.content))[0] + var localTask = localTaskList.findByUid(task.uid!!) + if (localTask != null) { + localTask.updateAsDirty(task) + } else { + localTask = LocalTask(localTaskList, task, task.uid, null) + localTask.addAsDirty() + } + } + } + Constants.ETEBASE_TYPE_ADDRESS_BOOK -> { + val provider = context.contentResolver.acquireContentProviderClient(ContactsContract.RawContacts.CONTENT_URI)!! + val localAddressBook = LocalAddressBook.findByUid(context, provider, account, cachedCol.col.uid)!! + val contact = Contact.fromReader(StringReader(cachedItem.content), null)[0] + if (contact.group) { + // FIXME: not currently supported + } else { + var localContact = localAddressBook.findByUid(contact.uid!!) as LocalContact? + if (localContact != null) { + localContact.updateAsDirty(contact) + } else { + localContact = LocalContact(localAddressBook, contact, contact.uid, null) + localContact.createAsDirty() + } + } + } + } + + val dialog = AlertDialog.Builder(context) + .setTitle(R.string.journal_item_restore_action) + .setIcon(R.drawable.ic_restore_black) + .setMessage(R.string.journal_item_restore_dialog_body) + .setPositiveButton(android.R.string.ok) { dialog, which -> + // dismiss + } + .create() + dialog.show() + } +} + +private class TabsAdapter(fm: FragmentManager, private val mainFragment: CollectionItemFragment, private val context: Context, private val cachedCollection: CachedCollection, private val cachedItem: CachedItem) : FragmentPagerAdapter(fm) { + + override fun getCount(): Int { + // FIXME: Make it depend on info enumType (only have non-raw for known types) + return 3 + } + + override fun getPageTitle(position: Int): CharSequence? { + return if (position == 0) { + context.getString(R.string.journal_item_tab_main) + } else if (position == 1) { + context.getString(R.string.journal_item_tab_raw) + } else { + context.getString(R.string.journal_item_tab_revisions) + } + } + + override fun getItem(position: Int): Fragment { + return if (position == 0) { + PrettyFragment(mainFragment, cachedCollection, cachedItem.content) + } else if (position == 1) { + TextFragment(cachedItem.content) + } else { + ItemRevisionsListFragment(cachedCollection, cachedItem) + } + } +} + + +class TextFragment(private val content: String) : Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val v = inflater.inflate(R.layout.text_fragment, container, false) + + val tv = v.findViewById(R.id.content) as TextView + + tv.text = content + + return v + } +} + +class PrettyFragment(private val mainFragment: CollectionItemFragment, private val cachedCollection: CachedCollection, private val content: String) : Fragment() { + private var asyncTask: Future? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + var v: View? = null + + when (cachedCollection.meta.collectionType) { + Constants.ETEBASE_TYPE_ADDRESS_BOOK -> { + v = inflater.inflate(R.layout.contact_info, container, false) + asyncTask = loadContactTask(v) + } + Constants.ETEBASE_TYPE_CALENDAR -> { + v = inflater.inflate(R.layout.event_info, container, false) + asyncTask = loadEventTask(v) + } + Constants.ETEBASE_TYPE_TASKS -> { + v = inflater.inflate(R.layout.task_info, container, false) + asyncTask = loadTaskTask(v) + } + } + + return v + } + + override fun onDestroyView() { + super.onDestroyView() + if (asyncTask != null) + asyncTask!!.cancel(true) + } + + private fun loadEventTask(view: View): Future { + return doAsync { + var event: Event? = null + val inputReader = StringReader(content) + + try { + event = Event.eventsFromReader(inputReader, null)[0] + } catch (e: InvalidCalendarException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + + if (event != null) { + uiThread { + val loader = view.findViewById(R.id.event_info_loading_msg) + loader.visibility = View.GONE + val contentContainer = view.findViewById(R.id.event_info_scroll_view) + contentContainer.visibility = View.VISIBLE + + setTextViewText(view, R.id.title, event.summary) + + setTextViewText(view, R.id.when_datetime, getDisplayedDatetime(event.dtStart?.date?.time!!, event.dtEnd?.date!!.time, event.isAllDay(), context)) + + setTextViewText(view, R.id.where, event.location) + + val organizer = event.organizer + if (organizer != null) { + val tv = view.findViewById(R.id.organizer) as TextView + tv.text = organizer.calAddress.toString().replaceFirst("mailto:".toRegex(), "") + } else { + val organizerView = view.findViewById(R.id.organizer_container) + organizerView.visibility = View.GONE + } + + setTextViewText(view, R.id.description, event.description) + + var first = true + var sb = StringBuilder() + for (attendee in event.attendees) { + if (first) { + first = false + sb.append(getString(R.string.journal_item_attendees)).append(": ") + } else { + sb.append(", ") + } + sb.append(attendee.calAddress.toString().replaceFirst("mailto:".toRegex(), "")) + } + setTextViewText(view, R.id.attendees, sb.toString()) + + first = true + sb = StringBuilder() + for (alarm in event.alarms) { + if (first) { + first = false + sb.append(getString(R.string.journal_item_reminders)).append(": ") + } else { + sb.append(", ") + } + sb.append(alarm.trigger.value) + } + setTextViewText(view, R.id.reminders, sb.toString()) + + if (event.attendees.isNotEmpty()) { + mainFragment.allowSendEmail(event, content) + } + + } + } + } + } + + private fun loadTaskTask(view: View): Future { + return doAsync { + var task: Task? = null + val inputReader = StringReader(content) + + try { + task = Task.tasksFromReader(inputReader)[0] + } catch (e: InvalidCalendarException) { + e.printStackTrace() + } catch (e: IOException) { + e.printStackTrace() + } + + if (task != null) { + uiThread { + val loader = view.findViewById(R.id.task_info_loading_msg) + loader.visibility = View.GONE + val contentContainer = view.findViewById(R.id.task_info_scroll_view) + contentContainer.visibility = View.VISIBLE + + setTextViewText(view, R.id.title, task.summary) + + setTextViewText(view, R.id.where, task.location) + + val organizer = task.organizer + if (organizer != null) { + val tv = view.findViewById(R.id.organizer) as TextView + tv.text = organizer.calAddress.toString().replaceFirst("mailto:".toRegex(), "") + } else { + val organizerView = view.findViewById(R.id.organizer_container) + organizerView.visibility = View.GONE + } + + setTextViewText(view, R.id.description, task.description) + } + } + } + } + + private fun loadContactTask(view: View): Future { + return doAsync { + var contact: Contact? = null + val reader = StringReader(content) + + try { + contact = Contact.fromReader(reader, null)[0] + } catch (e: IOException) { + e.printStackTrace() + } + + if (contact != null) { + uiThread { + val loader = view.findViewById(R.id.loading_msg) + loader.visibility = View.GONE + val contentContainer = view.findViewById(R.id.content_container) + contentContainer.visibility = View.VISIBLE + + val tv = view.findViewById(R.id.display_name) as TextView + tv.text = contact.displayName + + if (contact.group) { + showGroup(contact) + } else { + showContact(contact) + } + } + } + } + } + + + private fun showGroup(contact: Contact) { + val view = requireView() + + val mainCard = view.findViewById(R.id.main_card) as ViewGroup + + addInfoItem(view.context, mainCard, getString(R.string.journal_item_member_count), null, contact.members.size.toString()) + + for (member in contact.members) { + addInfoItem(view.context, mainCard, getString(R.string.journal_item_member), null, member) + } + } + + + private fun showContact(contact: Contact) { + val view = requireView() + val mainCard = view.findViewById(R.id.main_card) as ViewGroup + val aboutCard = view.findViewById(R.id.about_card) as ViewGroup + aboutCard.findViewById(R.id.title_container).visibility = View.VISIBLE + + // TEL + for (labeledPhone in contact.phoneNumbers) { + val types = labeledPhone.property.types + val type = if (types.size > 0) types[0].value else null + addInfoItem(view.context, mainCard, getString(R.string.journal_item_phone), type, labeledPhone.property.text) + } + + // EMAIL + for (labeledEmail in contact.emails) { + val types = labeledEmail.property.types + val type = if (types.size > 0) types[0].value else null + addInfoItem(view.context, mainCard, getString(R.string.journal_item_email), type, labeledEmail.property.value) + } + + // ORG, TITLE, ROLE + if (contact.organization != null) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_organization), contact.jobTitle, contact.organization?.values!![0]) + } + if (contact.jobDescription != null) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_job_description), null, contact.jobTitle) + } + + // IMPP + for (labeledImpp in contact.impps) { + addInfoItem(view.context, mainCard, getString(R.string.journal_item_impp), labeledImpp.property.protocol, labeledImpp.property.handle) + } + + // NICKNAME + if (contact.nickName != null && !contact.nickName?.values?.isEmpty()!!) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_nickname), null, contact.nickName?.values!![0]) + } + + // ADR + for (labeledAddress in contact.addresses) { + val types = labeledAddress.property.types + val type = if (types.size > 0) types[0].value else null + addInfoItem(view.context, mainCard, getString(R.string.journal_item_address), type, labeledAddress.property.label) + } + + // NOTE + if (contact.note != null) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_note), null, contact.note) + } + + // URL + for (labeledUrl in contact.urls) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_website), null, labeledUrl.property.value) + } + + // ANNIVERSARY + if (contact.anniversary != null) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_anniversary), null, getDisplayedDate(contact.anniversary?.date, contact.anniversary?.partialDate)) + } + // BDAY + if (contact.birthDay != null) { + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_birthday), null, getDisplayedDate(contact.birthDay?.date, contact.birthDay?.partialDate)) + } + + // RELATED + for (related in contact.relations) { + val types = related.types + val type = if (types.size > 0) types[0].value else null + addInfoItem(view.context, aboutCard, getString(R.string.journal_item_relation), type, related.text) + } + + // PHOTO + // if (contact.photo != null) + } + + private fun getDisplayedDate(date: Date?, partialDate: PartialDate?): String? { + if (date != null) { + val epochDate = date.time + return getDisplayedDatetime(epochDate, epochDate, true, context) + } else if (partialDate != null){ + val formatter = SimpleDateFormat("d MMMM", Locale.getDefault()) + val calendar = GregorianCalendar() + calendar.set(Calendar.DAY_OF_MONTH, partialDate.date!!) + calendar.set(Calendar.MONTH, partialDate.month!! - 1) + return formatter.format(calendar.time) + } + + return null + } + + companion object { + private fun addInfoItem(context: Context, parent: ViewGroup, type: String, label: String?, value: String?): View { + val layout = parent.findViewById(R.id.container) as ViewGroup + val infoItem = LayoutInflater.from(context).inflate(R.layout.contact_info_item, layout, false) + layout.addView(infoItem) + setTextViewText(infoItem, R.id.type, type) + setTextViewText(infoItem, R.id.title, label) + setTextViewText(infoItem, R.id.content, value) + parent.visibility = View.VISIBLE + + return infoItem + } + + private fun setTextViewText(parent: View, id: Int, text: String?) { + val tv = parent.findViewById(id) as TextView + if (text == null) { + tv.visibility = View.GONE + } else { + tv.text = text + } + } + + fun getDisplayedDatetime(startMillis: Long, endMillis: Long, allDay: Boolean, context: Context?): String? { + // Configure date/time formatting. + val flagsDate = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY + var flagsTime = DateUtils.FORMAT_SHOW_TIME + if (DateFormat.is24HourFormat(context)) { + flagsTime = flagsTime or DateUtils.FORMAT_24HOUR + } + + val datetimeString: String + if (allDay) { + // For multi-day allday events or single-day all-day events that are not + // today or tomorrow, use framework formatter. + + // We need to remove 24hrs because full day events are from the start of a day until the start of the next + var adjustedEnd = endMillis - 24 * 60 * 60 * 1000; + if (adjustedEnd < startMillis) { + adjustedEnd = startMillis; + } + val f = Formatter(StringBuilder(50), Locale.getDefault()) + datetimeString = DateUtils.formatDateRange(context, f, startMillis, + adjustedEnd, flagsDate).toString() + } else { + // For multiday events, shorten day/month names. + // Example format: "Fri Apr 6, 5:00pm - Sun, Apr 8, 6:00pm" + val flagsDatetime = flagsDate or flagsTime or DateUtils.FORMAT_ABBREV_MONTH or + DateUtils.FORMAT_ABBREV_WEEKDAY + datetimeString = DateUtils.formatDateRange(context, startMillis, endMillis, + flagsDatetime) + } + return datetimeString + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionMembersFragment.kt b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionMembersFragment.kt new file mode 100644 index 00000000..ef9cc387 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/ui/etebase/CollectionMembersFragment.kt @@ -0,0 +1,168 @@ +package com.etesync.syncadapter.ui.etebase + +import android.app.Dialog +import android.app.ProgressDialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import com.etebase.client.CollectionAccessLevel +import com.etebase.client.Utils +import com.etebase.client.exceptions.EtebaseException +import com.etebase.client.exceptions.NotFoundException +import com.etesync.syncadapter.CachedCollection +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.R +import com.etesync.syncadapter.resource.LocalCalendar +import com.etesync.syncadapter.syncadapter.requestSync +import com.etesync.syncadapter.ui.BaseActivity +import org.jetbrains.anko.doAsync +import org.jetbrains.anko.uiThread + +class CollectionMembersFragment : Fragment() { + private val model: AccountViewModel by activityViewModels() + private val collectionModel: CollectionViewModel by activityViewModels() + private var isAdmin: Boolean = false + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val ret = if (collectionModel.value!!.col.accessLevel == CollectionAccessLevel.Admin) { + isAdmin = true + inflater.inflate(R.layout.etebase_view_collection_members, container, false) + } else { + inflater.inflate(R.layout.etebase_view_collection_members_no_access, container, false) + } + + if (savedInstanceState == null) { + collectionModel.observe(this) { + (activity as? BaseActivity?)?.supportActionBar?.setTitle(R.string.collection_members_title) + if (container != null) { + initUi(inflater, ret, it) + } + } + } + + return ret + } + + private fun initUi(inflater: LayoutInflater, v: View, cachedCollection: CachedCollection) { + val meta = cachedCollection.meta + val colorSquare = v.findViewById(R.id.color) + val color = LocalCalendar.parseColor(meta.color) + when (meta.collectionType) { + Constants.ETEBASE_TYPE_CALENDAR -> { + colorSquare.setBackgroundColor(color) + } + Constants.ETEBASE_TYPE_TASKS -> { + colorSquare.setBackgroundColor(color) + } + Constants.ETEBASE_TYPE_ADDRESS_BOOK -> { + colorSquare.visibility = View.GONE + } + } + + val title = v.findViewById(R.id.display_name) as TextView + title.text = meta.name + + val desc = v.findViewById(R.id.description) as TextView + desc.text = meta.description + + if (isAdmin) { + v.findViewById(R.id.add_member).setOnClickListener { + addMemberClicked() + } + } else { + v.findViewById