Repo created
This commit is contained in:
parent
324070df30
commit
2d33a757bf
644 changed files with 99721 additions and 2 deletions
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact>
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.repository.DavCollectionRepository
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook.Companion.USER_DATA_READ_ONLY
|
||||
import at.bitfire.davdroid.resource.workaround.ContactDirtyVerifier
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.sync.SyncDataType
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.sync.adapter.SyncFrameworkIntegration
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* A local address book. Requires its own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVx5 creates a "DAVx5
|
||||
* address book" account for every CardDAV address book.
|
||||
*
|
||||
* @param account DAVx5 account which "owns" this address book
|
||||
* @param _addressBookAccount Address book account (not: DAVx5 account) storing the actual Android
|
||||
* contacts. This is the initial value of [addressBookAccount]. However when the address book is renamed,
|
||||
* the new name will only be available in [addressBookAccount], so usually that one should be used.
|
||||
* @param provider Content provider needed to access and modify the address book
|
||||
*/
|
||||
@OpenForTesting
|
||||
open class LocalAddressBook @AssistedInject constructor(
|
||||
@Assisted("account") val account: Account,
|
||||
@Assisted("addressBookAccount") _addressBookAccount: Account,
|
||||
@Assisted provider: ContentProviderClient,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val collectionRepository: DavCollectionRepository,
|
||||
@ApplicationContext private val context: Context,
|
||||
internal val dirtyVerifier: Optional<ContactDirtyVerifier>,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val syncFramework: SyncFrameworkIntegration
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(_addressBookAccount, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
@Assisted("account") account: Account,
|
||||
@Assisted("addressBookAccount") addressBookAccount: Account,
|
||||
provider: ContentProviderClient
|
||||
): LocalAddressBook
|
||||
}
|
||||
|
||||
override val tag: String
|
||||
get() = "contacts-${addressBookAccount.name}"
|
||||
|
||||
override val title
|
||||
get() = addressBookAccount.name
|
||||
|
||||
private val accountManager by lazy { AccountManager.get(context) }
|
||||
|
||||
/**
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
*
|
||||
* For instance, if groupMethod is GROUP_VCARDS, [findDirty] will find only dirty [LocalContact]s,
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
open val groupMethod: GroupMethod by lazy {
|
||||
val account = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()?.let { collectionId ->
|
||||
collectionRepository.get(collectionId)?.let { collection ->
|
||||
serviceRepository.getBlocking(collection.serviceId)?.let { service ->
|
||||
Account(service.accountName, context.getString(R.string.account_type))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (account == null)
|
||||
throw IllegalArgumentException("Collection of address book account $addressBookAccount does not have an account")
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
accountSettings.getGroupMethod()
|
||||
}
|
||||
val includeGroups
|
||||
get() = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
override var dbCollectionId: Long?
|
||||
get() = accountManager.getUserData(addressBookAccount, USER_DATA_COLLECTION_ID)?.toLongOrNull()
|
||||
set(id) {
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_COLLECTION_ID, id.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only flag for the address book itself.
|
||||
*
|
||||
* Setting this flag:
|
||||
*
|
||||
* - stores the new value in [USER_DATA_READ_ONLY] and
|
||||
* - sets the read-only flag for all contacts and groups in the address book in the content provider, which will
|
||||
* prevent non-sync-adapter apps from modifying them. However new entries can still be created, so the address book
|
||||
* is not really read-only.
|
||||
*
|
||||
* Reading this flag returns the stored value from [USER_DATA_READ_ONLY].
|
||||
*/
|
||||
override var readOnly: Boolean
|
||||
get() = accountManager.getUserData(addressBookAccount, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) {
|
||||
// set read-only flag for address book itself
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
// update raw contacts
|
||||
val rawContactValues = contentValuesOf(RawContacts.RAW_CONTACT_IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(rawContactsSyncUri(), rawContactValues, null, null)
|
||||
|
||||
// update data rows
|
||||
val dataValues = contentValuesOf(ContactsContract.Data.IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(syncAdapterURI(ContactsContract.Data.CONTENT_URI), dataValues, null, null)
|
||||
|
||||
// update group rows
|
||||
val groupValues = contentValuesOf(Groups.GROUP_IS_READ_ONLY to if (readOnly) 1 else 0)
|
||||
provider!!.update(groupsSyncUri(), groupValues, null, null)
|
||||
}
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
set(state) {
|
||||
syncState = state?.toString()?.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = contentValuesOf(LocalContact.COLUMN_FLAGS to flags)
|
||||
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
|
||||
|
||||
if (includeGroups) {
|
||||
values.clear()
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
number += provider!!.update(groupsSyncUri(), values, "NOT ${Groups.DIRTY}", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"NOT ${RawContacts.DIRTY} AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider!!.delete(groupsSyncUri(),
|
||||
"NOT ${Groups.DIRTY} AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an address book account and moves the contacts and groups (without making them dirty).
|
||||
* Does not keep user data of the old account, so these have to be set again.
|
||||
*
|
||||
* On success, [addressBookAccount] will be updated to the new account name.
|
||||
*
|
||||
* _Note:_ Previously, we had used [AccountManager.renameAccount], but then the contacts can't be moved because there's never
|
||||
* a moment when both accounts are available.
|
||||
*
|
||||
* @param newName the new account name (account type is taken from [addressBookAccount])
|
||||
*
|
||||
* @return whether the account was renamed successfully
|
||||
*/
|
||||
internal fun renameAccount(newName: String): Boolean {
|
||||
val oldAccount = addressBookAccount
|
||||
logger.info("Renaming address book from \"${oldAccount.name}\" to \"$newName\"")
|
||||
|
||||
// create new account
|
||||
val newAccount = Account(newName, oldAccount.type)
|
||||
if (!SystemAccountUtils.createAccount(context, newAccount, Bundle()))
|
||||
return false
|
||||
|
||||
// move contacts and groups to new account
|
||||
val batch = ContactsBatchOperation(provider!!)
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(groupsSyncUri())
|
||||
.withSelection(Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(Groups.ACCOUNT_NAME, newAccount.name)
|
||||
.withValue(Groups.ACCOUNT_TYPE, newAccount.type)
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(rawContactsSyncUri())
|
||||
.withSelection(RawContacts.ACCOUNT_NAME + "=? AND " + RawContacts.ACCOUNT_TYPE + "=?", arrayOf(oldAccount.name, oldAccount.type))
|
||||
.withValue(RawContacts.ACCOUNT_NAME, newAccount.name)
|
||||
.withValue(RawContacts.ACCOUNT_TYPE, newAccount.type)
|
||||
batch.commit()
|
||||
|
||||
// update AndroidAddressBook.account
|
||||
addressBookAccount = newAccount
|
||||
|
||||
// delete old account
|
||||
accountManager.removeAccountExplicitly(oldAccount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enables or disables sync on content changes for the address book account based on the current sync
|
||||
* interval account setting.
|
||||
*/
|
||||
fun updateSyncFrameworkSettings() {
|
||||
val accountSettings = accountSettingsFactory.create(account)
|
||||
val syncInterval = accountSettings.getSyncInterval(SyncDataType.CONTACTS)
|
||||
|
||||
// Enable/Disable content triggered syncs for the address book account.
|
||||
if (syncInterval != null)
|
||||
syncFramework.enableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
else
|
||||
syncFramework.disableSyncOnContentChange(addressBookAccount, ContactsContract.AUTHORITY)
|
||||
}
|
||||
|
||||
|
||||
/* operations on members (contacts/groups) */
|
||||
|
||||
override fun findByName(name: String): LocalAddress? {
|
||||
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
return if (includeGroups)
|
||||
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
else
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDeleted() =
|
||||
if (includeGroups)
|
||||
findDeletedContacts() + findDeletedGroups()
|
||||
else
|
||||
findDeletedContacts()
|
||||
|
||||
fun findDeletedContacts() = queryContacts(RawContacts.DELETED, null)
|
||||
fun findDeletedGroups() = queryGroups(Groups.DELETED, null)
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDirty() =
|
||||
if (includeGroups)
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
fun findDirtyContacts() = queryContacts(RawContacts.DIRTY, null)
|
||||
fun findDirtyGroups() = queryGroups(Groups.DIRTY, null)
|
||||
|
||||
override fun forgetETags() {
|
||||
if (includeGroups) {
|
||||
val values = contentValuesOf(AndroidGroup.COLUMN_ETAG to null)
|
||||
provider!!.update(groupsSyncUri(), values, null, null)
|
||||
}
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_ETAG to null)
|
||||
provider!!.update(rawContactsSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
fun getContactIdsByGroupMembership(groupId: Long): List<Long> {
|
||||
val ids = LinkedList<Long>()
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI), arrayOf(GroupMembership.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupId.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
fun getContactUidFromId(contactId: Long): String? {
|
||||
provider!!.query(rawContactsSyncUri(), arrayOf(AndroidContact.COLUMN_UID),
|
||||
"${RawContacts._ID}=?", arrayOf(contactId.toString()), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
/* special group operations */
|
||||
|
||||
/**
|
||||
* Finds the first group with the given title. If there is no group with this
|
||||
* title, a new group is created.
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
fun findOrCreateGroup(title: String): Long {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = contentValuesOf(Groups.TITLE to title)
|
||||
val uri = provider!!.insert(syncAdapterURI(Groups.CONTENT_URI), values) ?: throw RemoteException("Couldn't create contact group")
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
queryGroups(null, null).filter { it.getMembers().isEmpty() }.forEach { group ->
|
||||
logger.log(Level.FINE, "Deleting group", group)
|
||||
group.delete()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
const val USER_DATA_ACCOUNT_NAME = "account_name"
|
||||
const val USER_DATA_ACCOUNT_TYPE = "account_type"
|
||||
|
||||
/**
|
||||
* ID of the corresponding database [at.bitfire.davdroid.db.Collection].
|
||||
*
|
||||
* User data of the address book account (Long).
|
||||
*/
|
||||
const val USER_DATA_COLLECTION_ID = "collection_id"
|
||||
|
||||
/**
|
||||
* Indicates whether the address book is currently set to read-only (i.e. its contacts and groups have the read-only flag).
|
||||
*
|
||||
* User data of the address book account (Boolean).
|
||||
*/
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.provider.ContactsContract
|
||||
import androidx.annotation.OpenForTesting
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.os.bundleOf
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.settings.SettingsManager
|
||||
import at.bitfire.davdroid.sync.account.SystemAccountUtils
|
||||
import at.bitfire.davdroid.sync.account.setAndVerifyUserData
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import com.google.common.base.CharMatcher
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalAddressBookStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val localAddressBookFactory: LocalAddressBook.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository,
|
||||
private val settings: SettingsManager
|
||||
): LocalDataStore<LocalAddressBook> {
|
||||
|
||||
override val authority: String
|
||||
get() = ContactsContract.AUTHORITY
|
||||
|
||||
/** whether a (usually managed) setting wants all address-books to be read-only **/
|
||||
val forceAllReadOnly: Boolean
|
||||
get() = settings.getBoolean(Settings.FORCE_READ_ONLY_ADDRESSBOOKS)
|
||||
|
||||
|
||||
/**
|
||||
* Assembles a name for the address book (account) from its corresponding database [Collection].
|
||||
*
|
||||
* The address book account name contains
|
||||
*
|
||||
* - the collection display name or last URL path segment (filtered for dangerous special characters)
|
||||
* - the actual account name
|
||||
* - the collection ID, to make it unique.
|
||||
*
|
||||
* @param info Collection to take info from
|
||||
*/
|
||||
fun accountName(info: Collection): String {
|
||||
// Name of address book is given collection display name, otherwise the last URL path segment
|
||||
var name = info.displayName.takeIf { !it.isNullOrEmpty() } ?: info.url.lastSegment
|
||||
|
||||
// Remove ISO control characters + SQL problematic characters
|
||||
name = CharMatcher
|
||||
.javaIsoControl()
|
||||
.or(CharMatcher.anyOf("`'\""))
|
||||
.removeFrom(name)
|
||||
|
||||
// Add the actual account name to the address book account name
|
||||
val sb = StringBuilder(name)
|
||||
serviceRepository.getBlocking(info.serviceId)?.let { service ->
|
||||
sb.append(" (${service.accountName})")
|
||||
}
|
||||
// Add the collection ID for uniqueness
|
||||
sb.append(" #${info.id}")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalAddressBook? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
val name = accountName(fromCollection)
|
||||
val addressBookAccount = createAddressBookAccount(
|
||||
account = account,
|
||||
name = name,
|
||||
id = fromCollection.id
|
||||
) ?: return null
|
||||
|
||||
val addressBook = localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
|
||||
// update settings
|
||||
addressBook.updateSyncFrameworkSettings()
|
||||
addressBook.settings = contactsProviderSettings
|
||||
addressBook.readOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
@OpenForTesting
|
||||
internal fun createAddressBookAccount(account: Account, name: String, id: Long): Account? {
|
||||
// create address book account with reference to account, collection ID and URL
|
||||
val addressBookAccount = Account(name, context.getString(R.string.account_type_address_book))
|
||||
val userData = bundleOf(
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_NAME to account.name,
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_TYPE to account.type,
|
||||
LocalAddressBook.USER_DATA_COLLECTION_ID to id.toString()
|
||||
)
|
||||
if (!SystemAccountUtils.createAccount(context, addressBookAccount, userData)) {
|
||||
logger.warning("Couldn't create address book account: $addressBookAccount")
|
||||
return null
|
||||
}
|
||||
|
||||
return addressBookAccount
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalAddressBook> =
|
||||
getAddressBookAccounts(account).map { addressBookAccount ->
|
||||
localAddressBookFactory.create(account, addressBookAccount, provider)
|
||||
}
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalAddressBook, fromCollection: Collection) {
|
||||
var currentAccount = localCollection.addressBookAccount
|
||||
logger.log(Level.INFO, "Updating local address book $currentAccount from collection $fromCollection")
|
||||
|
||||
// Update the account name
|
||||
val newAccountName = accountName(fromCollection)
|
||||
if (currentAccount.name != newAccountName) {
|
||||
// rename, move contacts/groups and update [AndroidAddressBook.]account
|
||||
localCollection.renameAccount(newAccountName)
|
||||
currentAccount = Account(newAccountName, currentAccount.type)
|
||||
}
|
||||
|
||||
// Update the account user data
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, localCollection.account.name)
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, localCollection.account.type)
|
||||
accountManager.setAndVerifyUserData(currentAccount, LocalAddressBook.USER_DATA_COLLECTION_ID, fromCollection.id.toString())
|
||||
|
||||
// Set contacts provider settings
|
||||
localCollection.settings = contactsProviderSettings
|
||||
|
||||
// Update force read only
|
||||
val nowReadOnly = shouldBeReadOnly(fromCollection, forceAllReadOnly)
|
||||
if (nowReadOnly != localCollection.readOnly) {
|
||||
logger.info("Address book has changed to read-only = $nowReadOnly")
|
||||
localCollection.readOnly = nowReadOnly
|
||||
}
|
||||
|
||||
// Update automatic synchronization
|
||||
localCollection.updateSyncFrameworkSettings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates address books which are assigned to [oldAccount] so that they're assigned to [newAccount] instead.
|
||||
*
|
||||
* @param oldAccount The old account
|
||||
* @param newAccount The new account
|
||||
*/
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME) == oldAccount.name &&
|
||||
accountManager.getUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE) == oldAccount.type
|
||||
}
|
||||
.forEach { addressBookAccount ->
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_NAME, newAccount.name)
|
||||
accountManager.setAndVerifyUserData(addressBookAccount, LocalAddressBook.USER_DATA_ACCOUNT_TYPE, newAccount.type)
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalAddressBook) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.removeAccountExplicitly(localCollection.addressBookAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a [LocalAddressBook] based on its corresponding database collection.
|
||||
*
|
||||
* @param id [Collection.id] to look for
|
||||
*/
|
||||
fun deleteByCollectionId(id: Long) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val addressBookAccount = accountManager.getAccountsByType(context.getString(R.string.account_type_address_book)).firstOrNull { account ->
|
||||
accountManager.getUserData(account, LocalAddressBook.USER_DATA_COLLECTION_ID)?.toLongOrNull() == id
|
||||
}
|
||||
if (addressBookAccount != null)
|
||||
accountManager.removeAccountExplicitly(addressBookAccount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all address book accounts that belong to the given account.
|
||||
*
|
||||
* @param account Account which has the address books.
|
||||
* @return List of address book accounts.
|
||||
*/
|
||||
fun getAddressBookAccounts(account: Account): List<Account> =
|
||||
AccountManager.get(context).let { accountManager ->
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.filter { addressBookAccount ->
|
||||
account.name == accountManager.getUserData(
|
||||
addressBookAccount,
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_NAME
|
||||
) && account.type == accountManager.getUserData(
|
||||
addressBookAccount,
|
||||
LocalAddressBook.USER_DATA_ACCOUNT_TYPE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all address book accounts that belong to the given account in a flow.
|
||||
*
|
||||
* @param account Account which has the address books.
|
||||
* @return List of address book accounts as flow.
|
||||
*/
|
||||
fun getAddressBookAccountsFlow(account: Account): Flow<List<Account>> = callbackFlow {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
trySend(getAddressBookAccounts(account))
|
||||
}
|
||||
accountManager.addOnAccountsUpdatedListener(
|
||||
/* listener = */ listener,
|
||||
/* handler = */ null,
|
||||
/* updateImmediately = */ true
|
||||
)
|
||||
awaitClose { accountManager.removeOnAccountsUpdatedListener(listener) }
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Contacts Provider Settings (equal for every address book)
|
||||
*/
|
||||
val contactsProviderSettings
|
||||
get() = contentValuesOf(
|
||||
// SHOULD_SYNC is just a hint that an account's contacts (the contacts of this local address book) are syncable.
|
||||
ContactsContract.Settings.SHOULD_SYNC to 1,
|
||||
|
||||
// UNGROUPED_VISIBLE is required for making contacts work over Bluetooth (especially with some car systems).
|
||||
ContactsContract.Settings.UNGROUPED_VISIBLE to 1
|
||||
)
|
||||
|
||||
/**
|
||||
* Determines whether the address book should be set to read-only.
|
||||
*
|
||||
* @param forceAllReadOnly Whether (usually managed, app-wide) setting should overwrite local read-only information
|
||||
* @param info Collection data to determine read-only status from (either user-set read-only flag or missing write privilege)
|
||||
*/
|
||||
@VisibleForTesting
|
||||
internal fun shouldBeReadOnly(info: Collection, forceAllReadOnly: Boolean): Boolean =
|
||||
info.readOnly() || forceAllReadOnly
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendar
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import at.bitfire.synctools.storage.calendar.CalendarBatchOperation
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import java.util.LinkedList
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* Application-specific subclass of [AndroidCalendar] for local calendars.
|
||||
*
|
||||
* [Calendars._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalCalendar @AssistedInject constructor(
|
||||
@Assisted internal val androidCalendar: AndroidCalendar,
|
||||
private val logger: Logger
|
||||
) : LocalCollection<LocalEvent> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(calendar: AndroidCalendar): LocalCalendar
|
||||
}
|
||||
|
||||
|
||||
// properties
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = androidCalendar.syncId?.toLongOrNull()
|
||||
|
||||
override val tag: String
|
||||
get() = "events-${androidCalendar.account.name}-${androidCalendar.id}"
|
||||
|
||||
override val title: String
|
||||
get() = androidCalendar.displayName ?: androidCalendar.id.toString()
|
||||
|
||||
override val readOnly
|
||||
get() = androidCalendar.accessLevel <= Calendars.CAL_ACCESS_READ
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = androidCalendar.readSyncState()?.let {
|
||||
SyncState.fromString(it)
|
||||
}
|
||||
set(state) {
|
||||
androidCalendar.writeSyncState(state.toString())
|
||||
}
|
||||
|
||||
private val recurringCalendar = AndroidRecurringCalendar(androidCalendar)
|
||||
|
||||
|
||||
fun add(event: Event, fileName: String, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val mapped = LegacyAndroidEventBuilder2(
|
||||
calendar = androidCalendar,
|
||||
event = event,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.addEventAndExceptions(mapped)
|
||||
}
|
||||
|
||||
override fun findDeleted(): List<LocalEvent> {
|
||||
val result = LinkedList<LocalEvent>()
|
||||
androidCalendar.iterateEvents( "${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL", null) { entity ->
|
||||
result += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, entity))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
/*
|
||||
* RFC 5545 3.8.7.4. Sequence Number
|
||||
* When a calendar component is created, its sequence number is 0. It is monotonically incremented by the "Organizer's"
|
||||
* CUA each time the "Organizer" makes a significant revision to the calendar component.
|
||||
*/
|
||||
androidCalendar.iterateEvents("${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NULL", null) { values ->
|
||||
dirty += LocalEvent(recurringCalendar, AndroidEvent2(androidCalendar, values))
|
||||
}
|
||||
|
||||
return dirty
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
androidCalendar.findEvent("${Events._SYNC_ID}=? AND ${Events.ORIGINAL_SYNC_ID} IS null", arrayOf(name))?.let {
|
||||
LocalEvent(recurringCalendar, it)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int) =
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_FLAGS to flags),
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
// list all non-dirty events with the given flags and delete every row + its exceptions
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID),
|
||||
// `dirty` can be 0, 1, or null. "NOT dirty" is not enough.
|
||||
"""
|
||||
${Events.CALENDAR_ID}=?
|
||||
AND (${Events.DIRTY} IS NULL OR ${Events.DIRTY}=0)
|
||||
AND ${Events.ORIGINAL_ID} IS NULL
|
||||
AND ${AndroidEvent2.COLUMN_FLAGS}=?
|
||||
""".trimIndent(),
|
||||
arrayOf(androidCalendar.id.toString(), flags.toString())
|
||||
) { values ->
|
||||
val id = values.getAsLong(Events._ID)
|
||||
|
||||
// delete event and possible exceptions (content provider doesn't delete exceptions itself)
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newDelete(androidCalendar.eventsUri)
|
||||
.withSelection("${Events._ID}=? OR ${Events.ORIGINAL_ID}=?", arrayOf(id.toString(), id.toString()))
|
||||
}
|
||||
return batch.commit()
|
||||
}
|
||||
|
||||
override fun forgetETags() {
|
||||
androidCalendar.updateEventRows(
|
||||
contentValuesOf(AndroidEvent2.COLUMN_ETAG to null),
|
||||
"${Events.CALENDAR_ID}=?", arrayOf(androidCalendar.id.toString())
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
// process deleted exceptions
|
||||
logger.info("Processing deleted exceptions")
|
||||
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
logger.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
|
||||
val id = values.getAsLong(Events._ID) // can't be null (by definition)
|
||||
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
|
||||
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
// enqueue: increase sequence of main event
|
||||
val originalEventValues = androidCalendar.getEventRow(originalID, arrayOf(AndroidEvent2.COLUMN_SEQUENCE))
|
||||
val originalSequence = originalEventValues?.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(ContentUris.withAppendedId(Events.CONTENT_URI, originalID).asSyncAdapter(androidCalendar.account))
|
||||
.withValue(AndroidEvent2.COLUMN_SEQUENCE, originalSequence + 1)
|
||||
.withValue(Events.DIRTY, 1)
|
||||
|
||||
// completely remove deleted exception
|
||||
batch += BatchOperation.CpoBuilder.newDelete(ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(androidCalendar.account))
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
logger.info("Processing dirty exceptions")
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, AndroidEvent2.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY} AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(androidCalendar.id.toString())
|
||||
) { values ->
|
||||
logger.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
|
||||
val id = values.getAsLong(Events._ID) // can't be null (by definition)
|
||||
val originalID = values.getAsLong(Events.ORIGINAL_ID) // can't be null (by query)
|
||||
val sequence = values.getAsInteger(AndroidEvent2.COLUMN_SEQUENCE) ?: 0
|
||||
|
||||
val batch = CalendarBatchOperation(androidCalendar.client)
|
||||
|
||||
// enqueue: set original event to DIRTY
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(androidCalendar.eventUri(originalID))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
|
||||
// enqueue: increase exception SEQUENCE and set DIRTY to 0
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(androidCalendar.eventUri(id))
|
||||
.withValue(AndroidEvent2.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks dirty events (which are not already marked as deleted) which got no valid instances as "deleted"
|
||||
*
|
||||
* @return number of affected events
|
||||
*/
|
||||
fun deleteDirtyEventsWithoutInstances() {
|
||||
// Iterate dirty main events without exceptions
|
||||
androidCalendar.iterateEventRows(
|
||||
arrayOf(Events._ID),
|
||||
"${Events.DIRTY} AND NOT ${Events.DELETED} AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
null
|
||||
) { values ->
|
||||
val eventId = values.getAsLong(Events._ID)
|
||||
|
||||
// get number of instances
|
||||
val numEventInstances = androidCalendar.numInstances(eventId)
|
||||
|
||||
// delete event if there are no instances
|
||||
if (numEventInstances == 0) {
|
||||
logger.fine("Marking event #$eventId without instances as deleted")
|
||||
androidCalendar.updateEventRow(eventId, contentValuesOf(Events.DELETED to 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Attendees
|
||||
import android.provider.CalendarContract.Calendars
|
||||
import android.provider.CalendarContract.Events
|
||||
import android.provider.CalendarContract.Reminders
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.DavServiceRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.util.DateUtils
|
||||
import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter
|
||||
import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalCalendarStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val accountSettingsFactory: AccountSettings.Factory,
|
||||
private val localCalendarFactory: LocalCalendar.Factory,
|
||||
private val logger: Logger,
|
||||
private val serviceRepository: DavServiceRepository
|
||||
): LocalDataStore<LocalCalendar> {
|
||||
|
||||
override val authority: String
|
||||
get() = CalendarContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(client: ContentProviderClient, fromCollection: Collection): LocalCalendar? {
|
||||
val service = serviceRepository.getBlocking(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor =
|
||||
if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollectionInfo(
|
||||
info = collectionWithColor,
|
||||
withColor = true
|
||||
).apply {
|
||||
// ACCOUNT_NAME and ACCOUNT_TYPE are required (see docs)! If it's missing, other apps will crash.
|
||||
put(Calendars.ACCOUNT_NAME, account.name)
|
||||
put(Calendars.ACCOUNT_TYPE, account.type)
|
||||
|
||||
// Email address for scheduling. Used by the calendar provider to determine whether the
|
||||
// user is ORGANIZER/ATTENDEE for a certain event.
|
||||
put(Calendars.OWNER_ACCOUNT, account.name)
|
||||
|
||||
// flag as visible & syncable at creation, might be changed by user at any time
|
||||
put(Calendars.VISIBLE, 1)
|
||||
put(Calendars.SYNC_EVENTS, 1)
|
||||
}
|
||||
|
||||
logger.log(Level.INFO, "Adding local calendar", values)
|
||||
val provider = AndroidCalendarProvider(account, client)
|
||||
return localCalendarFactory.create(provider.createAndGetCalendar(values))
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, client: ContentProviderClient) =
|
||||
AndroidCalendarProvider(account, client)
|
||||
.findCalendars("${Calendars.SYNC_EVENTS}!=0", null)
|
||||
.map { localCalendarFactory.create(it) }
|
||||
|
||||
override fun update(client: ContentProviderClient, localCollection: LocalCalendar, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.androidCalendar.account)
|
||||
val values = valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors())
|
||||
|
||||
logger.log(Level.FINE, "Updating local calendar ${fromCollection.url}", values)
|
||||
val androidCalendar = localCollection.androidCalendar
|
||||
val provider = AndroidCalendarProvider(androidCalendar.account, client)
|
||||
provider.updateCalendar(androidCalendar.id, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = contentValuesOf(
|
||||
Calendars._SYNC_ID to info.id,
|
||||
Calendars.CALENDAR_DISPLAY_NAME to
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName,
|
||||
|
||||
Calendars.ALLOWED_AVAILABILITY to arrayOf(
|
||||
Events.AVAILABILITY_BUSY,
|
||||
Events.AVAILABILITY_FREE
|
||||
).joinToString(",") { it.toString() },
|
||||
|
||||
Calendars.ALLOWED_ATTENDEE_TYPES to arrayOf(
|
||||
Attendees.TYPE_NONE,
|
||||
Attendees.TYPE_OPTIONAL,
|
||||
Attendees.TYPE_REQUIRED,
|
||||
Attendees.TYPE_RESOURCE
|
||||
).joinToString(",") { it.toString() },
|
||||
|
||||
Calendars.ALLOWED_REMINDERS to arrayOf(
|
||||
Reminders.METHOD_DEFAULT,
|
||||
Reminders.METHOD_ALERT,
|
||||
Reminders.METHOD_EMAIL
|
||||
).joinToString(",") { it.toString() },
|
||||
)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly) {
|
||||
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)
|
||||
} else
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
|
||||
info.timezoneId?.let { tzId ->
|
||||
values.put(Calendars.CALENDAR_TIME_ZONE, DateUtils.findAndroidTimezoneID(tzId))
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
val values = contentValuesOf(Calendars.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Calendars.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)?.use {
|
||||
it.update(uri, values, "${Calendars.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalCalendar) {
|
||||
logger.log(Level.INFO, "Deleting local calendar", localCollection)
|
||||
localCollection.androidCalendar.delete()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
/** a tag that uniquely identifies the collection (DAVx5-wide) */
|
||||
val tag: String
|
||||
|
||||
/** ID of the collection in the database (corresponds to [at.bitfire.davdroid.db.Collection.id]) */
|
||||
val dbCollectionId: Long?
|
||||
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
var lastSyncState: SyncState?
|
||||
|
||||
/**
|
||||
* Whether the collection should be treated as read-only on sync.
|
||||
* Stops uploading dirty events (Server side changes are still downloaded).
|
||||
*/
|
||||
val readOnly: Boolean
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *deleted* by the user
|
||||
* or an app acting on their behalf.
|
||||
*
|
||||
* @return list of resources marked as *deleted*
|
||||
*/
|
||||
fun findDeleted(): List<T>
|
||||
|
||||
/**
|
||||
* Finds local resources of this collection which have been marked as *dirty*, i.e. resources
|
||||
* which have been modified by the user or an app acting on their behalf.
|
||||
*
|
||||
* @return list of resources marked as *dirty*
|
||||
*/
|
||||
fun findDirty(): List<T>
|
||||
|
||||
/**
|
||||
* Finds a local resource of this collection with a given file name. (File names are assigned
|
||||
* by the sync adapter.)
|
||||
*
|
||||
* @param name file name to look for
|
||||
* @return resource with the given name, or null if none
|
||||
*/
|
||||
fun findByName(name: String): T?
|
||||
|
||||
/**
|
||||
* Updates the flags value for entries which are not dirty.
|
||||
*
|
||||
* @param flags value of flags to set (for instance, [LocalResource.FLAG_REMOTELY_PRESENT]])
|
||||
*
|
||||
* @return number of marked entries
|
||||
*/
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes entries which are not dirty with a given flag combination.
|
||||
*
|
||||
* @param flags exact flags value to remove entries with (for instance, if this is [LocalResource.FLAG_REMOTELY_PRESENT]],
|
||||
* all entries with exactly this flag will be removed)
|
||||
*
|
||||
* @return number of removed entries
|
||||
*/
|
||||
fun removeNotDirtyMarked(flags: Int): Int
|
||||
|
||||
|
||||
/**
|
||||
* Forgets the ETags of all members so that they will be reloaded from the server during sync.
|
||||
*/
|
||||
fun forgetETags()
|
||||
|
||||
}
|
||||
239
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
239
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalContact.kt
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import android.provider.ContactsContract.RawContacts.getContactLookupUri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.contactrow.CachedGroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipBuilder
|
||||
import at.bitfire.davdroid.resource.contactrow.GroupMembershipHandler
|
||||
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesBuilder
|
||||
import at.bitfire.davdroid.resource.contactrow.UnknownPropertiesHandler
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidContactFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import com.google.common.base.Ascii
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_FLAGS = RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = RawContacts.SYNC3
|
||||
}
|
||||
|
||||
override val addressBook: LocalAddressBook
|
||||
get() = super.addressBook as LocalAddressBook
|
||||
|
||||
internal val cachedGroupMemberships = HashSet<Long>()
|
||||
internal val groupMemberships = HashSet<Long>()
|
||||
|
||||
override val scheduleTag: String?
|
||||
get() = null
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
constructor(addressBook: LocalAddressBook, values: ContentValues): super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: LocalAddressBook, contact: Contact, fileName: String?, eTag: String?, _flags: Int): super(addressBook, contact, fileName, eTag) {
|
||||
flags = _flags
|
||||
}
|
||||
|
||||
init {
|
||||
processor.registerHandler(CachedGroupMembershipHandler(this))
|
||||
processor.registerHandler(GroupMembershipHandler(this))
|
||||
processor.registerHandler(UnknownPropertiesHandler)
|
||||
processor.registerBuilderFactory(GroupMembershipBuilder.Factory(addressBook))
|
||||
processor.registerBuilderFactory(UnknownPropertiesBuilder.Factory)
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val contact = getContact()
|
||||
val uid: String = contact.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in contacts provider
|
||||
val values = contentValuesOf(COLUMN_UID to newUid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
// update this event
|
||||
contact.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cached [contact] so that the next read of [contact] will query the content provider again.
|
||||
*/
|
||||
fun clearCachedContact() {
|
||||
_contact = null
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contacts must not have a Schedule-Tag")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(RawContacts.DIRTY, 0)
|
||||
|
||||
// Android 7 workaround
|
||||
addressBook.dirtyVerifier.getOrNull()?.setHashCodeColumn(this, values)
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
fun resetDirty() {
|
||||
val values = contentValuesOf(RawContacts.DIRTY to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(ContactsContract.Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("flags", flags)
|
||||
.add("contact",
|
||||
try {
|
||||
Ascii.truncate(getContact().toString(), 1000, "…")
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context): Uri? =
|
||||
id?.let { idNotNull ->
|
||||
getContactLookupUri(
|
||||
context.contentResolver,
|
||||
ContentUris.withAppendedId(RawContacts.CONTENT_URI, idNotNull)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun addToGroup(batch: ContactsBatchOperation, groupID: Long) {
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(GroupMembership.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
groupMemberships += groupID
|
||||
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newInsert(dataSyncURI())
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
cachedGroupMemberships += groupID
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newDelete(dataSyncURI())
|
||||
.withSelection(
|
||||
"${Data.RAW_CONTACT_ID}=? AND ${Data.MIMETYPE} IN (?,?)",
|
||||
arrayOf(id.toString(), GroupMembership.CONTENT_ITEM_TYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
)
|
||||
groupMemberships.clear()
|
||||
cachedGroupMemberships.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact was member of (cached memberships).
|
||||
* Cached memberships are kept in sync with memberships by DAVx5 and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
getContact()
|
||||
return cachedGroupMemberships
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the IDs of all groups the contact is member of.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
fun getGroupMemberships(): Set<Long> {
|
||||
getContact()
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
|
||||
override fun buildContact(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
|
||||
LocalContact(addressBook as LocalAddressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
|
||||
/**
|
||||
* Represents a local data store for a specific collection type.
|
||||
* Manages creation, update, and deletion of collections of the given type.
|
||||
*/
|
||||
interface LocalDataStore<T: LocalCollection<*>> {
|
||||
|
||||
/**
|
||||
* Content provider authority for the data store.
|
||||
*/
|
||||
val authority: String
|
||||
|
||||
/**
|
||||
* Acquires a content provider client for the data store. The result of this call
|
||||
* should be passed to all other methods of this class.
|
||||
*
|
||||
* **The caller is responsible for closing the content provider client!**
|
||||
*
|
||||
* @param throwOnMissingPermissions If `true`, the function will throw [SecurityException] if permissions are not granted.
|
||||
*
|
||||
* @return the content provider client, or `null` if the content provider could not be acquired (or permissions are not
|
||||
* granted and [throwOnMissingPermissions] is `false`)
|
||||
*
|
||||
* @throws SecurityException on missing permissions
|
||||
*/
|
||||
fun acquireContentProvider(throwOnMissingPermissions: Boolean = false): ContentProviderClient?
|
||||
|
||||
/**
|
||||
* Creates a new local collection from the given (remote) collection info.
|
||||
*
|
||||
* @param client the content provider client
|
||||
* @param fromCollection collection info
|
||||
*
|
||||
* @return the new local collection, or `null` if creation failed
|
||||
*/
|
||||
fun create(client: ContentProviderClient, fromCollection: Collection): T?
|
||||
|
||||
/**
|
||||
* Returns all local collections of the data store, including those which don't have a corresponding remote
|
||||
* [Collection] entry.
|
||||
*
|
||||
* @param account the account that the data store is associated with
|
||||
* @param client the content provider client
|
||||
*
|
||||
* @return a list of all local collections
|
||||
*/
|
||||
fun getAll(account: Account, client: ContentProviderClient): List<T>
|
||||
|
||||
/**
|
||||
* Updates the local collection with the data from the given (remote) collection info.
|
||||
*
|
||||
* @param client the content provider client
|
||||
* @param localCollection the local collection to update
|
||||
* @param fromCollection collection info
|
||||
*/
|
||||
fun update(client: ContentProviderClient, localCollection: T, fromCollection: Collection)
|
||||
|
||||
/**
|
||||
* Deletes the local collection.
|
||||
*
|
||||
* @param localCollection the local collection to delete
|
||||
*/
|
||||
fun delete(localCollection: T)
|
||||
|
||||
/**
|
||||
* Changes the account assigned to the containing data to another one.
|
||||
*
|
||||
* @param oldAccount The old account.
|
||||
* @param newAccount The new account.
|
||||
*/
|
||||
fun updateAccount(oldAccount: Account, newAccount: Account)
|
||||
|
||||
}
|
||||
197
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
197
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalEvent.kt
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.Events
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.LegacyAndroidCalendar
|
||||
import at.bitfire.synctools.mapping.calendar.LegacyAndroidEventBuilder2
|
||||
import at.bitfire.synctools.storage.LocalStorageException
|
||||
import at.bitfire.synctools.storage.calendar.AndroidEvent2
|
||||
import at.bitfire.synctools.storage.calendar.AndroidRecurringCalendar
|
||||
import com.google.common.base.Ascii
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class LocalEvent(
|
||||
val recurringCalendar: AndroidRecurringCalendar,
|
||||
val androidEvent: AndroidEvent2
|
||||
) : LocalResource<Event> {
|
||||
|
||||
override val id: Long
|
||||
get() = androidEvent.id
|
||||
|
||||
override val fileName: String?
|
||||
get() = androidEvent.syncId
|
||||
|
||||
override val eTag: String?
|
||||
get() = androidEvent.eTag
|
||||
|
||||
override val scheduleTag: String?
|
||||
get() = androidEvent.scheduleTag
|
||||
|
||||
override val flags: Int
|
||||
get() = androidEvent.flags
|
||||
|
||||
|
||||
override fun update(data: Event, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
val eventAndExceptions = LegacyAndroidEventBuilder2(
|
||||
calendar = androidEvent.calendar,
|
||||
event = data,
|
||||
syncId = fileName,
|
||||
eTag = eTag,
|
||||
scheduleTag = scheduleTag,
|
||||
flags = flags
|
||||
).build()
|
||||
recurringCalendar.updateEventAndExceptions(id, eventAndExceptions)
|
||||
}
|
||||
|
||||
|
||||
private var _event: Event? = null
|
||||
/**
|
||||
* Retrieves the event from the content provider and converts it to a legacy data object.
|
||||
*
|
||||
* Caches the result: the content provider is only queried at the first call and then
|
||||
* this method always returns the same object.
|
||||
*
|
||||
* @throws LocalStorageException if there is no local event with the ID from [androidEvent]
|
||||
*/
|
||||
@Synchronized
|
||||
fun getCachedEvent(): Event {
|
||||
_event?.let { return it }
|
||||
|
||||
val legacyCalendar = LegacyAndroidCalendar(androidEvent.calendar)
|
||||
val event = legacyCalendar.getEvent(androidEvent.id)
|
||||
?: throw LocalStorageException("Event ${androidEvent.id} not found")
|
||||
|
||||
_event = event
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the [Event] that should actually be uploaded:
|
||||
*
|
||||
* 1. Takes the [getCachedEvent].
|
||||
* 2. Calculates the new SEQUENCE.
|
||||
*
|
||||
* _Note: This method currently modifies the object returned by [getCachedEvent], but
|
||||
* this may change in the future._
|
||||
*
|
||||
* @return data object that should be used for uploading
|
||||
*/
|
||||
fun eventToUpload(): Event {
|
||||
val event = getCachedEvent()
|
||||
|
||||
val nonGroupScheduled = event.attendees.isEmpty()
|
||||
val weAreOrganizer = event.isOrganizer == true
|
||||
|
||||
// Increase sequence (event.sequence null/non-null behavior is defined by the Event, see KDoc of event.sequence):
|
||||
// - If it's null, the event has just been created in the database, so we can start with SEQUENCE:0 (default).
|
||||
// - If it's non-null, the event already exists on the server, so increase by one.
|
||||
val sequence = event.sequence
|
||||
if (sequence != null && (nonGroupScheduled || weAreOrganizer))
|
||||
event.sequence = sequence + 1
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the SEQUENCE of the event in the content provider.
|
||||
*
|
||||
* @param sequence new sequence value
|
||||
*/
|
||||
fun updateSequence(sequence: Int?) {
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_SEQUENCE to sequence
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates and sets a new UID in the calendar provider, if no UID is already set.
|
||||
* It also returns the desired file name for the event for further processing in the sync algorithm.
|
||||
*
|
||||
* @return file name to use at upload
|
||||
*/
|
||||
override fun prepareForUpload(): String {
|
||||
// make sure that UID is set
|
||||
val uid: String = getCachedEvent().uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// persist to calendar provider
|
||||
val values = contentValuesOf(Events.UID_2445 to newUid)
|
||||
androidEvent.update(values)
|
||||
|
||||
// update in cached event data object
|
||||
getCachedEvent().uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
val uidIsGoodFilename = uid.all { char ->
|
||||
// see RFC 2396 2.2
|
||||
char.isLetterOrDigit() || arrayOf( // allow letters and digits
|
||||
';', ':', '@', '&', '=', '+', '$', ',', // allow reserved characters except '/' and '?'
|
||||
'-', '_', '.', '!', '~', '*', '\'', '(', ')' // allow unreserved characters
|
||||
).contains(char)
|
||||
}
|
||||
return if (uidIsGoodFilename)
|
||||
"$uid.ics" // use UID as file name
|
||||
else
|
||||
"${UUID.randomUUID()}.ics" // UID would be dangerous as file name, use random UUID instead
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
val values = contentValuesOf(
|
||||
Events.DIRTY to 0,
|
||||
AndroidEvent2.COLUMN_ETAG to eTag,
|
||||
AndroidEvent2.COLUMN_SCHEDULE_TAG to scheduleTag
|
||||
)
|
||||
if (fileName.isPresent)
|
||||
values.put(Events._SYNC_ID, fileName.get())
|
||||
androidEvent.update(values)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
androidEvent.update(contentValuesOf(
|
||||
AndroidEvent2.COLUMN_FLAGS to flags
|
||||
))
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
recurringCalendar.deleteEventAndExceptions(id)
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
androidEvent.update(contentValuesOf(
|
||||
Events.DELETED to 0
|
||||
))
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
.add("event",
|
||||
try {
|
||||
Ascii.truncate(getCachedEvent().toString(), 1000, "…")
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context) =
|
||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id)
|
||||
|
||||
}
|
||||
313
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
313
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalGroup.kt
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.resource.LocalGroup.Companion.COLUMN_PENDING_MEMBERS
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import at.bitfire.vcard4android.AndroidAddressBook
|
||||
import at.bitfire.vcard4android.AndroidContact
|
||||
import at.bitfire.vcard4android.AndroidGroup
|
||||
import at.bitfire.vcard4android.AndroidGroupFactory
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.LinkedList
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.logging.Logger
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
private val logger: Logger
|
||||
get() = Logger.getGlobal()
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
/** List of member UIDs, as sent by server. This list will be used to establish
|
||||
* the group memberships when all groups and contacts have been synchronized.
|
||||
* Use [PendingMemberships] to create/read the list. */
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null [COLUMN_PENDING_MEMBERS]: the pending memberships
|
||||
* are applied (if possible) to keep cached memberships in sync.
|
||||
*
|
||||
* @param addressBook address book to take groups from
|
||||
*/
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
logger.info("Assigning memberships of contact groups")
|
||||
|
||||
addressBook.allGroups { group ->
|
||||
val groupId = group.id!!
|
||||
val pendingMemberUids = group.pendingMemberships.toMutableSet()
|
||||
val batch = ContactsBatchOperation(addressBook.provider!!)
|
||||
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
|
||||
// process members which are currently in this group, but shouldn't be
|
||||
for (currentMemberId in addressBook.getContactIdsByGroupMembership(groupId)) {
|
||||
val uid = addressBook.getContactUidFromId(currentMemberId) ?: continue
|
||||
|
||||
if (!pendingMemberUids.contains(uid)) {
|
||||
logger.fine("$currentMemberId removed from group $groupId; removing group membership")
|
||||
val currentMember = addressBook.findContactById(currentMemberId)
|
||||
currentMember.removeGroupMemberships(batch)
|
||||
|
||||
// Android 7 hack
|
||||
changeContactIDs += currentMemberId
|
||||
}
|
||||
|
||||
// UID is processed, remove from pendingMembers
|
||||
pendingMemberUids -= uid
|
||||
}
|
||||
// now pendingMemberUids contains all UIDs which are not assigned yet
|
||||
|
||||
// process members which should be in this group, but aren't
|
||||
for (missingMemberUid in pendingMemberUids) {
|
||||
val missingMember = addressBook.findContactByUid(missingMemberUid)
|
||||
if (missingMember == null) {
|
||||
logger.warning("Group $groupId has member $missingMemberUid which is not found in the address book; ignoring")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.fine("Assigning member $missingMember to group $groupId")
|
||||
missingMember.addToGroup(batch, groupId)
|
||||
|
||||
// Android 7 hack
|
||||
changeContactIDs += missingMember.id!!
|
||||
}
|
||||
|
||||
addressBook.dirtyVerifier.getOrNull()?.let { verifier ->
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { id -> addressBook.findContactById(id) }
|
||||
.forEach { contact ->
|
||||
verifier.updateHashCode(contact, batch)
|
||||
}
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
override var scheduleTag: String?
|
||||
get() = null
|
||||
set(_) = throw NotImplementedError()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
var pendingMemberships = setOf<String>()
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) : super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
values.getAsString(COLUMN_PENDING_MEMBERS)?.let { members ->
|
||||
pendingMemberships = PendingMemberships.fromString(members).uids
|
||||
}
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
values.put(COLUMN_PENDING_MEMBERS, PendingMemberships(getContact().members).toString())
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
var uid: String? = null
|
||||
addressBook.provider!!.query(groupSyncUri(), arrayOf(AndroidContact.COLUMN_UID), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0).trimToNull()
|
||||
}
|
||||
|
||||
if (uid == null) {
|
||||
// generate new UID
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val values = contentValuesOf(AndroidContact.COLUMN_UID to uid)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
_contact?.uid = uid
|
||||
}
|
||||
|
||||
return "$uid.vcf"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
throw IllegalArgumentException("Contact groups must not have a Schedule-Tag")
|
||||
val id = requireNotNull(id)
|
||||
|
||||
val values = ContentValues(3)
|
||||
if (fileName.isPresent)
|
||||
values.put(COLUMN_FILENAME, fileName.get())
|
||||
values.putNull(COLUMN_ETAG) // don't save changed ETag but null, so that the group is downloaded again, so that pendingMembers is updated
|
||||
values.put(Groups.DIRTY, 0)
|
||||
update(values)
|
||||
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = null
|
||||
|
||||
// update cached group memberships
|
||||
val batch = ContactsBatchOperation(addressBook.provider!!)
|
||||
|
||||
// delete old cached group memberships
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newDelete(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withSelection(
|
||||
CachedGroupMembership.MIMETYPE + "=? AND " + CachedGroupMembership.GROUP_ID + "=?",
|
||||
arrayOf(CachedGroupMembership.CONTENT_ITEM_TYPE, id.toString())
|
||||
)
|
||||
|
||||
// insert updated cached group memberships
|
||||
for (member in getMembers())
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newInsert(addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI))
|
||||
.withValue(CachedGroupMembership.MIMETYPE, CachedGroupMembership.CONTENT_ITEM_TYPE)
|
||||
.withValue(CachedGroupMembership.RAW_CONTACT_ID, member)
|
||||
.withValue(CachedGroupMembership.GROUP_ID, id)
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
fun markMembersDirty() {
|
||||
val batch = ContactsBatchOperation(addressBook.provider!!)
|
||||
|
||||
for (member in getMembers())
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(RawContacts.CONTENT_URI, member)))
|
||||
.withValue(RawContacts.DIRTY, 1)
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun update(data: Contact, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
|
||||
// processes this.{fileName, eTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = contentValuesOf(Groups.DELETED to 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("flags", flags)
|
||||
.add("contact",
|
||||
try {
|
||||
getContact().toString()
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun groupSyncUri(): Uri {
|
||||
val id = requireNotNull(id)
|
||||
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws RemoteException on contact provider errors
|
||||
*/
|
||||
internal fun getMembers(): List<Long> {
|
||||
val id = requireNotNull(id)
|
||||
val members = LinkedList<Long>()
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members += cursor.getLong(0)
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
|
||||
// helper class for COLUMN_PENDING_MEMBERSHIPS blob
|
||||
|
||||
class PendingMemberships(
|
||||
/** list of member UIDs that shall be assigned **/
|
||||
val uids: Set<String>
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val SEPARATOR = '\n'
|
||||
|
||||
fun fromString(value: String) =
|
||||
PendingMemberships(value.split(SEPARATOR).toSet())
|
||||
}
|
||||
|
||||
override fun toString() = uids.joinToString(SEPARATOR.toString())
|
||||
|
||||
}
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
|
||||
LocalGroup(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxCollectionFactory
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
|
||||
/**
|
||||
* Application-specific implementation for jtx collections.
|
||||
*
|
||||
* [at.techbee.jtx.JtxContract.JtxCollection.SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalJtxCollection(account: Account, client: ContentProviderClient, id: Long):
|
||||
JtxCollection<JtxICalObject>(account, client, LocalJtxICalObject.Factory, id),
|
||||
LocalCollection<LocalJtxICalObject>{
|
||||
|
||||
override val readOnly: Boolean
|
||||
get() = throw NotImplementedError()
|
||||
|
||||
override val tag: String
|
||||
get() = "jtx-${account.name}-$id"
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId
|
||||
|
||||
override val title: String
|
||||
get() = displayname ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = SyncState.fromString(syncstate)
|
||||
set(value) { syncstate = value.toString() }
|
||||
|
||||
|
||||
override fun findDeleted(): List<LocalJtxICalObject> {
|
||||
val values = queryDeletedICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
values.forEach {
|
||||
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
|
||||
}
|
||||
return localJtxICalObjects
|
||||
}
|
||||
|
||||
override fun findDirty(): List<LocalJtxICalObject> {
|
||||
val values = queryDirtyICalObjects()
|
||||
val localJtxICalObjects = mutableListOf<LocalJtxICalObject>()
|
||||
values.forEach {
|
||||
localJtxICalObjects.add(LocalJtxICalObject.Factory.fromProvider(this, it))
|
||||
}
|
||||
return localJtxICalObjects
|
||||
}
|
||||
|
||||
override fun findByName(name: String): LocalJtxICalObject? {
|
||||
val values = queryByFilename(name) ?: return null
|
||||
return LocalJtxICalObject.Factory.fromProvider(this, values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds and returns a recurrence instance of a [LocalJtxICalObject]
|
||||
* @param uid UID of the main VTODO
|
||||
* @param recurid RECURRENCE-ID of the recurrence instance
|
||||
* @return LocalJtxICalObject or null if none or multiple entries found
|
||||
*/
|
||||
fun findRecurInstance(uid: String, recurid: String): LocalJtxICalObject? {
|
||||
val values = queryRecur(uid, recurid) ?: return null
|
||||
return LocalJtxICalObject.Factory.fromProvider(this, values)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int)= updateSetFlags(flags)
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) = deleteByFlags(flags)
|
||||
|
||||
override fun forgetETags() = updateSetETag(null)
|
||||
|
||||
|
||||
object Factory: JtxCollectionFactory<LocalJtxCollection> {
|
||||
override fun newInstance(account: Account, client: ContentProviderClient, id: Long) = LocalJtxCollection(account, client, id)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.repository.PrincipalRepository
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.techbee.jtx.JtxContract
|
||||
import at.techbee.jtx.JtxContract.asSyncAdapter
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
|
||||
class LocalJtxCollectionStore @Inject constructor(
|
||||
@ApplicationContext val context: Context,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
db: AppDatabase,
|
||||
val principalRepository: PrincipalRepository
|
||||
): LocalDataStore<LocalJtxCollection> {
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
override val authority: String
|
||||
get() = JtxContract.AUTHORITY
|
||||
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalJtxCollection? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor =
|
||||
if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollection(
|
||||
info = collectionWithColor,
|
||||
account = account,
|
||||
withColor = true
|
||||
)
|
||||
|
||||
val uri = JtxCollection.create(account, provider, values)
|
||||
return LocalJtxCollection(account, provider, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun valuesFromCollection(info: Collection, account: Account, withColor: Boolean): ContentValues {
|
||||
val owner = info.ownerId?.let { principalRepository.getBlocking(it) }
|
||||
|
||||
return ContentValues().apply {
|
||||
put(JtxContract.JtxCollection.SYNC_ID, info.id)
|
||||
put(JtxContract.JtxCollection.URL, info.url.toString())
|
||||
put(
|
||||
JtxContract.JtxCollection.DISPLAYNAME,
|
||||
info.displayName ?: info.url.lastSegment
|
||||
)
|
||||
put(JtxContract.JtxCollection.DESCRIPTION, info.description)
|
||||
if (owner != null)
|
||||
put(JtxContract.JtxCollection.OWNER, owner.url.toString())
|
||||
else
|
||||
Logger.getGlobal().warning("No collection owner given. Will create jtx collection without owner")
|
||||
put(JtxContract.JtxCollection.OWNER_DISPLAYNAME, owner?.displayName)
|
||||
if (withColor && info.color != null)
|
||||
put(JtxContract.JtxCollection.COLOR, info.color)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVEVENT, info.supportsVEVENT)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVJOURNAL, info.supportsVJOURNAL)
|
||||
put(JtxContract.JtxCollection.SUPPORTSVTODO, info.supportsVTODO)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_NAME, account.name)
|
||||
put(JtxContract.JtxCollection.ACCOUNT_TYPE, account.type)
|
||||
put(JtxContract.JtxCollection.READONLY, info.forceReadOnly || !info.privWriteContent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient): List<LocalJtxCollection> =
|
||||
JtxCollection.find(account, provider, context, LocalJtxCollection.Factory, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalJtxCollection, fromCollection: Collection) {
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
val values = valuesFromCollection(fromCollection, account = localCollection.account, withColor = accountSettings.getManageCalendarColors())
|
||||
localCollection.update(values)
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.JtxBoard)?.use { provider ->
|
||||
val values = contentValuesOf(JtxContract.JtxCollection.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = JtxContract.JtxCollection.CONTENT_URI.asSyncAdapter(oldAccount)
|
||||
provider.client.update(uri, values, "${JtxContract.JtxCollection.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalJtxCollection) {
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import at.bitfire.ical4android.JtxCollection
|
||||
import at.bitfire.ical4android.JtxICalObject
|
||||
import at.bitfire.ical4android.JtxICalObjectFactory
|
||||
import at.techbee.jtx.JtxContract
|
||||
import com.google.common.base.MoreObjects
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
|
||||
class LocalJtxICalObject(
|
||||
collection: JtxCollection<*>,
|
||||
fileName: String?,
|
||||
eTag: String?,
|
||||
scheduleTag: String?,
|
||||
flags: Int
|
||||
) :
|
||||
JtxICalObject(collection),
|
||||
LocalResource<JtxICalObject> {
|
||||
|
||||
|
||||
init {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
this.scheduleTag = scheduleTag
|
||||
}
|
||||
|
||||
|
||||
object Factory : JtxICalObjectFactory<LocalJtxICalObject> {
|
||||
|
||||
override fun fromProvider(
|
||||
collection: JtxCollection<JtxICalObject>,
|
||||
values: ContentValues
|
||||
): LocalJtxICalObject {
|
||||
val fileName = values.getAsString(JtxContract.JtxICalObject.FILENAME)
|
||||
val eTag = values.getAsString(JtxContract.JtxICalObject.ETAG)
|
||||
val scheduleTag = values.getAsString(JtxContract.JtxICalObject.SCHEDULETAG)
|
||||
val flags = values.getAsInteger(JtxContract.JtxICalObject.FLAGS)?: 0
|
||||
|
||||
val localJtxICalObject = LocalJtxICalObject(collection, fileName, eTag, scheduleTag, flags)
|
||||
localJtxICalObject.populateFromContentValues(values)
|
||||
|
||||
return localJtxICalObject
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun update(data: JtxICalObject, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
clearDirty(fileName.getOrNull(), eTag, scheduleTag)
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
.toString()
|
||||
|
||||
override fun getViewUri(context: Context) = null
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import at.bitfire.davdroid.resource.LocalResource.Companion.FLAG_REMOTELY_PRESENT
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Defines operations that are used by SyncManager for all sync data types.
|
||||
*/
|
||||
interface LocalResource<in TData: Any> {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Resource is present on remote server. This flag is used to identify resources
|
||||
* which are not present on the remote server anymore and can be deleted at the end
|
||||
* of the synchronization.
|
||||
*/
|
||||
const val FLAG_REMOTELY_PRESENT = 1
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unique ID which identifies the resource in the local storage. May be null if the
|
||||
* resource has not been saved yet.
|
||||
*/
|
||||
val id: Long?
|
||||
|
||||
/**
|
||||
* Remote file name for the resource, for instance `mycontact.vcf`. Also used to determine whether
|
||||
* a dirty record has just been created (in this case, [fileName] is *null*) or modified
|
||||
* (in this case, [fileName] is the remote file name).
|
||||
*/
|
||||
val fileName: String?
|
||||
|
||||
/** remote ETag for the resource */
|
||||
val eTag: String?
|
||||
|
||||
/** remote Schedule-Tag for the resource */
|
||||
val scheduleTag: String?
|
||||
|
||||
/** bitfield of flags; currently either [FLAG_REMOTELY_PRESENT] or 0 */
|
||||
val flags: Int
|
||||
|
||||
/**
|
||||
* Prepares the resource for uploading:
|
||||
*
|
||||
* 1. If the resource doesn't have an UID yet, this method generates one and writes it to the content provider.
|
||||
* 2. The new file name which can be used for the upload is derived from the UID and returned, but not
|
||||
* saved to the content provider. The sync manager is responsible for saving the file name that
|
||||
* was actually used.
|
||||
*
|
||||
* @return suggestion for new file name of the resource (like "<uid>.vcf")
|
||||
*/
|
||||
fun prepareForUpload(): String
|
||||
|
||||
/**
|
||||
* Unsets the _dirty_ field of the resource and updates other sync-related fields in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* @param fileName If this optional argument is present, [LocalResource.fileName] will be set to its value.
|
||||
* @param eTag ETag of the uploaded resource as returned by the server (null if the server didn't return one)
|
||||
* @param scheduleTag CalDAV only: `Schedule-Tag` of the uploaded resource as returned by the server
|
||||
* (null if not applicable or if the server didn't return one)
|
||||
*/
|
||||
fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String? = null)
|
||||
|
||||
/**
|
||||
* Sets (local) flags of the resource in the content provider.
|
||||
* Does not affect `this` object itself (which is immutable).
|
||||
*
|
||||
* At the moment, the only allowed values are 0 and [FLAG_REMOTELY_PRESENT].
|
||||
*/
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
* Does not affect `this` or the [data] object (which are both immutable).
|
||||
*
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int)
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
*/
|
||||
fun deleteLocal()
|
||||
|
||||
/**
|
||||
* Undoes deletion of the data object from the content provider.
|
||||
*/
|
||||
fun resetDeleted()
|
||||
|
||||
/**
|
||||
* User-readable debug summary of this local resource (used in debug info)
|
||||
*/
|
||||
fun getDebugSummary(): String
|
||||
|
||||
/**
|
||||
* Returns the content provider URI that opens the local resource for viewing ([Intent.ACTION_VIEW])
|
||||
* in its respective app.
|
||||
*
|
||||
* For instance, in case of a local raw contact, this method could return the content provider URI
|
||||
* that identifies the corresponding contact.
|
||||
*
|
||||
* @return content provider URI, or `null` if not available
|
||||
*/
|
||||
fun getViewUri(context: Context): Uri?
|
||||
|
||||
}
|
||||
159
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
159
app/src/main/kotlin/at/bitfire/davdroid/resource/LocalTask.kt
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTask
|
||||
import at.bitfire.ical4android.DmfsTaskFactory
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import com.google.common.base.Ascii
|
||||
import com.google.common.base.MoreObjects
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class LocalTask: DmfsTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
|
||||
override var scheduleTag: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: DmfsTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: DmfsTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
override fun buildTask(builder: BatchOperation.CpoBuilder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
override fun prepareForUpload(): String {
|
||||
val uid: String = task!!.uid ?: run {
|
||||
// generate new UID
|
||||
val newUid = UUID.randomUUID().toString()
|
||||
|
||||
// update in tasks provider
|
||||
val values = contentValuesOf(Tasks._UID to newUid)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
// update this task
|
||||
task!!.uid = newUid
|
||||
|
||||
newUid
|
||||
}
|
||||
|
||||
return "$uid.ics"
|
||||
}
|
||||
|
||||
override fun clearDirty(fileName: Optional<String>, eTag: String?, scheduleTag: String?) {
|
||||
if (scheduleTag != null)
|
||||
logger.fine("Schedule-Tag for tasks not supported yet, won't save")
|
||||
|
||||
val values = ContentValues(4)
|
||||
if (fileName.isPresent)
|
||||
values.put(Tasks._SYNC_ID, fileName.get())
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
|
||||
if (fileName.isPresent)
|
||||
this.fileName = fileName.get()
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun update(data: Task, fileName: String?, eTag: String?, scheduleTag: String?, flags: Int) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.scheduleTag = scheduleTag
|
||||
this.flags = flags
|
||||
|
||||
// processes this.{fileName, eTag, scheduleTag, flags} and resets DIRTY flag
|
||||
update(data)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = contentValuesOf(COLUMN_FLAGS to flags)
|
||||
taskList.provider.update(taskSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
override fun deleteLocal() {
|
||||
delete()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
throw NotImplementedError()
|
||||
}
|
||||
|
||||
override fun getDebugSummary() =
|
||||
MoreObjects.toStringHelper(this)
|
||||
.add("id", id)
|
||||
.add("fileName", fileName)
|
||||
.add("eTag", eTag)
|
||||
.add("scheduleTag", scheduleTag)
|
||||
.add("flags", flags)
|
||||
.add("task",
|
||||
try {
|
||||
Ascii.truncate(task.toString(), 1000, "…")
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
).toString()
|
||||
|
||||
override fun getViewUri(context: Context): Uri? {
|
||||
val idNotNull = id ?: return null
|
||||
if (taskList.providerName == TaskProvider.ProviderName.OpenTasks) {
|
||||
val contentUri = Tasks.getContentUri(taskList.providerName.authority)
|
||||
return ContentUris.withAppendedId(contentUri, idNotNull)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskFactory<LocalTask> {
|
||||
override fun fromProvider(taskList: DmfsTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentValues
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.DmfsTaskListFactory
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
/**
|
||||
* App-specific implementation of a task list.
|
||||
*
|
||||
* [TaskLists._SYNC_ID] corresponds to the database collection ID ([at.bitfire.davdroid.db.Collection.id]).
|
||||
*/
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
): DmfsTaskList<LocalTask>(account, provider, providerName, LocalTask.Factory, id), LocalCollection<LocalTask> {
|
||||
|
||||
private val logger = Logger.getGlobal()
|
||||
|
||||
private var accessLevel: Int = TaskListColumns.ACCESS_LEVEL_UNDEFINED
|
||||
override val readOnly
|
||||
get() =
|
||||
accessLevel != TaskListColumns.ACCESS_LEVEL_UNDEFINED &&
|
||||
accessLevel <= TaskListColumns.ACCESS_LEVEL_READ
|
||||
|
||||
override val dbCollectionId: Long?
|
||||
get() = syncId?.toLongOrNull()
|
||||
|
||||
override val tag: String
|
||||
get() = "tasks-${account.name}-$id"
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = contentValuesOf(TaskLists.SYNC_VERSION to state?.toString())
|
||||
provider.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
override fun populate(values: ContentValues) {
|
||||
super.populate(values)
|
||||
accessLevel = values.getAsInteger(TaskListColumns.ACCESS_LEVEL)
|
||||
}
|
||||
|
||||
|
||||
override fun findDeleted() = queryTasks(Tasks._DELETED, null)
|
||||
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks(Tasks._DIRTY, null)
|
||||
for (localTask in tasks) {
|
||||
try {
|
||||
val task = requireNotNull(localTask.task)
|
||||
val sequence = task.sequence
|
||||
if (sequence == null) // sequence has not been assigned yet (i.e. this task was just locally created)
|
||||
task.sequence = 0
|
||||
else // task was modified, increase sequence
|
||||
task.sequence = sequence + 1
|
||||
} catch(e: Exception) {
|
||||
logger.log(Level.WARNING, "Couldn't check/increase sequence", e)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_FLAGS to flags)
|
||||
return provider.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND NOT ${Tasks._DIRTY} AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
override fun forgetETags() {
|
||||
val values = contentValuesOf(LocalTask.COLUMN_ETAG to null)
|
||||
provider.update(tasksSyncUri(), values, "${Tasks.LIST_ID}=?",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
|
||||
object Factory: DmfsTaskListFactory<LocalTaskList> {
|
||||
|
||||
override fun newInstance(
|
||||
account: Account,
|
||||
provider: ContentProviderClient,
|
||||
providerName: TaskProvider.ProviderName,
|
||||
id: Long
|
||||
) = LocalTaskList(account, provider, providerName, id)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.content.contentValuesOf
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.db.AppDatabase
|
||||
import at.bitfire.davdroid.db.Collection
|
||||
import at.bitfire.davdroid.settings.AccountSettings
|
||||
import at.bitfire.davdroid.util.DavUtils.lastSegment
|
||||
import at.bitfire.ical4android.DmfsTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskListColumns
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
|
||||
class LocalTaskListStore @AssistedInject constructor(
|
||||
@Assisted private val providerName: TaskProvider.ProviderName,
|
||||
val accountSettingsFactory: AccountSettings.Factory,
|
||||
@ApplicationContext val context: Context,
|
||||
val db: AppDatabase,
|
||||
val logger: Logger
|
||||
): LocalDataStore<LocalTaskList> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(providerName: TaskProvider.ProviderName): LocalTaskListStore
|
||||
}
|
||||
|
||||
private val serviceDao = db.serviceDao()
|
||||
|
||||
override val authority: String
|
||||
get() = providerName.authority
|
||||
|
||||
override fun acquireContentProvider(throwOnMissingPermissions: Boolean) = try {
|
||||
context.contentResolver.acquireContentProviderClient(authority)
|
||||
} catch (e: SecurityException) {
|
||||
if (throwOnMissingPermissions)
|
||||
throw e
|
||||
else
|
||||
/* return */ null
|
||||
}
|
||||
|
||||
override fun create(provider: ContentProviderClient, fromCollection: Collection): LocalTaskList? {
|
||||
val service = serviceDao.get(fromCollection.serviceId) ?: throw IllegalArgumentException("Couldn't fetch DB service from collection")
|
||||
val account = Account(service.accountName, context.getString(R.string.account_type))
|
||||
|
||||
logger.log(Level.INFO, "Adding local task list", fromCollection)
|
||||
val uri = create(account, provider, providerName, fromCollection)
|
||||
return DmfsTaskList.findByID(account, provider, providerName, LocalTaskList.Factory, ContentUris.parseId(uri))
|
||||
}
|
||||
|
||||
private fun create(account: Account, provider: ContentProviderClient, providerName: TaskProvider.ProviderName, fromCollection: Collection): Uri {
|
||||
// If the collection doesn't have a color, use a default color.
|
||||
val collectionWithColor = if (fromCollection.color != null)
|
||||
fromCollection
|
||||
else
|
||||
fromCollection.copy(color = Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
val values = valuesFromCollectionInfo(
|
||||
info = collectionWithColor,
|
||||
withColor = true
|
||||
).apply {
|
||||
put(TaskLists.OWNER, account.name)
|
||||
put(TaskLists.SYNC_ENABLED, 1)
|
||||
put(TaskLists.VISIBLE, 1)
|
||||
}
|
||||
return DmfsTaskList.Companion.create(account, provider, providerName, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: Collection, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.id.toString())
|
||||
values.put(TaskLists.LIST_NAME,
|
||||
if (info.displayName.isNullOrBlank()) info.url.lastSegment else info.displayName)
|
||||
|
||||
if (withColor && info.color != null)
|
||||
values.put(TaskLists.LIST_COLOR, info.color)
|
||||
|
||||
if (info.privWriteContent && !info.forceReadOnly)
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_OWNER)
|
||||
else
|
||||
values.put(TaskListColumns.ACCESS_LEVEL, TaskListColumns.ACCESS_LEVEL_READ)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
override fun getAll(account: Account, provider: ContentProviderClient) =
|
||||
DmfsTaskList.find(account, LocalTaskList.Factory, provider, providerName, null, null)
|
||||
|
||||
override fun update(provider: ContentProviderClient, localCollection: LocalTaskList, fromCollection: Collection) {
|
||||
logger.log(Level.FINE, "Updating local task list ${fromCollection.url}", fromCollection)
|
||||
val accountSettings = accountSettingsFactory.create(localCollection.account)
|
||||
localCollection.update(valuesFromCollectionInfo(fromCollection, withColor = accountSettings.getManageCalendarColors()))
|
||||
}
|
||||
|
||||
override fun updateAccount(oldAccount: Account, newAccount: Account) {
|
||||
TaskProvider.acquire(context, providerName)?.use { provider ->
|
||||
val values = contentValuesOf(Tasks.ACCOUNT_NAME to newAccount.name)
|
||||
val uri = Tasks.getContentUri(providerName.authority)
|
||||
provider.client.update(uri, values, "${Tasks.ACCOUNT_NAME}=?", arrayOf(oldAccount.name))
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(localCollection: LocalTaskList) {
|
||||
localCollection.delete()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.dav4jvm.property.webdav.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncState(
|
||||
val type: Type,
|
||||
val value: String,
|
||||
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_TYPE = "type"
|
||||
private const val KEY_VALUE = "value"
|
||||
private const val KEY_INITIAL_SYNC = "initialSync"
|
||||
|
||||
fun fromString(s: String?): SyncState? {
|
||||
if (s == null)
|
||||
return null
|
||||
|
||||
return try {
|
||||
val json = JSONObject(s)
|
||||
SyncState(
|
||||
Type.valueOf(json.getString(KEY_TYPE)),
|
||||
json.getString(KEY_VALUE),
|
||||
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fromSyncToken(token: SyncToken, initialSync: Boolean? = null) =
|
||||
SyncState(Type.SYNC_TOKEN, requireNotNull(token.token), initialSync)
|
||||
|
||||
}
|
||||
|
||||
enum class Type { CTAG, SYNC_TOKEN }
|
||||
|
||||
override fun toString(): String {
|
||||
val json = JSONObject()
|
||||
json.put(KEY_TYPE, type.name)
|
||||
json.put(KEY_VALUE, value)
|
||||
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.vcard4android.CachedGroupMembership
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.contactrow.DataRowHandler
|
||||
import java.util.logging.Logger
|
||||
|
||||
class CachedGroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
|
||||
|
||||
override fun forMimeType() = CachedGroupMembership.CONTENT_ITEM_TYPE
|
||||
|
||||
override fun handle(values: ContentValues, contact: Contact) {
|
||||
super.handle(values, contact)
|
||||
|
||||
if (localContact.addressBook.groupMethod == GroupMethod.GROUP_VCARDS)
|
||||
localContact.cachedGroupMemberships += values.getAsLong(CachedGroupMembership.GROUP_ID)
|
||||
else
|
||||
Logger.getGlobal().warning("Ignoring cached group membership for group method CATEGORIES")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.contactrow.DataRowBuilder
|
||||
import java.util.LinkedList
|
||||
|
||||
class GroupMembershipBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, val addressBook: LocalAddressBook, readOnly: Boolean)
|
||||
: DataRowBuilder(Factory.MIME_TYPE, dataRowUri, rawContactId, contact, readOnly) {
|
||||
|
||||
override fun build(): List<BatchOperation.CpoBuilder> {
|
||||
val result = LinkedList<BatchOperation.CpoBuilder>()
|
||||
|
||||
if (addressBook.groupMethod == GroupMethod.CATEGORIES)
|
||||
for (category in contact.categories)
|
||||
result += newDataRow().withValue(GroupMembership.GROUP_ROW_ID, addressBook.findOrCreateGroup(category))
|
||||
else {
|
||||
// GroupMethod.GROUP_VCARDS -> memberships are handled by LocalGroups (and not by the members = LocalContacts, which we are processing here)
|
||||
// TODO: CATEGORIES <-> unknown properties
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
class Factory(val addressBook: LocalAddressBook): DataRowBuilder.Factory<GroupMembershipBuilder> {
|
||||
companion object {
|
||||
const val MIME_TYPE = GroupMembership.CONTENT_ITEM_TYPE
|
||||
}
|
||||
override fun mimeType() = MIME_TYPE
|
||||
override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) =
|
||||
GroupMembershipBuilder(dataRowUri, rawContactId, contact, addressBook, readOnly)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.provider.ContactsContract.CommonDataKinds.GroupMembership
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.util.trimToNull
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import at.bitfire.vcard4android.contactrow.DataRowHandler
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class GroupMembershipHandler(val localContact: LocalContact): DataRowHandler() {
|
||||
|
||||
override fun forMimeType() = GroupMembership.CONTENT_ITEM_TYPE
|
||||
|
||||
override fun handle(values: ContentValues, contact: Contact) {
|
||||
super.handle(values, contact)
|
||||
|
||||
val groupId = values.getAsLong(GroupMembership.GROUP_ROW_ID)
|
||||
localContact.groupMemberships += groupId
|
||||
|
||||
if (localContact.addressBook.groupMethod == GroupMethod.CATEGORIES) {
|
||||
try {
|
||||
val group = localContact.addressBook.findGroupById(groupId)
|
||||
group.getContact().displayName.trimToNull()?.let { groupName ->
|
||||
logger.fine("Adding membership in group $groupName as category")
|
||||
contact.categories.add(groupName)
|
||||
}
|
||||
} catch (ignored: FileNotFoundException) {
|
||||
logger.warning("Contact is member in group $groupId which doesn't exist anymore")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
|
||||
object UnknownProperties {
|
||||
|
||||
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
|
||||
const val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.net.Uri
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.contactrow.DataRowBuilder
|
||||
import java.util.LinkedList
|
||||
|
||||
class UnknownPropertiesBuilder(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean)
|
||||
: DataRowBuilder(Factory.mimeType(), dataRowUri, rawContactId, contact, readOnly) {
|
||||
|
||||
override fun build(): List<BatchOperation.CpoBuilder> {
|
||||
val result = LinkedList<BatchOperation.CpoBuilder>()
|
||||
contact.unknownProperties?.let { unknownProperties ->
|
||||
result += newDataRow().withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
object Factory: DataRowBuilder.Factory<UnknownPropertiesBuilder> {
|
||||
override fun mimeType() = UnknownProperties.CONTENT_ITEM_TYPE
|
||||
override fun newInstance(dataRowUri: Uri, rawContactId: Long?, contact: Contact, readOnly: Boolean) =
|
||||
UnknownPropertiesBuilder(dataRowUri, rawContactId, contact, readOnly)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.contactrow
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.contactrow.DataRowHandler
|
||||
|
||||
object UnknownPropertiesHandler: DataRowHandler() {
|
||||
|
||||
override fun forMimeType() = UnknownProperties.CONTENT_ITEM_TYPE
|
||||
|
||||
override fun handle(values: ContentValues, contact: Contact) {
|
||||
super.handle(values, contact)
|
||||
|
||||
contact.unknownProperties = values.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.workaround
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalContact.Companion.COLUMN_HASHCODE
|
||||
import at.bitfire.synctools.storage.BatchOperation
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.util.Optional
|
||||
import java.util.logging.Level
|
||||
import java.util.logging.Logger
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Android 7.x introduced a new behavior in the Contacts provider: when metadata of a contact (like the "last contacted" time)
|
||||
* changes, the contact is marked as "dirty" (i.e. the [android.provider.ContactsContract.RawContacts.DIRTY] flag is set).
|
||||
* So, under Android 7.x, every time a user calls a contact or writes an SMS to a contact, the contact is marked as dirty.
|
||||
*
|
||||
* **This behavior is not present in Android ≤ 6.x nor in ≥ Android 8.x, where a contact is only marked as dirty
|
||||
* when its data actually change.**
|
||||
*
|
||||
* So, as a dirty workaround for Android 7.x, we need to calculate a hash code from the contact data and group memberships every
|
||||
* time we change the contact. When then a contact is marked as dirty, we compare the hash code of the current contact data with
|
||||
* the previous hash code. If the hash code has changed, the contact is "really dirty" and we need to upload it. Otherwise,
|
||||
* we reset the dirty flag to ignore the meta-data change.
|
||||
*
|
||||
* @constructor May only be called on Android 7.x, otherwise an [IllegalStateException] is thrown.
|
||||
*/
|
||||
class Android7DirtyVerifier @Inject constructor(
|
||||
val logger: Logger
|
||||
): ContactDirtyVerifier {
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("Android7DirtyVerifier must not be used on Android != 7.x")
|
||||
}
|
||||
|
||||
|
||||
// address-book level functions
|
||||
|
||||
override fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean {
|
||||
val reallyDirty = verifyDirtyContacts(addressBook)
|
||||
|
||||
val deleted = addressBook.findDeleted().size
|
||||
if (isUpload && reallyDirty == 0 && deleted == 0) {
|
||||
logger.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries all contacts with the [android.provider.ContactsContract.RawContacts.DIRTY] flag and checks whether their data
|
||||
* checksum has changed, i.e. if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
*
|
||||
* The dirty flag is removed from contacts which are not "really dirty", i.e. from contacts whose contact data
|
||||
* checksum has not changed.
|
||||
*
|
||||
* @return number of "really dirty" contacts
|
||||
*/
|
||||
private fun verifyDirtyContacts(addressBook: LocalAddressBook): Int {
|
||||
var reallyDirty = 0
|
||||
for (contact in addressBook.findDirtyContacts()) {
|
||||
val lastHash = getLastHashCode(addressBook, contact)
|
||||
val currentHash = contactDataHashCode(contact)
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
logger.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
logger.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (addressBook.includeGroups)
|
||||
reallyDirty += addressBook.findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
private fun getLastHashCode(addressBook: LocalAddressBook, contact: LocalContact): Int {
|
||||
addressBook.provider!!.query(contact.rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
// contact level functions
|
||||
|
||||
/**
|
||||
* Calculates a hash code from the [at.bitfire.vcard4android.Contact] data and group memberships.
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory!
|
||||
*
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
private fun contactDataHashCode(contact: LocalContact): Int {
|
||||
contact.clearCachedContact()
|
||||
|
||||
// groupMemberships is filled by getContact()
|
||||
val dataHash = contact.getContact().hashCode()
|
||||
val groupHash = contact.groupMemberships.hashCode()
|
||||
val combinedHash = dataHash xor groupHash
|
||||
logger.log(Level.FINE, "Calculated data hash = $dataHash, group memberships hash = $groupHash → combined hash = $combinedHash", contact)
|
||||
return combinedHash
|
||||
}
|
||||
|
||||
override fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues) {
|
||||
val hashCode = contactDataHashCode(contact)
|
||||
toValues.put(COLUMN_HASHCODE, hashCode)
|
||||
}
|
||||
|
||||
override fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact) {
|
||||
val values = ContentValues(1)
|
||||
setHashCodeColumn(contact, values)
|
||||
|
||||
addressBook.provider!!.update(contact.rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation) {
|
||||
val hashCode = contactDataHashCode(contact)
|
||||
|
||||
batch += BatchOperation.CpoBuilder
|
||||
.newUpdate(contact.rawContactSyncURI())
|
||||
.withValue(COLUMN_HASHCODE, hashCode)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// factory
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object Android7DirtyVerifierModule {
|
||||
|
||||
/**
|
||||
* Provides an [Android7DirtyVerifier] on Android 7.x, or an empty [Optional] on other versions.
|
||||
*/
|
||||
@Provides
|
||||
fun provide(android7DirtyVerifier: Provider<Android7DirtyVerifier>): Optional<ContactDirtyVerifier> =
|
||||
if (/* Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && */ Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
Optional.of(android7DirtyVerifier.get())
|
||||
else
|
||||
Optional.empty()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource.workaround
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.synctools.storage.ContactsBatchOperation
|
||||
|
||||
/**
|
||||
* Only required for [Android7DirtyVerifier]. If that class is removed because the minimum SDK is raised to Android 8,
|
||||
* this interface and all calls to it can be removed as well.
|
||||
*/
|
||||
interface ContactDirtyVerifier {
|
||||
|
||||
// address-book level functions
|
||||
|
||||
/**
|
||||
* Checks whether contacts which are marked as "dirty" are really dirty, i.e. their data has changed.
|
||||
* If contacts are not really dirty (because only the metadata like "last contacted" changed), the "dirty" flag is removed.
|
||||
*
|
||||
* Intended to be called by [at.bitfire.davdroid.sync.ContactsSyncManager.prepare].
|
||||
*
|
||||
* @param addressBook the address book
|
||||
* @param isUpload whether this sync is an upload
|
||||
*
|
||||
* @return `true` if the address book should be synced, `false` if the sync is an upload and no contacts have been changed
|
||||
*/
|
||||
fun prepareAddressBook(addressBook: LocalAddressBook, isUpload: Boolean): Boolean
|
||||
|
||||
|
||||
// contact level functions
|
||||
|
||||
/**
|
||||
* Sets the [LocalContact.COLUMN_HASHCODE] column in the given [ContentValues] to the hash code of the contact data.
|
||||
*
|
||||
* @param contact the contact to calculate the hash code for
|
||||
* @param toValues set the hash code into these values
|
||||
*/
|
||||
fun setHashCodeColumn(contact: LocalContact, toValues: ContentValues)
|
||||
|
||||
/**
|
||||
* Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data directly in the content provider.
|
||||
*/
|
||||
fun updateHashCode(addressBook: LocalAddressBook, contact: LocalContact)
|
||||
|
||||
/**
|
||||
Sets the [LocalContact.COLUMN_HASHCODE] field of the contact to the hash code of the contact data in a content provider batch operation.
|
||||
*/
|
||||
fun updateHashCode(contact: LocalContact, batch: ContactsBatchOperation)
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue