/* * Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering). * 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 com.etesync.syncadapter.syncadapter import android.accounts.Account import android.app.PendingIntent import android.app.Service import android.content.* import android.database.sqlite.SQLiteException import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.Bundle import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.util.Pair import at.bitfire.ical4android.CalendarStorageException import at.bitfire.vcard4android.ContactsStorageException import com.etebase.client.FetchOptions import com.etebase.client.exceptions.ConnectionException import com.etebase.client.exceptions.TemporaryServerErrorException import com.etebase.client.exceptions.UnauthorizedException import com.etesync.journalmanager.Crypto import com.etesync.journalmanager.Exceptions import com.etesync.journalmanager.JournalManager import com.etesync.syncadapter.* import com.etesync.syncadapter.Constants.COLLECTION_TYPES import com.etesync.syncadapter.log.Logger import com.etesync.syncadapter.model.CollectionInfo import com.etesync.syncadapter.model.JournalEntity import com.etesync.syncadapter.model.JournalModel import com.etesync.syncadapter.ui.DebugInfoActivity import com.etesync.syncadapter.ui.PermissionsActivity import com.etesync.syncadapter.utils.NotificationUtils import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.lang.Math.abs import java.util.* import java.util.logging.Level //import com.android.vending.billing.IInAppBillingService; typealias JournalList = List> class CachedJournalFetcher { private val cache: HashMap> = HashMap() private fun fetchJournals(journalsManager: JournalManager, settings: AccountSettings, serviceType: CollectionInfo.Type): JournalList { val journals = LinkedList>() for (journal in journalsManager.list()) { val crypto: Crypto.CryptoManager if (journal.key != null) { crypto = Crypto.CryptoManager(journal.version, settings.keyPair!!, journal.key!!) } else { crypto = Crypto.CryptoManager(journal.version, settings.password(), journal.uid!!) } journal.verify(crypto) val info = CollectionInfo.fromJson(journal.getContent(crypto)) info.updateFromJournal(journal) journals.add(Pair(journal, info)) } return journals; } fun list(journalsManager: JournalManager, settings: AccountSettings, serviceType: CollectionInfo.Type): JournalList { val cacheAge = 5 * 1000 // 5 seconds - it's just a hack for burst fetching val now = System.currentTimeMillis() val journals: JournalList synchronized (cache) { val cached = cache.get(settings.account.name) if ((cached != null) && (abs(now - cached.first!!) <= cacheAge)) { journals = cached.second!! } else { journals = fetchJournals(journalsManager, settings, serviceType) cache.set(settings.account.name, Pair(System.currentTimeMillis(), journals)) } } return journals.filter { it.second?.enumType == serviceType } } } abstract class SyncAdapterService : Service() { protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter override fun onBind(intent: Intent): IBinder? { return syncAdapter().syncAdapterBinder } abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) { private val syncErrorTitle: Int = R.string.sync_error_generic private val notificationManager = SyncNotification(context, "refresh-collections", Constants.NOTIFICATION_REFRESH_COLLECTIONS) abstract fun onPerformSyncDo(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { Logger.log.log(Level.INFO, "$authority sync of $account has been initiated.", extras.keySet().toTypedArray()) // required for dav4android (ServiceLoader) Thread.currentThread().contextClassLoader = context.classLoader notificationManager.cancel() try { onPerformSyncDo(account, extras, authority, provider, syncResult) } catch (e: SecurityException) { // Shouldn't be needed - not sure why it doesn't fail onSecurityException(account, extras, authority, syncResult) } catch (e: Exceptions.ServiceUnavailableException) { syncResult.stats.numIoExceptions++ syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY } catch (e: TemporaryServerErrorException) { syncResult.stats.numIoExceptions++ syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY } catch (e: ConnectionException) { syncResult.stats.numIoExceptions++ syncResult.delayUntil = Constants.DEFAULT_RETRY_DELAY } catch (e: Exceptions.IgnorableHttpException) { // Ignore } catch (e: Exception) { if (e is ContactsStorageException || e is CalendarStorageException || e is SQLiteException) { Logger.log.log(Level.SEVERE, "Couldn't prepare local journals", e) syncResult.databaseError = true } val syncPhase = R.string.sync_phase_journals val title = context.getString(syncErrorTitle, account.name) notificationManager.setThrowable(e) val detailsIntent = notificationManager.detailsIntent detailsIntent.putExtra(Constants.KEY_ACCOUNT, account) if (e !is Exceptions.UnauthorizedException && e !is UnauthorizedException) { detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) } notificationManager.notify(title, context.getString(syncPhase)) } catch (e: OutOfMemoryError) { val syncPhase = R.string.sync_phase_journals val title = context.getString(syncErrorTitle, account.name) notificationManager.setThrowable(e) val detailsIntent = notificationManager.detailsIntent detailsIntent.putExtra(Constants.KEY_ACCOUNT, account) notificationManager.notify(title, context.getString(syncPhase)) } } override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) { Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority") syncResult.databaseError = true val intent = Intent(context, PermissionsActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS) .setSmallIcon(R.drawable.ic_error_light) .setLargeIcon(App.getLauncherBitmap(context)) .setContentTitle(context.getString(R.string.sync_error_permissions)) .setContentText(context.getString(R.string.sync_error_permissions_text)) .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) .setCategory(NotificationCompat.CATEGORY_ERROR) .build() val nm = NotificationManagerCompat.from(context) nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify) } protected fun checkSyncConditions(settings: AccountSettings): Boolean { if (settings.syncWifiOnly) { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val network = cm.activeNetworkInfo if (network == null) { Logger.log.info("No network available, stopping") return false } if (network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) { Logger.log.info("Not on connected WiFi, stopping") return false } var onlySSID = settings.syncWifiOnlySSID if (onlySSID != null) { onlySSID = "\"" + onlySSID + "\"" val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val info = wifi.connectionInfo if (info == null || onlySSID != info.ssid) { Logger.log.info("Connected to wrong WiFi network (" + info!!.ssid + ", required: " + onlySSID + "), ignoring") return false } } } return true } inner class RefreshCollections internal constructor(private val account: Account, private val serviceType: CollectionInfo.Type) { private val context: Context init { context = getContext() } @Throws(Exceptions.HttpException::class, Exceptions.IntegrityException::class, InvalidAccountException::class, Exceptions.GenericCryptoException::class) internal fun run() { Logger.log.info("Refreshing " + serviceType + " collections of service #" + serviceType.toString()) val settings = AccountSettings(context, account) val httpClient = HttpClient.Builder(context, settings).setForeground(false).build() if (settings.isLegacy) { val journalsManager = JournalManager(httpClient.okHttpClient, settings.uri?.toHttpUrlOrNull()!!) var journals = journalFetcher.list(journalsManager, settings, serviceType) if (journals.isEmpty()) { journals = LinkedList() try { val info = CollectionInfo.defaultForServiceType(serviceType) val uid = JournalManager.Journal.genUid() info.uid = uid val crypto = Crypto.CryptoManager(info.version, settings.password(), uid) val journal = JournalManager.Journal(crypto, info.toJson(), uid) journalsManager.create(journal) journals.add(Pair(journal, info)) } catch (e: Exceptions.AssociateNotAllowedException) { // Skip for now } } legacySaveCollections(journals) httpClient.close() return } val etebaseLocalCache = EtebaseLocalCache.getInstance(context, account.name) synchronized(etebaseLocalCache) { val cacheAge = 5 * 1000 // 5 seconds - it's just a hack for burst fetching val now = System.currentTimeMillis() val lastCollectionsFetch = collectionLastFetchMap[account.name] ?: 0 if (abs(now - lastCollectionsFetch) <= cacheAge) { return@synchronized } val etebase = EtebaseLocalCache.getEtebase(context, httpClient.okHttpClient, settings) val colMgr = etebase.collectionManager var stoken = etebaseLocalCache.loadStoken() var done = false while (!done) { val colList = colMgr.list(COLLECTION_TYPES, FetchOptions().stoken(stoken)) for (col in colList.data) { etebaseLocalCache.collectionSet(colMgr, col) } for (col in colList.removedMemberships) { etebaseLocalCache.collectionUnset(colMgr, col.uid()) } stoken = colList.stoken done = colList.isDone if (stoken != null) { etebaseLocalCache.saveStoken(stoken) } } collectionLastFetchMap[account.name] = now } httpClient.close() } private fun legacySaveCollections(journals: Iterable>) { val data = (context.applicationContext as App).data val service = JournalModel.Service.fetchOrCreate(data, account.name, serviceType) val existing = HashMap() for (journalEntity in JournalEntity.getJournals(data, service)) { existing[journalEntity.uid] = journalEntity } for (pair in journals) { val journal = pair.first val collection = pair.second Logger.log.log(Level.FINE, "Saving collection", journal!!.uid) collection!!.serviceID = service.id val journalEntity = JournalEntity.fetchOrCreate(data, collection) journalEntity.owner = journal.owner journalEntity.encryptedKey = journal.key journalEntity.isReadOnly = journal.readOnly journalEntity.isDeleted = false journalEntity.remoteLastUid = journal.lastUid data.upsert(journalEntity) existing.remove(collection.uid) } for (journalEntity in existing.values) { Logger.log.log(Level.FINE, "Deleting collection", journalEntity.uid) journalEntity.isDeleted = true data.update(journalEntity) } } } } companion object { val journalFetcher = CachedJournalFetcher() var collectionLastFetchMap = HashMap() } }