SyncManager.kt 34.5 KB
Newer Older
1
/*
Ricki Hirner's avatar
Ricki Hirner committed
2
 * Copyright © Ricki Hirner (bitfire web engineering).
3 4 5 6 7
 * 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
 */
8

9
package foundation.e.accountmanager.syncadapter
10 11

import android.accounts.Account
12
import android.app.PendingIntent
13
import android.content.*
14
import android.net.Uri
15
import android.os.Bundle
16 17 18 19
import android.os.RemoteException
import android.provider.CalendarContract
import android.provider.ContactsContract
import android.support.v4.app.NotificationCompat
Ricki Hirner's avatar
Ricki Hirner committed
20
import android.support.v4.app.NotificationManagerCompat
21 22 23 24 25
import foundation.e.dav4android.*
import foundation.e.dav4android.exception.*
import foundation.e.dav4android.property.GetCTag
import foundation.e.dav4android.property.GetETag
import foundation.e.dav4android.property.SyncToken
26 27 28 29 30 31 32 33 34 35 36
import foundation.e.accountmanager.*
import foundation.e.accountmanager.BuildConfig
import foundation.e.accountmanager.Constants
import foundation.e.accountmanager.R
import foundation.e.accountmanager.log.Logger
import foundation.e.accountmanager.model.SyncState
import foundation.e.accountmanager.resource.*
import foundation.e.accountmanager.settings.ISettings
import foundation.e.accountmanager.ui.AccountSettingsActivity
import foundation.e.accountmanager.ui.DebugInfoActivity
import foundation.e.accountmanager.ui.NotificationUtils
37 38 39
import foundation.e.ical4android.CalendarStorageException
import foundation.e.ical4android.TaskProvider
import foundation.e.vcard4android.ContactsStorageException
40
import okhttp3.HttpUrl
41
import okhttp3.RequestBody
42
import org.apache.commons.lang3.exception.ContextedException
43 44 45
import org.dmfs.tasks.contract.TaskContract
import java.io.IOException
import java.io.InterruptedIOException
46
import java.net.HttpURLConnection
47
import java.security.cert.CertificateException
48
import java.util.*
49 50
import java.util.concurrent.*
import java.util.concurrent.atomic.AtomicInteger
51
import java.util.logging.Level
52
import javax.net.ssl.SSLHandshakeException
53

54 55 56 57
/**
 * Authors: Nihar Thakkar and others
 */

58 59
@Suppress("MemberVisibilityCanBePrivate")
abstract class SyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
60
        val context: Context,
61
        val settings: ISettings,
62
        val account: Account,
63
        val accountSettings: AccountSettings,
64 65 66
        val extras: Bundle,
        val authority: String,
        val syncResult: SyncResult,
67
        val localCollection: CollectionType
68
): AutoCloseable {
69

70 71 72 73 74
    enum class SyncAlgorithm {
        PROPFIND_REPORT,
        COLLECTION_SYNC
    }

75 76
    companion object {

77 78 79 80
        val MAX_PROCESSING_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4)
        val MAX_DOWNLOAD_THREADS = Math.max(Runtime.getRuntime().availableProcessors(), 4)
        const val MAX_MULTIGET_RESOURCES = 10

81 82 83 84 85 86 87 88 89 90 91 92 93
        fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) =
                manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR)

        private fun notificationTag(authority: String, account: Account) =
                "$authority-${account.name}".hashCode().toString()

    }

    private val mainAccount = if (localCollection is LocalAddressBook)
        localCollection.mainAccount
    else
        account

Ricki Hirner's avatar
Ricki Hirner committed
94
    protected val notificationManager = NotificationManagerCompat.from(context)
Ricki Hirner's avatar
Ricki Hirner committed
95
    protected val notificationTag = notificationTag(authority, mainAccount)
96

97 98 99 100 101 102 103 104 105 106
    protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()

    protected lateinit var collectionURL: HttpUrl
    protected lateinit var davCollection: RemoteType

    protected var hasCollectionSync = false

    override fun close() {
        httpClient.close()
    }
