LocalAddressBook.kt 13.7 KB
Newer Older
1
/*
Ricki Hirner's avatar
Ricki Hirner committed
2
 * Copyright © Ricki Hirner (bitfire web engineering).
3 4 5 6 7 8 9 10 11
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 */
package at.bitfire.davdroid.resource

import android.accounts.Account
import android.accounts.AccountManager
12
import android.annotation.TargetApi
13 14 15 16 17 18 19 20 21 22
import android.content.*
import android.os.Build
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 android.util.Base64
import at.bitfire.davdroid.DavUtils
23
import at.bitfire.davdroid.R
24
import at.bitfire.davdroid.log.Logger
25
import at.bitfire.davdroid.model.CollectionInfo
26
import at.bitfire.davdroid.model.SyncState
27 28 29 30 31 32
import at.bitfire.vcard4android.*
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level

33 34 35 36 37 38
/**
 * A local address book. Requires an own Android account, because Android manages contacts per
 * account and there is no such thing as "address books". So, DAVdroid creates a "DAVdroid
 * address book" account for every CardDAV address book. These accounts are bound to a
 * DAVdroid main account.
 */
39 40 41 42
class LocalAddressBook(
        private val context: Context,
        account: Account,
        provider: ContentProviderClient?
43
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
44 45 46

    companion object {

47 48 49 50
        const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
        const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
        const val USER_DATA_URL = "url"
        const val USER_DATA_READ_ONLY = "read_only"
51 52 53 54

        fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
            val accountManager = AccountManager.get(context)

55
            val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
56
            if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
57
                throw IllegalStateException("Couldn't create address book account")
58 59 60

            val addressBook = LocalAddressBook(context, account, provider)
            ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
61

62
            // initialize Contacts Provider Settings
63 64 65
            val values = ContentValues(2)
            values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
            values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
66
            addressBook.settings = values
67

68 69 70
            return addressBook
        }

71 72 73 74 75
        fun findAll(context: Context, provider: ContentProviderClient, mainAccount: Account?) = AccountManager.get(context)
                .getAccountsByType(context.getString(R.string.account_type_address_book))
                .map { LocalAddressBook(context, it, provider) }
                .filter { mainAccount == null || it.mainAccount == mainAccount }
                .toList()
76 77 78 79 80 81 82

        fun accountName(mainAccount: Account, info: CollectionInfo): String {
            val baos = ByteArrayOutputStream()
            baos.write(info.url.hashCode())
            val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)

            val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
83
            sb.append(" (${mainAccount.name} $hash)")
84 85 86 87 88 89 90 91 92 93 94 95 96
            return sb.toString()
        }

        fun initialUserData(mainAccount: Account, url: String): Bundle {
            val bundle = Bundle(3)
            bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
            bundle.putString(USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
            bundle.putString(USER_DATA_URL, url)
            return bundle
        }

    }

97 98 99
    override val title = account.name
    override val uniqueId = "contacts-${account.name}"

100
    /**
101 102 103 104 105
     * Whether contact groups ([LocalGroup]) are included in query results
     * and are affected by updates/deletes on generic members.
     *
     * For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
     * but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
106 107 108
     */
    var includeGroups = true

109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
    private var _mainAccount: Account? = null
    var mainAccount: Account
        get() {
            _mainAccount?.let { return it }

            val accountManager = AccountManager.get(context)
            val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
            val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
            if (name != null && type != null)
                return Account(name, type)
            else
                throw IllegalStateException("Address book doesn't exist anymore")
        }
        set(account) {
            val accountManager = AccountManager.get(context)
            accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, account.name)
            accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, account.type)

            _mainAccount = account
        }

    var url: String
        get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
                ?: throw IllegalStateException("Address book has no URL")
        set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)

    var readOnly: Boolean
        get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
        set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)

    override var lastSyncState: SyncState?
        get() = syncState?.let { SyncState.fromString(String(it)) }
        set(state) {
            syncState = state?.toString()?.toByteArray()
        }

145 146 147

    /* operations on the collection (address book) itself */