107 108 109 110


    fun performSync() {
        // dismiss previous error notifications
111
        notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
112

113
        unwrapExceptions({
Ricki Hirner's avatar
Ricki Hirner committed
114
            Logger.log.info("Preparing synchronization")
115
            if (!prepare()) {
Ricki Hirner's avatar
Ricki Hirner committed
116
                Logger.log.info("No reason to synchronize, aborting")
117
                return@unwrapExceptions
118
            }
119
            abortIfCancelled()
120

121
            Logger.log.info("Querying server capabilities")
122
            var remoteSyncState = queryCapabilities()
123
            abortIfCancelled()
124

125
            Logger.log.info("Sending local deletes/updates to server")
126
            val modificationsSent = processLocallyDeleted() ||
127
                    uploadDirty()
128
            abortIfCancelled()
129

130
            if (modificationsSent || syncRequired(remoteSyncState))
131 132 133 134
                when (syncAlgorithm()) {
                    SyncAlgorithm.PROPFIND_REPORT -> {
                        Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
                        resetPresentRemotely()
135

136
                        // get current sync state
137 138
                        if (modificationsSent)
                            remoteSyncState = querySyncState()
139

140 141 142 143 144
                        // list and process all entries at current sync state (which may be the same as or newer than remoteSyncState)
                        Logger.log.info("Processing remote entries")
                        syncRemote { callback ->
                            listAllRemote(callback)
                        }
145

146
                        Logger.log.info("Deleting entries which are not present remotely anymore")
147
                        syncResult.stats.numDeletes += deleteNotPresentRemotely()
148

149 150
                        Logger.log.info("Post-processing")
                        postProcess()
151

152 153
                        Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
                        localCollection.lastSyncState = remoteSyncState
154 155
                    }
                    SyncAlgorithm.COLLECTION_SYNC -> {
156
                        var initialSync = false
157

158 159
                        var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
                        if (syncState == null) {
160 161
                            Logger.log.info("Starting initial sync")
                            initialSync = true
162
                            resetPresentRemotely()
163
                        } else if (syncState.initialSync == true) {
164 165 166
                            Logger.log.info("Continuing initial sync")
                            initialSync = true
                        }
Ricki Hirner's avatar
Ricki Hirner committed
167

168
                        var furtherChanges = false
169
                        do {
170
                            Logger.log.info("Listing changes since $syncState")
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190
                            syncRemote { callback ->
                                try {
                                    val result = listRemoteChanges(syncState, callback)
                                    syncState = SyncState.fromSyncToken(result.first, initialSync)
                                    furtherChanges = result.second
                                } catch(e: HttpException) {
                                    if (e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token") }) {
                                        Logger.log.info("Sync token invalid, performing initial sync")
                                        initialSync = true
                                        resetPresentRemotely()

                                        val result = listRemoteChanges(null, callback)
                                        syncState = SyncState.fromSyncToken(result.first, initialSync)
                                        furtherChanges = result.second
                                    } else
                                        throw e
                                }

                                Logger.log.log(Level.INFO, "Saving sync state", syncState)
                                localCollection.lastSyncState = syncState
191
                            }
192 193 194

                            Logger.log.info("Server has further changes: $furtherChanges")
                        } while(furtherChanges)
195

196
                        if (initialSync) {
197
                            // initial sync is finished, remove all local resources which have not been listed by server
198
                            Logger.log.info("Deleting local resources which are not on server (anymore)")
199
                            deleteNotPresentRemotely()
200

201
                            // remove initial sync flag
202 203 204
                            syncState!!.initialSync = false
                            Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
                            localCollection.lastSyncState = syncState
205 206 207 208
                        }

                        Logger.log.info("Post-processing")
                        postProcess()
209
                    }
210
                }
211 212
            else
                Logger.log.info("Remote collection didn't change, no reason to sync")
213

214 215 216 217 218 219
        }, { e, local, remote ->
            when (e) {
                // sync was cancelled: re-throw to SyncAdapterService
                is InterruptedException,
                is InterruptedIOException ->
                    throw e
220

221 222 223
                // specific I/O errors
                is SSLHandshakeException -> {
                    Logger.log.log(Level.WARNING, "SSL handshake failed", e)
224

225 226 227 228
                    // when a certificate is rejected by cert4android, the cause will be a CertificateException
                    if (!BuildConfig.customCerts || e.cause !is CertificateException)
                        notifyException(e, local, remote)
                }
229

230 231 232 233 234 235 236 237
                // specific HTTP errors
                is ServiceUnavailableException -> {
                    Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
                    e.retryAfter?.let { retryAfter ->
                        // how many seconds to wait? getTime() returns ms, so divide by 1000
                        syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
                    }
                }
238

239 240 241 242 243
                // all others
                else ->
                    notifyException(e, local, remote)
            }
        })
244 245
    }

246

247
    protected abstract fun prepare(): Boolean
248

249 250 251
    /**
     * Queries the server for synchronization capabilities like specific report types,
     * data formats etc.
252 253 254 255 256 257 258 259
     *
     * Should also query and save the initial sync state (e.g. CTag/sync-token).
     *
     * @return current sync state
     */
    protected abstract fun queryCapabilities(): SyncState?

    /**
260
     * Processes locally deleted entries and forwards them to the server (HTTP `DELETE`).
261
     *
262
     * @return whether resources have been deleted from the server
263
     */
264 265
    protected open fun processLocallyDeleted(): Boolean {
        var numDeleted = 0
266

267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293
        // Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
        // but only if they don't have changed on the server. Then finally remove them from the local address book.
        val localList = localCollection.findDeleted()
        for (local in localList) {
            abortIfCancelled()
            useLocal(local) {
                val fileName = local.fileName
                if (fileName != null) {
                    Logger.log.info("$fileName has been deleted locally -> deleting from server (ETag ${local.eTag})")

                    useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())) { remote ->
                        try {
                            remote.delete(local.eTag) {}
                            numDeleted++
                        } catch (e: HttpException) {
                            Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
                        }
                    }
                } else
                    Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
                local.delete()
                syncResult.stats.numDeletes++
            }
        }
        Logger.log.info("Removed $numDeleted record(s) from server")
        return numDeleted > 0
    }
294 295

    /**
296
     * Uploads locally modified resources to the server (HTTP `PUT`).
297 298
     *
     * @return whether resources have been uploaded
299
     */
300 301 302 303 304 305 306 307 308 309 310 311 312 313
    protected open fun uploadDirty(): Boolean {
        var numUploaded = 0

        // upload dirty contacts
        for (local in localCollection.findDirty())
            useLocal(local) {
                abortIfCancelled()

                if (local.fileName == null) {
                    Logger.log.fine("Generating file name/UID for local record #${local.id}")
                    local.assignNameAndUID()
                }

                val fileName = local.fileName!!
314
                useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build(), accountSettings.credentials().authState?.accessToken)) { remote ->
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350
                    // generate entity to upload (VCard, iCal, whatever)
                    val body = prepareUpload(local)

                    var eTag: String? = null
                    val processETag: (response: okhttp3.Response) -> Unit = {
                        it.header("ETag")?.let {
                            eTag = GetETag(it).eTag
                        }
                    }
                    try {
                        if (local.eTag == null) {
                            Logger.log.info("Uploading new record $fileName")
                            remote.put(body, null, true, processETag)
                        } else {
                            Logger.log.info("Uploading locally modified record $fileName")
                            remote.put(body, local.eTag, false, processETag)
                        }
                        numUploaded++
                    } catch(e: ConflictException) {
                        // we can't interact with the user to resolve the conflict, so we treat 409 like 412
                        Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
                    } catch(e: PreconditionFailedException) {
                        Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
                    }

                    if (eTag != null)
                        Logger.log.fine("Received new ETag=$eTag after uploading")
                    else
                        Logger.log.fine("Didn't receive new ETag after uploading, setting to null")

                    local.clearDirty(eTag)
                }
            }
        Logger.log.info("Sent $numUploaded record(s) to server")
        return numUploaded > 0
    }
351

352
    protected abstract fun prepareUpload(resource: ResourceType): RequestBody
353