148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
    override fun markNotDirty(flags: Int): Int {
        val values = ContentValues(1)
        values.put(LocalContact.COLUMN_FLAGS, 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, "${Groups.DIRTY}=0", null)
        }

        return number
    }

    override fun removeNotDirtyMarked(flags: Int): Int {
        var number = provider!!.delete(rawContactsSyncUri(),
                "${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))

        if (includeGroups)
            number += provider.delete(groupsSyncUri(),
                    "${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))

        return number
    }

173
    fun update(info: CollectionInfo) {
174
        val newAccountName = accountName(mainAccount, info)
175 176

        @TargetApi(Build.VERSION_CODES.LOLLIPOP)
177 178 179 180 181 182 183 184 185 186
        if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
            val accountManager = AccountManager.get(context)
            val future = accountManager.renameAccount(account, newAccountName, {
                try {
                    // update raw contacts to new account name
                    provider?.let { provider ->
                        val values = ContentValues(1)
                        values.put(RawContacts.ACCOUNT_NAME, newAccountName)
                        provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
                    }
187
                } catch (e: RemoteException) {
Ricki Hirner's avatar
Ricki Hirner committed
188
                    Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
189 190 191 192 193
                }
            }, null)
            account = future.result
        }

194
        Constants.log.info("Address book read-only? = ${info.readOnly}")
195
        readOnly = info.readOnly || info.forceReadOnly
196

197 198 199 200 201 202
        // make sure it will still be synchronized when contacts are updated
        ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
    }

    fun delete() {
        val accountManager = AccountManager.get(context)
203 204 205 206
        if (Build.VERSION.SDK_INT >= 22)
            accountManager.removeAccount(account, null, null, null)
        else
            accountManager.removeAccount(account, null, null)
207 208 209 210 211
    }


    /* operations on members (contacts/groups) */

212 213 214 215 216 217
    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
218 219 220 221 222
    }


    /**
     * Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
223
     * @throws RemoteException on content provider errors
224
     */
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
    override fun findDeleted() =
            if (includeGroups)
                findDeletedContacts() + findDeletedGroups()
            else
                findDeletedContacts()

    fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
    fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", 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}!=0", null)
    fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)

    private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array<String>?, whereGroups: String?, whereArgsGroups: Array<String>?): List<LocalAddress> {
        val contacts = queryContacts(whereContacts, whereArgsContacts)
        return if (includeGroups)
            contacts + queryGroups(whereGroups, whereArgsGroups)
        else
            contacts
253 254
    }

255

256 257 258 259 260 261
    /**
     * Queries all contacts with 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
262
     * @throws RemoteException on content provider errors
263 264 265 266 267 268
     */
    fun verifyDirty(): Int {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
            throw IllegalStateException("verifyDirty() should not be called on Android != 7")

        var reallyDirty = 0
269 270 271 272 273 274 275 276 277 278
        for (contact in findDirtyContacts()) {
            val lastHash = contact.getLastHashCode()
            val currentHash = contact.dataHashCode()
            if (lastHash == currentHash) {
                // hash is code still the same, contact is not "really dirty" (only metadata been have changed)
                Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
                contact.resetDirty()
            } else {
                Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
                reallyDirty++
279
            }
280
        }
281 282

        if (includeGroups)
283
            reallyDirty += findDirtyGroups().size
284 285 286 287 288

        return reallyDirty
    }

    fun getByGroupMembership(groupID: Long): List<LocalContact> {
289 290 291 292 293 294 295 296
        val ids = HashSet<Long>()
        provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
                arrayOf(RawContacts.Data.RAW_CONTACT_ID),
                "(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
                arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
                null)?.use { cursor ->
            while (cursor.moveToNext())
                ids += cursor.getLong(0)
297
        }
298 299

        return ids.map { findContactByID(it) }
300 301 302 303 304 305 306 307
    }


    /* special group operations */

    /**
     * Finds the first group with the given title. If there is no group with this
     * title, a new group is created.
308 309 310
     * @param title title of the group to look for
     * @return id of the group with given title
     * @throws RemoteException on content provider errors
311 312
     */
    fun findOrCreateGroup(title: String): Long {
313 314 315 316
        provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
                "${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
            if (cursor.moveToNext())
                return cursor.getLong(0)
317
        }
318 319 320 321 322

        val values = ContentValues(1)
        values.put(Groups.TITLE, title)
        val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
        return ContentUris.parseId(uri)
323 324 325 326 327 328
    }

    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 ->
Ricki Hirner's avatar
Ricki Hirner committed
329
            Logger.log.log(Level.FINE, "Deleting group", group)
330 331 332 333
            group.delete()
        }
    }

334
}