354 355
    /**
     * Determines whether a sync is required because there were changes on the server.
356
     * For instance, this method can compare the collection's `CTag`/`sync-token` with
357
     * the last known local value.
358 359 360
     *
     * When local changes have been uploaded ([processLocallyDeleted] and/or
     * [uploadDirty] were true), a sync is always required and this method
361
     * should *not* be evaluated.
362 363
     *
     * @param state remote sync state to compare local sync state with
364
     *
365 366
     * @return whether data has been changed on the server, i.e. whether running the
     * sync algorithm is required
367
     */
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388
    protected open fun syncRequired(state: SyncState?): Boolean {
        if (syncAlgorithm() == SyncAlgorithm.PROPFIND_REPORT && extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
            Logger.log.info("Manual sync in PROPFIND/REPORT mode, forcing sync")
            return true
        }

        val localState = localCollection.lastSyncState
        Logger.log.info("Local sync state = $localState, remote sync state = $state")
        return when {
            state?.type == SyncState.Type.SYNC_TOKEN -> {
                val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
                lastKnownToken != state.value
            }
            state?.type == SyncState.Type.CTAG -> {
                val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
                lastKnownCTag != state.value
            }
            else ->
                true
        }
    }
389 390 391 392 393 394 395 396

    /**
     * Determines which sync algorithm to use.
     * @return
     *   - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
     *   PROPFIND or specific REPORT requests), then compare and synchronize
     *   - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
     */
397
    protected abstract fun syncAlgorithm(): SyncAlgorithm
398

399
    /**
400 401 402 403 404
     * Marks all local resources which shall be taken into consideration for this
     * sync as "synchronizing". Purpose of marking is that resources which have been marked
     * and are not present remotely anymore can be deleted.
     *
     * Used together with [deleteNotPresentRemotely].
405
     */
406 407 408 409
    protected open fun resetPresentRemotely() {
        val number = localCollection.markNotDirty(0)
        Logger.log.info("Number of local non-dirty entries: $number")
    }
410

411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444
    protected open fun syncRemote(listRemote: (DavResponseCallback) -> Unit) {
        // results must be processed in main thread because exceptions must be thrown in main
        // thread, so that they can be catched by SyncManager
        val results = ConcurrentLinkedQueue<Future<*>>()

        // thread-safe sync stats
        val nInserted = AtomicInteger()
        val nUpdated = AtomicInteger()
        val nDeleted = AtomicInteger()
        val nSkipped = AtomicInteger()

        // download queue
        val toDownload = LinkedBlockingQueue<HttpUrl>()

        // tasks from this executor create the download tasks (if necessary)
        val processor = ThreadPoolExecutor(1, MAX_PROCESSING_THREADS,
                10, TimeUnit.SECONDS,
                LinkedBlockingQueue(MAX_PROCESSING_THREADS),  // accept up to MAX_PROCESSING_THREADS processing tasks
                ThreadPoolExecutor.CallerRunsPolicy()         // if the queue is full, run task in submitting thread
        )

        // this executor runs the actual download tasks
        val downloader = ThreadPoolExecutor(0, MAX_DOWNLOAD_THREADS,
                10, TimeUnit.SECONDS,
                LinkedBlockingQueue(MAX_DOWNLOAD_THREADS),  // accept up to MAX_DOWNLOAD_THREADS download tasks
                ThreadPoolExecutor.CallerRunsPolicy()       // if the queue is full, run task in submitting thread
        )
        fun downloadBunch() {
            val bunch = LinkedList<HttpUrl>()
            toDownload.drainTo(bunch, MAX_MULTIGET_RESOURCES)
            results += downloader.submit {
                downloadRemote(bunch)
            }
        }
445

446 447 448 449 450 451
        listRemote { response, relation ->
            // ignore non-members
            if (relation != Response.HrefRelation.MEMBER)
                return@listRemote

            // ignore collections
452
            if (response[foundation.e.dav4android.property.ResourceType::class.java]?.types?.contains(foundation.e.dav4android.property.ResourceType.COLLECTION) == true)
453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476
                return@listRemote

            val name = response.hrefName()

            if (response.isSuccess()) {
                Logger.log.fine("Found remote resource: $name")

                results += processor.submit {
                    useLocal(localCollection.findByName(name)) { local ->
                        if (local == null) {
                            Logger.log.info("$name has been added remotely")
                            toDownload += response.href
                            nInserted.incrementAndGet()
                        } else {
                            val localETag = local.eTag
                            val remoteETag = response[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
                            if (localETag == remoteETag) {
                                Logger.log.info("$name has not been changed on server (ETag still $remoteETag)")
                                nSkipped.incrementAndGet()
                            } else {
                                Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
                                toDownload += response.href
                                nUpdated.incrementAndGet()
                            }
477

478 479 480 481
                            // mark as remotely present, so that this resource won't be deleted at the end
                            local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
                        }
                    }
482

483 484 485 486 487 488
                    synchronized(processor) {
                        if (toDownload.size >= MAX_MULTIGET_RESOURCES)
                            // download another bunch of MAX_MULTIGET_RESOURCES resources
                            downloadBunch()
                    }
                }
489

490 491 492 493 494 495 496 497 498 499
            } else if (response.status?.code == HttpURLConnection.HTTP_NOT_FOUND) {
                // collection sync: resource has been deleted on remote server
                results += processor.submit {
                    useLocal(localCollection.findByName(name)) { local ->
                        Logger.log.info("$name has been deleted on server, deleting locally")
                        local?.delete()
                        nDeleted.incrementAndGet()
                    }
                }
            }
500

501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560
            // check already available results for exceptions so that they don't become too many
            checkResults(results)
        }

        // process remaining responses
        processor.shutdown()
        processor.awaitTermination(5, TimeUnit.MINUTES)

        // download remaining resources
        if (toDownload.isNotEmpty())
            downloadBunch()

        // signal end of queue and wait for download thread
        downloader.shutdown()
        downloader.awaitTermination(5, TimeUnit.MINUTES)

        // check remaining results for exceptions
        checkResults(results)

        // update sync stats
        with(syncResult.stats) {
            numInserts += nInserted.get()
            numUpdates += nUpdated.get()
            numDeletes += nDeleted.get()
            numSkippedEntries += nSkipped.get()
        }
    }

    protected abstract fun listAllRemote(callback: DavResponseCallback)

    protected open fun listRemoteChanges(syncState: SyncState?, callback: DavResponseCallback): Pair<SyncToken, Boolean> {
        var furtherResults = false

        val report = davCollection.reportChanges(
                syncState?.takeIf { syncState.type == SyncState.Type.SYNC_TOKEN }?.value,
                false, null,
                GetETag.NAME) { response, relation ->
            when (relation) {
                Response.HrefRelation.SELF ->
                    furtherResults = response.status?.code == 507

                Response.HrefRelation.MEMBER ->
                    callback(response, relation)

                else ->
                    Logger.log.fine("Unexpected sync-collection response: $response")
            }
        }

        var syncToken: SyncToken? = null
        report.filterIsInstance(SyncToken::class.java).firstOrNull()?.let {
            syncToken = it
        }
        if (syncToken == null)
            throw DavException("Received sync-collection response without sync-token")

        return Pair(syncToken!!, furtherResults)
    }

    protected abstract fun downloadRemote(bunch: List<HttpUrl>)
561 562

    /**
563 564 565 566 567 568
     * Locally deletes entries which are
     *   1. not dirty and
     *   2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
     *
     * Used together with [resetPresentRemotely] when a full listing has been received from
     * the server to locally delete resources which are not present remotely (anymore).
569
     */
570 571 572 573 574
    protected open fun deleteNotPresentRemotely(): Int {
        val removed = localCollection.removeNotDirtyMarked(0)
        Logger.log.info("Removed $removed local resources which are not present on the server anymore")
        return removed
    }
575 576

    /**
577
     * Post-processing of synchronized entries, for instance contact group membership operations.
578
     */
579
    protected abstract fun postProcess()
580 581


582 583
    // sync helpers

584 585 586
    /**
     * Throws an [InterruptedException] if the current thread has been interrupted,
     * most probably because synchronization was cancelled by the user.
587 588
     *
     * @throws InterruptedException (which will be caught by [performSync])
589 590 591 592 593
     * */
    protected fun abortIfCancelled() {
        if (Thread.interrupted())
            throw InterruptedException("Sync was cancelled")
    }
594

595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615
    protected fun syncState(dav: Response) =
            dav[SyncToken::class.java]?.token?.let {
                SyncState(SyncState.Type.SYNC_TOKEN, it)
            } ?:
            dav[GetCTag::class.java]?.cTag?.let {
                SyncState(SyncState.Type.CTAG, it)
            }

    private fun querySyncState(): SyncState? {
        var state: SyncState? = null
        davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME) { response, relation ->
            if (relation == Response.HrefRelation.SELF)
                state = syncState(response)
        }
        return state
    }


    // exception helpers

    private fun notifyException(e: Throwable, local: ResourceType?, remote: HttpUrl?) {
616 617 618
        val message: String

        when (e) {
619 620
            is IOException,
            is InterruptedIOException -> {
621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636
                Logger.log.log(Level.WARNING, "I/O error", e)
                message = context.getString(R.string.sync_error_io, e.localizedMessage)
                syncResult.stats.numIoExceptions++
            }
            is UnauthorizedException -> {
                Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
                message = context.getString(R.string.sync_error_authentication_failed)
                syncResult.stats.numAuthExceptions++
            }
            is HttpException, is DavException -> {
                Logger.log.log(Level.SEVERE, "HTTP/DAV exception", e)
                message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
                syncResult.stats.numParseExceptions++       // numIoExceptions would indicate a soft error
            }
            is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
                Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
637
                message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
638 639 640 641 642 643 644 645 646 647
                syncResult.databaseError = true
            }
            else -> {
                Logger.log.log(Level.SEVERE, "Unclassified sync error", e)
                message = e.localizedMessage ?: e::class.java.simpleName
                syncResult.stats.numParseExceptions++
            }
        }

        val contentIntent: Intent
648
        var viewItemAction: NotificationCompat.Action? = null
649 650 651 652
        if ((account.type == context.getString(R.string.account_type) ||
                        account.type == context.getString(R.string.eelo_account_type) ||
                        account.type == context.getString(R.string.google_account_type)) &&
                (e is UnauthorizedException || e is NotFoundException)) {
653 654 655 656 657
            contentIntent = Intent(context, AccountSettingsActivity::class.java)
            contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
        } else {
            contentIntent = Intent(context, DebugInfoActivity::class.java)
            contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
658

659 660 661
            contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
            contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)

662
            // use current local/remote resource
663
            if (local != null) {
664 665
                // pass local resource info to debug info
                contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
666

667 668 669
                // generate "view item" action
                viewItemAction = buildViewItemAction(local)
            }
670
            if (remote != null)
671
                contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
Ricki Hirner's avatar
Tests  
Ricki Hirner committed
672
        }
673 674

        // to make the PendingIntent unique
675
        contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
676

Ricki Hirner's avatar
Ricki Hirner committed
677 678
        val channel: String
        val priority: Int
679
        if (e is IOException) {
Ricki Hirner's avatar
Ricki Hirner committed
680
            channel = NotificationUtils.CHANNEL_SYNC_IO_ERRORS
681
            priority = NotificationCompat.PRIORITY_MIN
Ricki Hirner's avatar
Ricki Hirner committed
682 683 684 685
        } else {
            channel = NotificationUtils.CHANNEL_SYNC_ERRORS
            priority = NotificationCompat.PRIORITY_DEFAULT
        }
686

Ricki Hirner's avatar
Ricki Hirner committed
687
        val builder = NotificationUtils.newBuilder(context, channel)
688 689 690 691
        builder .setSmallIcon(R.drawable.ic_sync_error_notification)
                .setContentTitle(localCollection.title)
                .setContentText(message)
                .setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
692
                .setSubText(mainAccount.name)
693 694
                .setOnlyAlertOnce(true)
                .setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
Ricki Hirner's avatar
Ricki Hirner committed
695
                .setPriority(priority)
696 697
                .setCategory(NotificationCompat.CATEGORY_ERROR)
        viewItemAction?.let { builder.addAction(it) }
698
        builder.addAction(buildRetryAction())
Ricki Hirner's avatar
Ricki Hirner committed
699

700 701 702 703
        notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
    }

    private fun buildRetryAction(): NotificationCompat.Action {
Ricki Hirner's avatar
Ricki Hirner committed
704 705
        val retryIntent = Intent(context, DavService::class.java)
        retryIntent.action = DavService.ACTION_FORCE_SYNC
706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721

        val syncAuthority: String
        val syncAccount: Account
        if (authority == ContactsContract.AUTHORITY) {
            // if this is a contacts sync, retry syncing all address books of the main account
            syncAuthority = context.getString(R.string.address_books_authority)
            syncAccount = mainAccount
        } else {
            syncAuthority = authority
            syncAccount = account
        }

        retryIntent.data = Uri.parse("sync://").buildUpon()
                .authority(syncAuthority)
                .appendPath(syncAccount.type)
                .appendPath(syncAccount.name)
Ricki Hirner's avatar
Ricki Hirner committed
722 723
                .build()

724 725 726
        return NotificationCompat.Action(
                android.R.drawable.ic_menu_rotate, context.getString(R.string.sync_error_retry),
                PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT))
727 728
    }

729
    private fun buildViewItemAction(local: ResourceType): NotificationCompat.Action? {
730 731 732 733 734 735 736 737 738 739 740 741 742 743
        Logger.log.log(Level.FINE, "Adding view action for local resource", local)
        val intent = local.id?.let { id ->
            when (local) {
                is LocalContact ->
                    Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
                is LocalEvent ->
                    Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
                is LocalTask ->
                    Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
                else ->
                    null
            }
        }
        return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
Ricki Hirner's avatar
Ricki Hirner committed
744
            NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
745 746 747 748 749
                    PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
        else
            null
    }

750 751 752 753 754 755 756 757 758 759 760 761 762 763
    fun checkResults(results: MutableCollection<Future<*>>) {
        val iter = results.iterator()
        while (iter.hasNext()) {
            val result = iter.next()
            if (result.isDone) {
                try {
                    result.get()
                } catch(e: ExecutionException) {
                    throw e.cause!!
                }
                iter.remove()
            }
        }
    }
764

765 766 767
    protected fun<T: ResourceType?, R> useLocal(local: T, body: (T) -> R): R {
        try {
            return body(local)
768 769 770 771
        } catch (e: ContextedException) {
            e.addContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
            throw e
        } catch (e: Throwable) {
772
            if (local != null)
773
                throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE, local)
774 775 776
            else
                throw e
        }
777 778
    }

779 780 781
    protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
        try {
            return body(remote)
782 783 784
        } catch (e: ContextedException) {
            e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
            throw e
785
        } catch(e: Throwable) {
786
            throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.location)
787 788 789 790 791 792
        }
    }

    protected fun<T> useRemote(remote: Response, body: (Response) -> T): T {
        try {
            return body(remote)
793 794 795
        } catch (e: ContextedException) {
            e.addContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
            throw e
796
        } catch (e: Throwable) {
797
            throw ContextedException(e).setContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE, remote.href)
798
        }
799
    }
800

801 802
    protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
            useRemote(davCollection, body)
803

804 805 806 807 808 809 810
    private fun unwrapExceptions(body: () -> Unit, handler: (e: Throwable, local: ResourceType?, remote: HttpUrl?) -> Unit) {
        var ex: Throwable? = null
        try {
            body()
        } catch(e: Throwable) {
            ex = e
        }
811

812 813 814
        var local: ResourceType? = null
        var remote: HttpUrl? = null

815
        if (ex is ContextedException) {
816
            @Suppress("UNCHECKED_CAST")
817 818
            // we want the innermost context value, which is the first one
            (ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_LOCAL_RESOURCE) as? ResourceType)?.let {
819 820 821
                if (local == null)
                    local = it
            }
822
            (ex.getFirstContextValue(Constants.EXCEPTION_CONTEXT_REMOTE_RESOURCE) as? HttpUrl)?.let {
823 824 825
                if (remote == null)
                    remote = it
            }
826
            ex = ex.cause
827 828 829 830
        }

        if (ex != null)
            handler(ex, local, remote)
831 832
    }

833

834
}