Rewrite syncadapter package to Kotlin

This commit is contained in:
Ricki Hirner 2017-07-19 19:04:47 +02:00
parent e2c47bbe92
commit 4cb60ca78c
45 changed files with 2196 additions and 2706 deletions

View file

@ -45,7 +45,6 @@ android {
lintOptions {
disable 'GoogleAppIndexingWarning' // we don't need Google indexing, thanks
disable 'GradleDependency'
disable 'GradleDynamicVersion'
disable 'IconColors'
disable 'IconLauncherShape'
disable 'IconMissingDensityFolder'
@ -74,9 +73,13 @@ dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
//noinspection GradleDynamicVersion
compile 'com.android.support:appcompat-v7:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:cardview-v7:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:design:26.+'
//noinspection GradleDynamicVersion
compile 'com.android.support:preference-v14:26.+'
compile 'com.github.yukuku:ambilwarna:2.0.1'
@ -89,9 +92,11 @@ dependencies {
provided 'org.projectlombok:lombok:1.16.16'
// for tests
//noinspection GradleDynamicVersion
androidTestCompile('com.android.support.test:runner:+') {
exclude group: 'com.android.support', module: 'support-annotations'
}
//noinspection GradleDynamicVersion
androidTestCompile('com.android.support.test:rules:+') {
exclude group: 'com.android.support', module: 'support-annotations'
}

View file

@ -40,7 +40,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
companion object {
val CURRENT_VERSION = 6;
val CURRENT_VERSION = 6
val KEY_SETTINGS_VERSION = "version"
val KEY_USERNAME = "user_name"
@ -190,7 +190,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
}
@SuppressWarnings("unused")
@Suppress("unused")
private fun update_1_2() {
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
@ -223,11 +223,12 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
if (Build.VERSION.SDK_INT >= 24)
provider.close()
else
@Suppress("deprecation")
provider.release()
}
}
@SuppressWarnings("unused")
@Suppress("unused")
private fun update_2_3() {
// Don't show a warning for Android updates anymore
accountManager.setUserData(account, "last_android_version", null)
@ -273,6 +274,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
@ -296,6 +298,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
if (Build.VERSION.SDK_INT >= 24)
client.close()
else
@Suppress("deprecation")
client.release()
}
}
@ -353,24 +356,24 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
}
}
@SuppressWarnings("unused")
@Suppress("unused")
private fun update_3_4() {
setGroupMethod(GroupMethod.CATEGORIES)
}
/* Android 7.1.1 OpenTasks fix */
@SuppressWarnings("unused")
@Suppress("unused")
private fun update_4_5() {
// call PackageChangedReceiver which then enables/disables OpenTasks sync when it's (not) available
PackageChangedReceiver.updateTaskSync(context)
}
@SuppressWarnings("unused")
@Suppress("unused")
private fun update_5_6() {
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.use { provider ->
// don't run syncs during the migration
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 0)
ContentResolver.setIsSyncable(account, App.getAddressBooksAuthority(), 0)
ContentResolver.setIsSyncable(account, App.addressBooksAuthority, 0)
ContentResolver.cancelSync(account, null)
val parcel = Parcel.obtain()
@ -392,10 +395,9 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
info.type = CollectionInfo.Type.ADDRESS_BOOK
info.displayName = account.name
App.log.log(Level.INFO, "Creating new address book account", url)
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), App.getAddressBookAccountType())
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), App.addressBookAccountType)
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
throw ContactsStorageException("Couldn't create address book account")
val addressBook = LocalAddressBook(context, addressBookAccount, provider)
// move contacts to new address book
App.log.info("Moving contacts from $account to $addressBookAccount")
@ -425,8 +427,8 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
accountManager.setUserData(account, KEY_SETTINGS_VERSION, "6")
// request sync of new address book account
ContentResolver.setIsSyncable(account, App.getAddressBooksAuthority(), 1)
setSyncInterval(App.getAddressBooksAuthority(), Constants.DEFAULT_SYNC_INTERVAL)
ContentResolver.setIsSyncable(account, App.addressBooksAuthority, 1)
setSyncInterval(App.addressBooksAuthority, Constants.DEFAULT_SYNC_INTERVAL)
}

View file

@ -1,189 +0,0 @@
/*
* 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 at.bitfire.davdroid;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Process;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import android.util.Log;
import org.apache.commons.lang3.time.DateFormatUtils;
import java.io.File;
import java.io.IOException;
import java.util.logging.FileHandler;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.net.ssl.HostnameVerifier;
import at.bitfire.cert4android.CustomCertManager;
import at.bitfire.davdroid.log.LogcatHandler;
import at.bitfire.davdroid.log.PlainTextFormatter;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.Settings;
import lombok.Cleanup;
import okhttp3.internal.tls.OkHostnameVerifier;
public class App extends Application {
public static final String
DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts",
LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage",
OVERRIDE_PROXY = "overrideProxy",
OVERRIDE_PROXY_HOST = "overrideProxyHost",
OVERRIDE_PROXY_PORT = "overrideProxyPort";
public static final String OVERRIDE_PROXY_HOST_DEFAULT = "localhost";
public static final int OVERRIDE_PROXY_PORT_DEFAULT = 8118;
private CustomCertManager certManager;
public CustomCertManager getCertManager() { return certManager; }
private static SSLSocketFactoryCompat sslSocketFactoryCompat;
public static SSLSocketFactoryCompat getSslSocketFactoryCompat() { return sslSocketFactoryCompat; }
private static HostnameVerifier hostnameVerifier;
public static HostnameVerifier getHostnameVerifier() { return hostnameVerifier; }
public final static Logger log = Logger.getLogger("davdroid");
static {
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android");
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android");
}
private static String addressBookAccountType;
public static String getAddressBookAccountType() { return addressBookAccountType; }
private static String addressBooksAuthority;
public static String getAddressBooksAuthority() { return addressBooksAuthority; }
@Override
@SuppressLint("HardwareIds")
public void onCreate() {
super.onCreate();
reinitCertManager();
reinitLogger();
addressBookAccountType = getString(R.string.account_type_address_book);
addressBooksAuthority = getString(R.string.address_books_authority);
}
public void reinitCertManager() {
if (BuildConfig.customCerts) {
if (certManager != null)
certManager.close();
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this);
Settings settings = new Settings(dbHelper.getReadableDatabase());
certManager = new CustomCertManager(this, !settings.getBoolean(DISTRUST_SYSTEM_CERTIFICATES, false));
sslSocketFactoryCompat = new SSLSocketFactoryCompat(certManager);
hostnameVerifier = certManager.hostnameVerifier(OkHostnameVerifier.INSTANCE);
}
}
public void reinitLogger() {
@Cleanup ServiceDB.OpenHelper dbHelper = new ServiceDB.OpenHelper(this);
Settings settings = new Settings(dbHelper.getReadableDatabase());
boolean logToFile = settings.getBoolean(LOG_TO_EXTERNAL_STORAGE, false),
logVerbose = logToFile || Log.isLoggable(log.getName(), Log.DEBUG);
App.log.info("Verbose logging: " + logVerbose);
// set logging level according to preferences
final Logger rootLogger = Logger.getLogger("");
rootLogger.setLevel(logVerbose ? Level.ALL : Level.INFO);
// remove all handlers and add our own logcat handler
rootLogger.setUseParentHandlers(false);
for (Handler handler : rootLogger.getHandlers())
rootLogger.removeHandler(handler);
rootLogger.addHandler(LogcatHandler.INSTANCE);
NotificationManagerCompat nm = NotificationManagerCompat.from(this);
// log to external file according to preferences
if (logToFile) {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
.setLargeIcon(getLauncherBitmap(this))
.setContentTitle(getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true);
File dir = getExternalFilesDir(null);
if (dir != null)
try {
String fileName = new File(dir, "davdroid-" + Process.myPid() + "-" +
DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss") + ".txt").toString();
log.info("Logging to " + fileName);
FileHandler fileHandler = new FileHandler(fileName);
fileHandler.setFormatter(PlainTextFormatter.DEFAULT);
log.addHandler(fileHandler);
builder .setContentText(dir.getPath())
.setSubText(getString(R.string.logging_to_external_storage_warning))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(getString(R.string.logging_to_external_storage, dir.getPath())))
.setOngoing(true);
} catch (IOException e) {
log.log(Level.SEVERE, "Couldn't create external log file", e);
builder .setContentText(getString(R.string.logging_couldnt_create_file, e.getLocalizedMessage()))
.setCategory(NotificationCompat.CATEGORY_ERROR);
}
else
builder.setContentText(getString(R.string.logging_no_external_storage));
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build());
} else
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING);
}
@Nullable
public static Bitmap getLauncherBitmap(@NonNull Context context) {
Bitmap bitmapLogo = null;
Drawable drawableLogo = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP ?
context.getDrawable(R.mipmap.ic_launcher) :
context.getResources().getDrawable(R.mipmap.ic_launcher);
if (drawableLogo instanceof BitmapDrawable)
bitmapLogo = ((BitmapDrawable)drawableLogo).getBitmap();
return bitmapLogo;
}
public static class ReinitSettingsReceiver extends BroadcastReceiver {
public static final String ACTION_REINIT_SETTINGS = "at.bitfire.davdroid.REINIT_SETTINGS";
@Override
public void onReceive(Context context, Intent intent) {
log.info("Received broadcast: re-initializing settings (logger/cert manager)");
App app = (App)context.getApplicationContext();
app.reinitLogger();
app.reinitCertManager();
}
}
}

View file

@ -0,0 +1,181 @@
/*
* 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 at.bitfire.davdroid
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.os.Process
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import android.util.Log
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.davdroid.log.LogcatHandler
import at.bitfire.davdroid.log.PlainTextFormatter
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.Settings
import okhttp3.internal.tls.OkHostnameVerifier
import org.apache.commons.lang3.time.DateFormatUtils
import java.io.File
import java.io.IOException
import java.util.logging.FileHandler
import java.util.logging.Level
import java.util.logging.Logger
import javax.net.ssl.HostnameVerifier
class App: Application() {
companion object {
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrustSystemCerts"
@JvmField val LOG_TO_EXTERNAL_STORAGE = "logToExternalStorage"
@JvmField val OVERRIDE_PROXY = "overrideProxy"
@JvmField val OVERRIDE_PROXY_HOST = "overrideProxyHost"
@JvmField val OVERRIDE_PROXY_PORT = "overrideProxyPort"
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
@JvmField
val log = Logger.getLogger("davdroid")!!
init {
at.bitfire.dav4android.Constants.log = Logger.getLogger("davdroid.dav4android")
at.bitfire.cert4android.Constants.log = Logger.getLogger("davdroid.cert4android")
}
lateinit var addressBookAccountType: String
lateinit var addressBooksAuthority: String
@JvmStatic
fun getLauncherBitmap(context: Context): Bitmap? {
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
context.getDrawable(R.mipmap.ic_launcher)
else
@Suppress("deprecation")
context.resources.getDrawable(R.mipmap.ic_launcher)
return if (drawableLogo is BitmapDrawable)
drawableLogo.bitmap
else
null
}
}
var certManager: CustomCertManager? = null
var sslSocketFactoryCompat: SSLSocketFactoryCompat? = null
var hostnameVerifier: HostnameVerifier? = null
override fun onCreate() {
super.onCreate()
reinitCertManager()
reinitLogger()
addressBookAccountType = getString(R.string.account_type_address_book)
addressBooksAuthority = getString(R.string.address_books_authority)
}
fun reinitCertManager() {
if (true /* custom certificate support */) {
certManager?.close()
ServiceDB.OpenHelper(this).use { dbHelper ->
val settings = Settings(dbHelper.readableDatabase)
val mgr = CustomCertManager(this, !settings.getBoolean(DISTRUST_SYSTEM_CERTIFICATES, false))
sslSocketFactoryCompat = SSLSocketFactoryCompat(mgr)
hostnameVerifier = mgr.hostnameVerifier(OkHostnameVerifier.INSTANCE)
certManager = mgr
}
}
}
fun reinitLogger() {
ServiceDB.OpenHelper(this).use { dbHelper ->
val settings = Settings(dbHelper.getReadableDatabase())
val logToFile = settings.getBoolean(LOG_TO_EXTERNAL_STORAGE, false)
val logVerbose = logToFile || Log.isLoggable(log.name, Log.DEBUG)
App.log.info("Verbose logging: $logVerbose")
// set logging level according to preferences
val rootLogger = Logger.getLogger("")
rootLogger.level = if (logVerbose) Level.ALL else Level.INFO
// remove all handlers and add our own logcat handler
rootLogger.useParentHandlers = false
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
rootLogger.addHandler(LogcatHandler)
val nm = NotificationManagerCompat.from(this)
// log to external file according to preferences
if (logToFile) {
val builder = NotificationCompat.Builder(this)
builder .setSmallIcon(R.drawable.ic_sd_storage_light)
.setLargeIcon(getLauncherBitmap(this))
.setContentTitle(getString(R.string.logging_davdroid_file_logging))
.setLocalOnly(true)
val dir = getExternalFilesDir(null)
if (dir != null)
try {
val fileName = File(dir, "davdroid-${Process.myPid()}-${DateFormatUtils.format(System.currentTimeMillis(), "yyyyMMdd-HHmmss")}.txt").toString()
log.info("Logging to $fileName")
val fileHandler = FileHandler(fileName)
fileHandler.formatter = PlainTextFormatter.DEFAULT
log.addHandler(fileHandler)
builder .setContentText(dir.path)
.setSubText(getString(R.string.logging_to_external_storage_warning))
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setStyle(NotificationCompat.BigTextStyle()
.bigText(getString(R.string.logging_to_external_storage, dir.path)))
.setOngoing(true)
} catch (e: IOException) {
log.log(Level.SEVERE, "Couldn't create external log file", e)
builder .setContentText(getString(R.string.logging_couldnt_create_file, e.localizedMessage))
.setCategory(NotificationCompat.CATEGORY_ERROR)
}
else
builder.setContentText(getString(R.string.logging_no_external_storage))
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
} else
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
}
}
class ReinitSettingsReceiver: BroadcastReceiver() {
companion object {
@JvmField val ACTION_REINIT_SETTINGS = "at.bitfire.davdroid.REINIT_SETTINGS"
}
override fun onReceive(context: Context, intent: Intent) {
log.info("Received broadcast: re-initializing settings (logger/cert manager)")
val app = context.applicationContext
if (app is App) {
app.reinitLogger()
app.reinitCertManager()
} else
App.log.severe("context is ${app::class.java.canonicalName} instead of App")
}
}
}

View file

@ -1,33 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid;
import java.lang.reflect.Array;
public class ArrayUtils {
@SuppressWarnings("unchecked")
public static <T> T[][] partition(T[] bigArray, int max) {
int nItems = bigArray.length;
int nPartArrays = (nItems + max-1)/max;
T[][] partArrays = (T[][])Array.newInstance(bigArray.getClass().getComponentType(), nPartArrays, 0);
// nItems is now the number of remaining items
for (int i = 0; nItems > 0; i++) {
int n = (nItems < max) ? nItems : max;
partArrays[i] = (T[])Array.newInstance(bigArray.getClass().getComponentType(), n);
System.arraycopy(bigArray, i*max, partArrays[i], 0, n);
nItems -= n;
}
return partArrays;
}
}

View file

@ -125,7 +125,7 @@ class DavService: Service() {
}
// delete orphaned address book accounts
accountManager.getAccountsByType(App.getAddressBookAccountType())
accountManager.getAccountsByType(App.addressBookAccountType)
.map { LocalAddressBook(this, it, null) }
.forEach {
try {

View file

@ -64,12 +64,14 @@ class HttpClient private constructor() {
val builder = client.newBuilder()
// use MemorizingTrustManager to manage self-signed certificates
context?.let {
val app = it.applicationContext as App
if (App.getSslSocketFactoryCompat() != null && app.certManager != null)
builder.sslSocketFactory(App.getSslSocketFactoryCompat(), app.certManager)
if (App.getHostnameVerifier() != null)
builder.hostnameVerifier(App.getHostnameVerifier())
context?.applicationContext?.let { app ->
if (app is App) {
if (app.sslSocketFactoryCompat != null && app.certManager != null)
builder.sslSocketFactory(app.sslSocketFactoryCompat, app.certManager)
if (app.hostnameVerifier != null)
builder.hostnameVerifier(app.hostnameVerifier)
} else
App.log.severe("Application context is ${app::class.java.canonicalName} instead of App")
}
// set timeouts

View file

@ -45,7 +45,7 @@ class LocalAddressBook(
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
val accountManager = AccountManager.get(context)
val account = Account(accountName(mainAccount, info), App.getAddressBookAccountType())
val account = Account(accountName(mainAccount, info), App.addressBookAccountType)
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
throw ContactsStorageException("Couldn't create address book account")
@ -60,7 +60,7 @@ class LocalAddressBook(
val accountManager = AccountManager.get(context)
val result = LinkedList<LocalAddressBook>()
accountManager.getAccountsByType(App.getAddressBookAccountType())
accountManager.getAccountsByType(App.addressBookAccountType)
.map { LocalAddressBook(context, it, provider) }
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
.forEach { result += it }
@ -135,6 +135,7 @@ class LocalAddressBook(
if (Build.VERSION.SDK_INT >= 22)
accountManager.removeAccount(account, null, null, null)
else
@Suppress("deprecation")
accountManager.removeAccount(account, null, null)
} catch(e: Exception) {
throw ContactsStorageException("Couldn't remove address book", e)

View file

@ -23,7 +23,6 @@ import ezvcard.Ezvcard
import java.io.FileNotFoundException
import java.util.*
// TODO toString
class LocalContact: AndroidContact, LocalResource {
companion object {

View file

@ -19,8 +19,6 @@ import net.fortuna.ical4j.model.property.ProdId
import java.io.FileNotFoundException
import java.util.*
//@TargetApi(17)
//TODO @ToString(of={ "fileName","eTag" }, callSuper=true)
class LocalEvent: AndroidEvent, LocalResource {
companion object {

View file

@ -25,7 +25,6 @@ import java.io.FileNotFoundException
import java.util.*
import java.util.logging.Level
// TODO @ToString(callSuper=true)
class LocalGroup: AndroidGroup, LocalResource {
companion object {

View file

@ -17,7 +17,6 @@ import java.io.FileNotFoundException
import java.text.ParseException
import java.util.*
// TODO @ToString(of={ "fileName","eTag" }, callSuper=true)
class LocalTask: AndroidTask, LocalResource {
companion object {

View file

@ -1,89 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import at.bitfire.davdroid.ui.setup.LoginActivity;
public class AccountAuthenticatorService extends Service {
private AccountAuthenticator accountAuthenticator;
@Override
public void onCreate() {
accountAuthenticator = new AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
return accountAuthenticator.getIBinder();
return null;
}
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
final Context context;
public AccountAuthenticator(Context context) {
super(context);
this.context = context;
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType,
String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Intent intent = new Intent(context, LoginActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
return null;
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
return null;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.ui.setup.LoginActivity
class AccountAuthenticatorService: Service() {
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>, options: Bundle): Bundle {
val intent = Intent(context, LoginActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle(1)
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}

View file

@ -1,53 +0,0 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
public class AddressBookProvider extends ContentProvider {
@Override
public boolean onCreate() {
return false;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
return null;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
return null;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
return 0;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
return 0;
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter
import android.content.ContentProvider
import android.content.ContentValues
import android.net.Uri
class AddressBookProvider: ContentProvider() {
override fun onCreate() = false
override fun insert(p0: Uri?, p1: ContentValues?) = null
override fun query(p0: Uri?, p1: Array<out String>?, p2: String?, p3: Array<out String>?, p4: String?) = null
override fun update(p0: Uri?, p1: ContentValues?, p2: String?, p3: Array<out String>?) = 0
override fun delete(p0: Uri?, p1: String?, p2: Array<out String>?) = 0
override fun getType(p0: Uri?) = null
}

View file

@ -1,161 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.provider.ContactsContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class AddressBooksSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new AddressBooksSyncAdapter(this);
}
private static class AddressBooksSyncAdapter extends SyncAdapter {
public AddressBooksSyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient addressBooksProvider, SyncResult syncResult) {
@Cleanup("release") ContentProviderClient contactsProvider = getContext().getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (contactsProvider == null) {
App.log.severe("Couldn't access contacts provider");
syncResult.databaseError = true;
return;
}
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalAddressBooks(contactsProvider, account);
AccountManager accountManager = AccountManager.get(getContext());
for (Account addressBookAccount : accountManager.getAccountsByType(getContext().getString(R.string.account_type_address_book))) {
App.log.log(Level.INFO, "Running sync for address book", addressBookAccount);
Bundle syncExtras = new Bundle(extras);
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true);
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true);
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras);
}
} catch(InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
} catch(ContactsStorageException e) {
App.log.log(Level.SEVERE, "Couldn't prepare local address books", e);
} finally {
dbHelper.close();
}
App.log.info("Address book sync complete");
}
private void updateLocalAddressBooks(ContentProviderClient provider, Account account) throws ContactsStorageException {
final Context context = getContext();
@Cleanup SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(context);
// enumerate remote and local address books
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteAddressBooks(db, service);
List<LocalAddressBook> local = LocalAddressBook.find(context, provider, account);
// delete obsolete local address books
for (LocalAddressBook addressBook : local) {
String url = addressBook.getURL();
if (!remote.containsKey(url)) {
App.log.log(Level.FINE, "Deleting obsolete local address book", url);
addressBook.delete();
} else {
// we already have a local address book for this remote collection, don't take into consideration anymore
try {
addressBook.update(remote.get(url));
} catch(ContactsStorageException e) {
App.log.log(Level.WARNING, "Couldn't rename address book account", e);
}
remote.remove(url);
}
}
// create new local address books
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local address book " + info);
LocalAddressBook.create(context, provider, account, info);
}
}
@Nullable
private Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
@Cleanup Cursor c = db.query(ServiceDB.Services._TABLE, new String[] { ServiceDB.Services.ID },
ServiceDB.Services.ACCOUNT_NAME + "=? AND " + ServiceDB.Services.SERVICE + "=?", new String[] { account.name, ServiceDB.Services.SERVICE_CARDDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteAddressBooks(@NonNull SQLiteDatabase db, Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor c = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, new String[] { String.valueOf(service) }, null, null, null);
while (c.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(c, values);
CollectionInfo info = new CollectionInfo(values);
collections.put(info.getUrl(), info);
}
}
return collections;
}
}
}

View file

@ -0,0 +1,134 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import android.provider.ContactsContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.R
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.vcard4android.ContactsStorageException
import java.util.logging.Level
class AddressBooksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = AddressBooksSyncAdapter(this)
protected class AddressBooksSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
if (contactsProvider == null) {
App.log.severe("Couldn't access contacts provider")
syncResult.databaseError = true
return
}
try {
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
updateLocalAddressBooks(contactsProvider, account)
val accountManager = AccountManager.get(context)
for (addressBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
App.log.log(Level.INFO, "Running sync for address book", addressBookAccount)
val syncExtras = Bundle(extras)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_SETTINGS, true)
syncExtras.putBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, true)
ContentResolver.requestSync(addressBookAccount, ContactsContract.AUTHORITY, syncExtras)
}
} catch(e: Exception) {
App.log.log(Level.SEVERE, "Couldn't sync address books", e)
}
App.log.info("Address book sync complete")
}
private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local address books
val service = getService()
val remote = remoteAddressBooks(service)
// delete/update local address books
for (addressBook in LocalAddressBook.find(context, provider, account)) {
val url = addressBook.getURL()
val info = remote[url]
if (info == null) {
App.log.log(Level.INFO, "Deleting obsolete local address book", url)
addressBook.delete()
} else {
// remote CollectionInfo found for this local collection, update data
try {
App.log.log(Level.FINE, "Updating local address book $url", info)
addressBook.update(info)
} catch(e: ContactsStorageException) {
App.log.log(Level.WARNING, "Couldn't rename address book account", e)
}
// we already have a local address book for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local address books
for ((_, info) in remote) {
App.log.log(Level.INFO, "Adding local address book", info)
LocalAddressBook.create(context, provider, account, info)
}
}
}
}
}

View file

@ -1,251 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import org.apache.commons.codec.Charsets;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.DavCalendar;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.CalendarData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.davdroid.resource.LocalEvent;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.Event;
import at.bitfire.ical4android.InvalidCalendarException;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
public class CalendarSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 20;
public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar) {
super(context, account, settings, extras, authority, result, "calendar/" + calendar.getId());
localCollection = calendar;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_CALENDAR_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_calendar, account.name);
}
@Override
protected boolean prepare() {
collectionURL = HttpUrl.parse(localCalendar().getName());
davCollection = new DavCalendar(httpClient, collectionURL);
return true;
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
davCollection.propfind(0, GetCTag.NAME);
}
@Override
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
super.prepareDirty();
localCalendar().processDirtyExceptions();
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
LocalEvent local = (LocalEvent)resource;
App.log.log(Level.FINE, "Preparing upload of event " + local.getFileName(), local.getEvent());
ByteArrayOutputStream os = new ByteArrayOutputStream();
local.getEvent().write(os);
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// calculate time range limits
Date limitStart = null;
Integer pastDays = settings.getTimeRangePastDays();
if (pastDays != null) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_MONTH, -pastDays);
limitStart = calendar.getTime();
}
// fetch list of remote VEVENTs and build hash table to index file name
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.calendarQuery("VEVENT", limitStart, null);
remoteResources = new HashMap<>(davCollection.getMembers().size());
for (DavResource iCal : davCollection.getMembers()) {
String fileName = iCal.fileName();
App.log.fine("Found remote VEVENT: " + fileName);
remoteResources.put(fileName, iCal);
}
currentDavResource = null;
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
App.log.info("Downloading " + toDownload.size() + " events (" + MAX_MULTIGET + " at once)");
// download new/updated iCalendars from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/calendar");
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
GetETag eTag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.getETag()))
throw new DavException("Received CalDAV GET response without ETag for " + remote.getLocation());
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVEvent(remote.fileName(), eTag.getETag(), stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.getLocation());
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (final DavResource remote : davCollection.getMembers()) {
currentDavResource = remote;
String eTag;
GetETag getETag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (getETag != null)
eTag = getETag.getETag();
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType)remote.getProperties().get(GetContentType.NAME);
if (getContentType != null && getContentType.getType() != null) {
MediaType type = MediaType.parse(getContentType.getType());
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
CalendarData calendarData = (CalendarData)remote.getProperties().get(CalendarData.NAME);
if (calendarData == null || calendarData.getICalendar() == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.getICalendar().getBytes());
processVEvent(remote.fileName(), eTag, stream, charset);
}
}
currentDavResource = null;
}
}
// helpers
private LocalCalendar localCalendar() { return ((LocalCalendar)localCollection); }
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
private void processVEvent(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
Event[] events;
try {
events = Event.fromStream(stream, charset);
} catch (InvalidCalendarException e) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e);
return;
}
if (events.length == 1) {
Event newData = events[0];
// delete local event, if it exists
LocalEvent localEvent = (LocalEvent)localResources.get(fileName);
currentLocalResource = localEvent;
if (localEvent != null) {
App.log.info("Updating " + fileName + " in local calendar");
localEvent.setETag(eTag);
localEvent.update(newData);
syncResult.stats.numUpdates++;
} else {
App.log.info("Adding " + fileName + " to local calendar");
localEvent = new LocalEvent(localCalendar(), newData, fileName, eTag);
currentLocalResource = localEvent;
localEvent.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring " + fileName);
currentLocalResource = null;
}
}

View file

@ -0,0 +1,210 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.davdroid.resource.LocalEvent
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.ical4android.Event
import at.bitfire.ical4android.InvalidCalendarException
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
*/
class CalendarSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
val localCalendar: LocalCalendar
): SyncManager(context, account, settings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
val MAX_MULTIGET = 20
init {
localCollection = localCalendar
}
override fun notificationId() = Constants.NOTIFICATION_CALENDAR_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_calendar, account.name)!!
override fun prepare(): Boolean {
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
davCollection = DavCalendar(httpClient, collectionURL)
return true
}
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun prepareDirty() {
super.prepareDirty()
localCalendar.processDirtyExceptions()
}
override fun prepareUpload(resource: LocalResource): RequestBody {
if (resource is LocalEvent) {
val event = requireNotNull(resource.event)
App.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
val os = ByteArrayOutputStream()
event.write(os)
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
)
} else
throw IllegalArgumentException("resource must be a LocalEvent")
}
override fun listRemote() {
// calculate time range limits
var limitStart: Date? = null
settings.getTimeRangePastDays()?.let { pastDays ->
val calendar = Calendar.getInstance()
calendar.add(Calendar.DAY_OF_MONTH, -pastDays)
limitStart = calendar.time
}
// fetch list of remote VEVENTs and build hash table to index file name
val calendar = davCalendar()
currentDavResource = calendar
calendar.calendarQuery("VEVENT", limitStart, null)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (iCal in davCollection.members) {
val fileName = iCal.fileName()
App.log.fine("Found remote VEVENT: $fileName")
remoteResources[fileName] = iCal
}
currentDavResource = null
}
override fun downloadRemote() {
App.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
// download new/updated iCalendars from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
App.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/calendar")
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream()?.use { reader ->
processVEvent(remote.fileName(), eTag.eTag!!, reader)
}
} else {
// multiple contacts, use multi-get
val calendar = davCalendar()
currentDavResource = calendar
calendar.multiget(bunch.map { it.location })
// process multiget results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without event data")
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
}
}
currentDavResource = null
}
}
// helpers
private fun davCalendar() = davCollection as DavCalendar
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
val events: List<Event>
try {
events = Event.fromReader(reader)
} catch (e: InvalidCalendarException) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
return
}
if (events.size == 1) {
val newData = events.first()
// delete local event, if it exists
val localEvent = localResources[fileName] as LocalEvent?
currentLocalResource = localEvent
if (localEvent != null) {
App.log.info("Updating $fileName in local calendar")
localEvent.eTag = eTag
localEvent.update(newData)
syncResult.stats.numUpdates++
} else {
App.log.info("Adding $fileName to local calendar")
val newEvent = LocalEvent(localCalendar, newData, fileName, eTag)
currentLocalResource = newEvent
newEvent.add()
syncResult.stats.numInserts++
}
} else
App.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
currentLocalResource = null
}
}

View file

@ -1,147 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalCalendar;
import at.bitfire.ical4android.CalendarStorageException;
import lombok.Cleanup;
public class CalendarsSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new SyncAdapter(this);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, provider, syncResult);
try {
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalCalendars(provider, account, settings);
for (LocalCalendar calendar : LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null)) {
App.log.info("Synchronizing calendar #" + calendar.getId() + ", URL: " + calendar.getName());
CalendarSyncManager syncManager = new CalendarSyncManager(getContext(), account, settings, extras, authority, syncResult, calendar);
syncManager.performSync();
}
} catch(InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
} catch(CalendarStorageException|SQLiteException e) {
App.log.log(Level.SEVERE, "Couldn't prepare local calendars", e);
syncResult.databaseError = true;
}
App.log.info("Calendar sync complete");
}
private void updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException {
@Cleanup SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
// enumerate remote and local calendars
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteCalendars(db, service);
List<LocalCalendar> local = LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null);
boolean updateColors = settings.getManageCalendarColors();
// delete obsolete local calendars
for (LocalCalendar calendar : local) {
String url = calendar.getName();
if (!remote.containsKey(url)) {
App.log.fine("Deleting obsolete local calendar " + url);
calendar.delete();
} else {
// remote CollectionInfo found for this local collection, update data
CollectionInfo info = remote.get(url);
App.log.fine("Updating local calendar " + url + " with " + info);
calendar.update(info, updateColors);
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote.remove(url);
}
}
// create new local calendars
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local calendar list " + info);
LocalCalendar.create(account, provider, info);
}
}
@Nullable
Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteCalendars(@NonNull SQLiteDatabase db, Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VEVENT + "!=0 AND " + Collections.SYNC,
new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
CollectionInfo info = new CollectionInfo(values);
collections.put(info.getUrl(), info);
}
}
return collections;
}
}
}

View file

@ -0,0 +1,121 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import android.provider.CalendarContract
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.resource.LocalCalendar
import at.bitfire.ical4android.AndroidCalendar
import java.util.logging.Level
class CalendarsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = SyncAdapter(this)
protected class SyncAdapter(
context: Context
): SyncAdapterService.SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
updateLocalCalendars(provider, account, settings)
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
App.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
CalendarSyncManager(context, account, settings, extras, authority, syncResult, provider, calendar)
.performSync()
}
} catch(e: Exception) {
App.log.log(Level.SEVERE, "Couldn't sync calendars", e)
}
App.log.info("Calendar sync complete")
}
private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(ServiceDB.Services._TABLE, arrayOf(ServiceDB.Services.ID),
"${ServiceDB.Services.ACCOUNT_NAME}=? AND ${ServiceDB.Services.SERVICE}=?",
arrayOf(account.name, ServiceDB.Services.SERVICE_CARDDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local calendars
val service = getService()
val remote = remoteCalendars(service)
// delete/update local calendars
val updateColors = settings.getManageCalendarColors()
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
calendar.name?.let { url ->
val info = remote[url]
if (info == null) {
App.log.log(Level.INFO, "Deleting obsolete local calendar", url)
calendar.delete()
} else {
// remote CollectionInfo found for this local collection, update data
App.log.log(Level.FINE, "Updating local calendar $url", info)
calendar.update(info, updateColors)
// we already have a local calendar for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local calendars
for ((_, info) in remote) {
App.log.log(Level.INFO, "Adding local calendar", info)
LocalCalendar.create(account, provider, info)
}
}
}
}
}

View file

@ -1,84 +0,0 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.vcard4android.ContactsStorageException;
import lombok.Cleanup;
public class ContactsSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new ContactsSyncAdapter(this);
}
private static class ContactsSyncAdapter extends SyncAdapter {
public ContactsSyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, provider, syncResult);
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
LocalAddressBook addressBook = new LocalAddressBook(getContext(), account, provider);
AccountSettings settings = new AccountSettings(getContext(), addressBook.getMainAccount());
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
App.log.info("Synchronizing address book: " + addressBook.getURL());
App.log.info("Taking settings from: " + addressBook.getMainAccount());
ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, addressBook);
syncManager.performSync();
} catch(InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
} catch(ContactsStorageException e) {
App.log.log(Level.SEVERE, "Couldn't prepare local address books", e);
} finally {
dbHelper.close();
}
App.log.info("Contacts sync complete");
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentResolver
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.resource.LocalAddressBook
import java.util.logging.Level
class ContactsSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = ContactsSyncAdapter(this)
protected class ContactsSyncAdapter(
context: Context
): SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val addressBook = LocalAddressBook(context, account, provider)
val settings = AccountSettings(context, addressBook.getMainAccount())
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
App.log.info("Synchronizing address book: ${addressBook.getURL()}")
App.log.info("Taking settings from: ${addressBook.getMainAccount()}")
ContactsSyncManager(context, account, settings, extras, authority, syncResult, provider, addressBook)
.performSync()
} catch(e: Exception) {
App.log.log(Level.SEVERE, "Couldn't sync contacts", e)
}
App.log.info("Contacts sync complete")
}
}
}

View file

@ -1,540 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.ContentProviderClient;
import android.content.ContentProviderOperation;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.os.Build;
import android.os.Bundle;
import android.os.RemoteException;
import android.provider.ContactsContract;
import android.provider.ContactsContract.Groups;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import org.apache.commons.codec.Charsets;
import org.apache.commons.collections4.SetUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.dav4android.DavAddressBook;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.AddressData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.dav4android.property.SupportedAddressData;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalAddressBook;
import at.bitfire.davdroid.resource.LocalContact;
import at.bitfire.davdroid.resource.LocalGroup;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.BatchOperation;
import at.bitfire.vcard4android.Contact;
import at.bitfire.vcard4android.ContactsStorageException;
import at.bitfire.vcard4android.GroupMethod;
import ezvcard.VCardVersion;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.ResponseBody;
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
public class ContactsSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 10;
final private ContentProviderClient provider;
private boolean hasVCard4;
private GroupMethod groupMethod;
public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, LocalAddressBook localAddressBook) {
super(context, account, settings, extras, authority, result, "addressBook");
this.provider = provider;
localCollection = localAddressBook;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_CONTACTS_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_contacts, account.name);
}
@Override
protected boolean prepare() throws ContactsStorageException {
LocalAddressBook localAddressBook = localAddressBook();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
int reallyDirty = localAddressBook.verifyDirty(),
deleted = localAddressBook.getDeleted().size();
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
App.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed");
return false;
}
}
// set up Contacts Provider Settings
ContentValues values = new ContentValues(2);
values.put(ContactsContract.Settings.SHOULD_SYNC, 1);
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1);
localAddressBook.updateSettings(values);
collectionURL = HttpUrl.parse(localAddressBook.getURL());
davCollection = new DavAddressBook(httpClient, collectionURL);
return true;
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
// prepare remote address book
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME);
SupportedAddressData supportedAddressData = (SupportedAddressData)davCollection.getProperties().get(SupportedAddressData.NAME);
hasVCard4 = supportedAddressData != null && supportedAddressData.hasVCard4();
App.log.info("Server advertises VCard/4 support: " + hasVCard4);
groupMethod = settings.getGroupMethod();
App.log.info("Contact group method: " + groupMethod);
localAddressBook().setIncludeGroups(groupMethod == GroupMethod.GROUP_VCARDS);
}
@Override
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
super.prepareDirty();
LocalAddressBook addressBook = localAddressBook();
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (LocalGroup group : addressBook.getDeletedGroups()) {
App.log.fine("Finally removing group " + group);
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty();
group.delete();
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (LocalGroup group : addressBook.getDirtyGroups()) {
App.log.fine("Marking members of modified group " + group + " as dirty");
group.markMembersDirty();
group.clearDirty(null);
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
// mark groups with changed members as dirty
BatchOperation batch = new BatchOperation(addressBook.getProvider());
for (LocalContact contact : addressBook.getDirtyContacts())
try {
App.log.fine("Looking for changed group memberships of contact " + contact.getFileName());
Set<Long> cachedGroups = contact.getCachedGroupMemberships(),
currentGroups = contact.getGroupMemberships();
for (Long groupID : SetUtils.disjunction(cachedGroups, currentGroups)) {
App.log.fine("Marking group as dirty: " + groupID);
batch.enqueue(new BatchOperation.Operation(
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
));
}
} catch(FileNotFoundException ignored) {
}
batch.commit();
}
}
@Override
protected RequestBody prepareUpload(@NonNull LocalResource resource) throws IOException, ContactsStorageException {
final Contact contact;
if (resource instanceof LocalContact) {
LocalContact local = ((LocalContact)resource);
contact = local.getContact();
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (long groupID : local.getGroupMemberships()) {
try {
@Cleanup Cursor c = provider.query(
localAddressBook().syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
new String[] { Groups.TITLE },
null, null,
null
);
if (c != null && c.moveToNext()) {
String title = c.getString(0);
if (!TextUtils.isEmpty(title))
contact.getCategories().add(title);
}
} catch(RemoteException e) {
throw new ContactsStorageException("Couldn't find group for adding CATEGORIES", e);
}
}
}
} else if (resource instanceof LocalGroup)
contact = ((LocalGroup)resource).getContact();
else
throw new IllegalArgumentException("Argument must be LocalContact or LocalGroup");
App.log.log(Level.FINE, "Preparing upload of VCard " + resource.getFileName(), contact);
ByteArrayOutputStream os = new ByteArrayOutputStream();
contact.write(hasVCard4 ? VCardVersion.V4_0 : VCardVersion.V3_0, groupMethod, os);
return RequestBody.create(
hasVCard4 ? DavAddressBook.MIME_VCARD4 : DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
final DavAddressBook addressBook = davAddressBook();
currentDavResource = addressBook;
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME);
remoteResources = new HashMap<>(davCollection.getMembers().size());
for (DavResource vCard : davCollection.getMembers()) {
// ignore member collections
ResourceType type = (ResourceType)vCard.getProperties().get(ResourceType.NAME);
if (type != null && type.getTypes().contains(ResourceType.COLLECTION))
continue;
String fileName = vCard.fileName();
App.log.fine("Found remote VCard: " + fileName);
remoteResources.put(fileName, vCard);
}
currentDavResource = null;
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException {
App.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)");
// prepare downloader which may be used to download external resource like contact photos
Contact.Downloader downloader = new ResourceDownloader(collectionURL);
// download new/updated VCards from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5");
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
GetETag eTag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.getETag()))
throw new DavException("Received CardDAV GET response without ETag for " + remote.getLocation());
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVCard(remote.fileName(), eTag.getETag(), stream, charset, downloader);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.getLocation());
final DavAddressBook addressBook = davAddressBook();
currentDavResource = addressBook;
addressBook.multiget(urls.toArray(new HttpUrl[urls.size()]), hasVCard4);
// process multi-get results
for (final DavResource remote : davCollection.getMembers()) {
currentDavResource = remote;
String eTag;
GetETag getETag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (getETag != null)
eTag = getETag.getETag();
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType)remote.getProperties().get(GetContentType.NAME);
if (getContentType != null && getContentType.getType() != null) {
MediaType type = MediaType.parse(getContentType.getType());
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
AddressData addressData = (AddressData)remote.getProperties().get(AddressData.NAME);
if (addressData == null || addressData.getVCard() == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(addressData.getVCard().getBytes());
processVCard(remote.fileName(), eTag, stream, charset, downloader);
}
}
currentDavResource = null;
}
}
@Override
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
if (groupMethod == GroupMethod.CATEGORIES) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// remove empty groups
App.log.info("Removing empty groups");
localAddressBook().removeEmptyGroups();
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
App.log.info("Assigning memberships of downloaded contact groups");
LocalGroup.applyPendingMemberships(localAddressBook());
}
}
// helpers
private DavAddressBook davAddressBook() { return (DavAddressBook)davCollection; }
private LocalAddressBook localAddressBook() { return (LocalAddressBook)localCollection; }
private void processVCard(String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException {
App.log.info("Processing CardDAV resource " + fileName);
List<Contact> contacts = Contact.fromStream(stream, charset, downloader);
if (contacts.size() == 0) {
App.log.warning("Received VCard without data, ignoring");
return;
} else if (contacts.size() > 1)
App.log.warning("Received multiple VCards, using first one");
final Contact newData = contacts.get(0);
if (groupMethod == GroupMethod.CATEGORIES && newData.getGroup()) {
groupMethod = GroupMethod.GROUP_VCARDS;
App.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: " + groupMethod);
localAddressBook().removeGroups();
settings.setGroupMethod(groupMethod);
}
// update local contact, if it exists
LocalResource local = localResources.get(fileName);
currentLocalResource = local;
if (local != null) {
App.log.log(Level.INFO, "Updating " + fileName + " in local address book", newData);
if (local instanceof LocalGroup && newData.getGroup()) {
// update group
LocalGroup group = (LocalGroup)local;
group.setETag(eTag);
group.updateFromServer(newData);
syncResult.stats.numUpdates++;
} else if (local instanceof LocalContact && !newData.getGroup()) {
// update contact
LocalContact contact = (LocalContact)local;
contact.setETag(eTag);
contact.update(newData);
syncResult.stats.numUpdates++;
} else {
// group has become an individual contact or vice versa
try {
local.delete();
local = null;
} catch(CalendarStorageException e) {
// CalendarStorageException is not used by LocalGroup and LocalContact
}
}
}
if (local == null) {
if (newData.getGroup()) {
App.log.log(Level.INFO, "Creating local group", newData);
LocalGroup group = new LocalGroup(localAddressBook(), newData, fileName, eTag);
currentLocalResource = group;
group.create();
local = group;
} else {
App.log.log(Level.INFO, "Creating local contact", newData);
LocalContact contact = new LocalContact(localAddressBook(), newData, fileName, eTag);
currentLocalResource = contact;
contact.create();
local = contact;
}
syncResult.stats.numInserts++;
}
if (groupMethod == GroupMethod.CATEGORIES && local instanceof LocalContact) {
// VCard3: update group memberships from CATEGORIES
LocalContact contact = (LocalContact)local;
currentLocalResource = contact;
BatchOperation batch = new BatchOperation(provider);
App.log.log(Level.FINE, "Removing contact group memberships");
contact.removeGroupMemberships(batch);
for (String category : contact.getContact().getCategories()) {
long groupID = localAddressBook().findOrCreateGroup(category);
App.log.log(Level.FINE, "Adding membership in group " + category + " (" + groupID + ")");
contact.addToGroup(batch, groupID);
}
batch.commit();
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local instanceof LocalContact)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
((LocalContact)local).updateHashCode(null);
currentLocalResource = null;
}
// downloader helper class
@RequiredArgsConstructor
private class ResourceDownloader implements Contact.Downloader {
final HttpUrl baseUrl;
@Override
public byte[] download(String url, String accepts) {
HttpUrl httpUrl = HttpUrl.parse(url);
if (httpUrl == null) {
App.log.log(Level.SEVERE, "Invalid external resource URL", url);
return null;
}
String host = httpUrl.host();
if (host == null) {
App.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url);
return null;
}
OkHttpClient resourceClient = HttpClient.create(context);
// authenticate only against a certain host, and only upon request
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password());
// allow redirects
resourceClient = resourceClient.newBuilder()
.followRedirects(true)
.build();
try {
Response response = resourceClient.newCall(new Request.Builder()
.get()
.url(httpUrl)
.build()).execute();
ResponseBody body = response.body();
if (body != null) {
@Cleanup InputStream stream = body.byteStream();
if (response.isSuccessful() && stream != null) {
return IOUtils.toByteArray(stream);
} else
App.log.severe("Couldn't download external resource");
}
} catch(IOException e) {
App.log.log(Level.SEVERE, "Couldn't download external resource", e);
}
return null;
}
}
}

View file

@ -0,0 +1,454 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.os.Build
import android.os.Bundle
import android.os.RemoteException
import android.provider.ContactsContract
import android.provider.ContactsContract.Groups
import at.bitfire.dav4android.DavAddressBook
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.*
import at.bitfire.davdroid.*
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalContact
import at.bitfire.davdroid.resource.LocalGroup
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.vcard4android.BatchOperation
import at.bitfire.vcard4android.Contact
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import ezvcard.VCardVersion
import okhttp3.HttpUrl
import okhttp3.Request
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.*
import java.util.*
import java.util.logging.Level
/**
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
*
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
* handle/manage groups:</p>
* <ul>
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
* "category". When a group is dirty or has been deleted, all its members have to be set to
* dirty, too (because they have to be uploaded without the respective category). This
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
* which is done in {@link #postProcess()} because groups may become empty after downloading
* updated remoted contacts.</li>
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
* <ol>
* <li>However, when a contact is dirty, it has
* to be checked whether its group memberships have changed. In this case, the respective
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
* group membership of G is removed, the contact will be set to dirty because of the changed
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
* then have to check whether the group memberships have actually changed, and if so,
* all affected groups have to be set to dirty. To detect changes in group memberships,
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
* If the cached group memberships are not the same as the current group member ships, the
* difference set (in our example G, because its in the cached memberships, but not in the
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
* <li>When downloading remote contacts, groups (+ member information) may be received
* by the actual members. Thus, the member lists have to be cached until all VCards
* are received. This is done by caching the member UIDs of each group in
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
* </ol>
* </ul>
*/
class ContactsSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: ContentProviderClient,
val localAddressBook: LocalAddressBook
): SyncManager(context, account, settings, extras, authority, syncResult, "addressBook") {
val MAX_MULTIGET = 10
private var hasVCard4 = false
private lateinit var groupMethod: GroupMethod
init {
localCollection = localAddressBook
}
override fun notificationId() = Constants.NOTIFICATION_CONTACTS_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_contacts, account.name)!!
override fun prepare(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
val reallyDirty = localAddressBook.verifyDirty()
val deleted = localAddressBook.getDeleted().size
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
App.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
return false
}
}
// set up Contacts Provider Settings
val values = ContentValues(2)
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
localAddressBook.updateSettings(values)
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
davCollection = DavAddressBook(httpClient, collectionURL)
return true
}
override fun queryCapabilities() {
// prepare remote address book
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME)
(davCollection.properties[SupportedAddressData.NAME] as SupportedAddressData?)?.let {
hasVCard4 = it.hasVCard4()
}
App.log.info("Server advertises VCard/4 support: $hasVCard4")
groupMethod = settings.getGroupMethod()
App.log.info("Contact group method: $groupMethod")
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
}
override fun prepareDirty() {
super.prepareDirty()
if (groupMethod == GroupMethod.CATEGORIES) {
/* groups memberships are represented as contact CATEGORIES */
// groups with DELETED=1: set all members to dirty, then remove group
for (group in localAddressBook.getDeletedGroups()) {
App.log.fine("Finally removing group $group")
// useless because Android deletes group memberships as soon as a group is set to DELETED:
// group.markMembersDirty()
group.delete()
}
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
for (group in localAddressBook.getDirtyGroups()) {
App.log.fine("Marking members of modified group $group as dirty")
group.markMembersDirty()
group.clearDirty(null)
}
} else {
/* groups as separate VCards: there are group contacts and individual contacts */
// mark groups with changed members as dirty
val batch = BatchOperation(localAddressBook.provider!!)
for (contact in localAddressBook.getDirtyContacts())
try {
App.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
val cachedGroups = contact.getCachedGroupMemberships()
val currentGroups = contact.getGroupMemberships()
for (groupID in cachedGroups.minus(currentGroups)) {
App.log.fine("Marking group as dirty: $groupID")
batch.enqueue(BatchOperation.Operation(
ContentProviderOperation.newUpdate(localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
.withValue(Groups.DIRTY, 1)
.withYieldAllowed(true)
))
}
} catch(e: FileNotFoundException) {
}
batch.commit()
}
}
override fun prepareUpload(resource: LocalResource): RequestBody {
val contact: Contact
if (resource is LocalContact) {
contact = resource.contact!!
if (groupMethod == GroupMethod.CATEGORIES) {
// add groups as CATEGORIES
for (groupID in resource.getGroupMemberships()) {
try {
provider.query(
localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
arrayOf(Groups.TITLE), null, null, null
)?.use { cursor ->
if (cursor.moveToNext()) {
val title = cursor.getString(0)
if (!title.isNullOrEmpty())
contact.categories.add(title)
}
}
} catch(e: RemoteException) {
throw ContactsStorageException("Couldn't find group for adding CATEGORIES", e)
}
}
}
} else if (resource is LocalGroup)
contact = resource.contact!!
else
throw IllegalArgumentException("resource must be a LocalContact or a LocalGroup")
App.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact)
val os = ByteArrayOutputStream()
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
return RequestBody.create(
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
os.toByteArray()
)
}
override fun listRemote() {
val addressBook = davAddressBook()
currentDavResource = addressBook
// fetch list of remote VCards and build hash table to index file name
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (vCard in davCollection.members) {
// ignore member collections
val type = vCard.properties[ResourceType.NAME] as ResourceType?
if (type != null && type.types.contains(ResourceType.COLLECTION))
continue
val fileName = vCard.fileName()
App.log.fine("Found remote VCard: $fileName")
remoteResources[fileName] = vCard
}
currentDavResource = null
}
override fun downloadRemote() {
App.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
// prepare downloader which may be used to download external resource like contact photos
val downloader = ResourceDownloader(collectionURL)
// download new/updated VCards from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
App.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVCard(remote.fileName(), eTag.eTag!!, reader, downloader)
}
} else {
// multiple contacts, use multi-get
val addressBook = davAddressBook()
currentDavResource = addressBook
addressBook.multiget(bunch.map { it.location }, hasVCard4)
// process multi-get results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val addressData = remote.properties[AddressData.NAME] as AddressData?
val vCard = addressData?.vCard
?: throw DavException("Received multi-get response without address data")
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
}
}
currentDavResource = null
}
}
override fun postProcess() {
if (groupMethod == GroupMethod.CATEGORIES) {
/* VCard3 group handling: groups memberships are represented as contact CATEGORIES */
// remove empty groups
App.log.info("Removing empty groups")
localAddressBook.removeEmptyGroups()
} else {
/* VCard4 group handling: there are group contacts and individual contacts */
App.log.info("Assigning memberships of downloaded contact groups")
LocalGroup.applyPendingMemberships(localAddressBook)
}
}
// helpers
private fun davAddressBook() = davCollection as DavAddressBook
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
App.log.info("Processing CardDAV resource $fileName")
val contacts = Contact.fromReader(reader, downloader)
if (contacts.isEmpty()) {
App.log.warning("Received VCard without data, ignoring")
return
} else if (contacts.size > 1)
App.log.warning("Received multiple VCards, using first one")
val newData = contacts.first()
if (groupMethod == GroupMethod.CATEGORIES && newData.group) {
groupMethod = GroupMethod.GROUP_VCARDS
App.log.warning("Received group VCard although group method is CATEGORIES. Deleting all groups; new group method: $groupMethod")
localAddressBook.removeGroups()
settings.setGroupMethod(groupMethod)
}
// update local contact, if it exists
var local = localResources[fileName]
currentLocalResource = local
if (local != null) {
App.log.log(Level.INFO, "Updating $fileName in local address book", newData)
if (local is LocalGroup && newData.group) {
// update group
local.eTag = eTag
local.updateFromServer(newData)
syncResult.stats.numUpdates++
} else if (local is LocalContact && !newData.group) {
// update contact
local.eTag = eTag
local.update(newData)
syncResult.stats.numUpdates++
} else {
// group has become an individual contact or vice versa
local.delete()
local = null
}
}
if (local == null) {
if (newData.group) {
App.log.log(Level.INFO, "Creating local group", newData)
val group = LocalGroup(localAddressBook, newData, fileName, eTag)
currentLocalResource = group
group.create()
local = group
} else {
App.log.log(Level.INFO, "Creating local contact", newData)
val contact = LocalContact(localAddressBook, newData, fileName, eTag)
currentLocalResource = contact
contact.create()
local = contact
}
syncResult.stats.numInserts++
}
if (groupMethod == GroupMethod.CATEGORIES && local is LocalContact) {
// VCard3: update group memberships from CATEGORIES
currentLocalResource = local
val batch = BatchOperation(provider)
App.log.log(Level.FINE, "Removing contact group memberships")
local.removeGroupMemberships(batch)
for (category in local.contact!!.categories) {
val groupID = localAddressBook.findOrCreateGroup(category)
App.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
local.addToGroup(batch, groupID)
}
batch.commit()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local is LocalContact)
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
local.updateHashCode(null)
currentLocalResource = null
}
// downloader helper class
private inner class ResourceDownloader(
val baseUrl: HttpUrl
): Contact.Downloader {
override fun download(url: String, accepts: String): ByteArray? {
val httpUrl = HttpUrl.parse(url)
if (httpUrl == null) {
App.log.log(Level.SEVERE, "Invalid external resource URL", url)
return null
}
val host = httpUrl.host()
if (host == null) {
App.log.log(Level.SEVERE, "External resource URL doesn't specify a host name", url)
return null
}
var resourceClient = HttpClient.create(context)
// authenticate only against a certain host, and only upon request
val username = settings.username()
val password = settings.password()
if (username != null && password != null)
resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), username, password)
// allow redirects
resourceClient = resourceClient.newBuilder()
.followRedirects(true)
.build()
try {
val response = resourceClient.newCall(Request.Builder()
.get()
.url(httpUrl)
.build()).execute()
if (response.isSuccessful)
return response.body()?.bytes()
else
App.log.warning("Couldn't download external resource")
} catch(e: IOException) {
App.log.log(Level.SEVERE, "Couldn't download external resource", e)
}
return null
}
}
}

View file

@ -1,90 +0,0 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter;
import android.accounts.AbstractAccountAuthenticator;
import android.accounts.Account;
import android.accounts.AccountAuthenticatorResponse;
import android.accounts.AccountManager;
import android.accounts.NetworkErrorException;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.IBinder;
import at.bitfire.davdroid.ui.AccountsActivity;
public class NullAuthenticatorService extends Service {
private AccountAuthenticator accountAuthenticator;
@Override
public void onCreate() {
accountAuthenticator = new NullAuthenticatorService.AccountAuthenticator(this);
}
@Override
public IBinder onBind(Intent intent) {
if (intent.getAction().equals(android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT))
return accountAuthenticator.getIBinder();
return null;
}
private static class AccountAuthenticator extends AbstractAccountAuthenticator {
final Context context;
public AccountAuthenticator(Context context) {
super(context);
this.context = context;
}
@Override
public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
return null;
}
@Override
public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException {
Intent intent = new Intent(context, AccountsActivity.class);
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, intent);
return bundle;
}
@Override
public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public String getAuthTokenLabel(String authTokenType) {
return null;
}
@Override
public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {
return null;
}
@Override
public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException {
return null;
}
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright © 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 at.bitfire.davdroid.syncadapter
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Bundle
import at.bitfire.davdroid.ui.AccountsActivity
class NullAuthenticatorService: Service() {
private lateinit var accountAuthenticator: AccountAuthenticator
override fun onCreate() {
accountAuthenticator = AccountAuthenticator(this)
}
override fun onBind(intent: Intent?) =
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
private class AccountAuthenticator(
val context: Context
): AbstractAccountAuthenticator(context) {
override fun addAccount(response: AccountAuthenticatorResponse?, accountType: String?, authTokenType: String?, requiredFeatures: Array<String>, options: Bundle): Bundle {
val intent = Intent(context, AccountsActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle(1)
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?) = null
override fun getAuthTokenLabel(p0: String?) = null
override fun confirmCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Bundle?) = null
override fun updateCredentials(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun getAuthToken(p0: AccountAuthenticatorResponse?, p1: Account?, p2: String?, p3: Bundle?) = null
override fun hasFeatures(p0: AccountAuthenticatorResponse?, p1: Account?, p2: Array<out String>?) = null
}
}

View file

@ -1,115 +0,0 @@
/*
* 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Bundle;
import android.os.IBinder;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.ui.PermissionsActivity;
//import com.android.vending.billing.IInAppBillingService;
public abstract class SyncAdapterService extends Service {
abstract protected AbstractThreadedSyncAdapter syncAdapter();
@Nullable
@Override
public IBinder onBind(Intent intent) {
return syncAdapter().getSyncAdapterBinder();
}
public static abstract class SyncAdapter extends AbstractThreadedSyncAdapter {
public SyncAdapter(Context context) {
super(context, false);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
App.log.log(Level.INFO, authority + " sync of " + account + " has been initiated.", extras.keySet().toArray());
// required for dav4android (ServiceLoader)
Thread.currentThread().setContextClassLoader(getContext().getClassLoader());
}
@Override
public void onSecurityException(Account account, Bundle extras, String authority, SyncResult syncResult) {
App.log.log(Level.WARNING, "Security exception when opening content provider for " + authority);
syncResult.databaseError = true;
Intent intent = new Intent(getContext(), PermissionsActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
Notification notify = new NotificationCompat.Builder(getContext())
.setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(getContext()))
.setContentTitle(getContext().getString(R.string.sync_error_permissions))
.setContentText(getContext().getString(R.string.sync_error_permissions_text))
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build();
NotificationManagerCompat nm = NotificationManagerCompat.from(getContext());
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify);
}
protected boolean checkSyncConditions(@NonNull AccountSettings settings) {
if (settings.getSyncWifiOnly()) {
ConnectivityManager cm = (ConnectivityManager)getContext().getSystemService(CONNECTIVITY_SERVICE);
NetworkInfo network = cm.getActiveNetworkInfo();
if (network == null) {
App.log.info("No network available, stopping");
return false;
}
if (network.getType() != ConnectivityManager.TYPE_WIFI || !network.isConnected()) {
App.log.info("Not on connected WiFi, stopping");
return false;
}
String onlySSID = settings.getSyncWifiOnlySSID();
if (onlySSID != null) {
onlySSID = "\"" + onlySSID + "\"";
WifiManager wifi = (WifiManager)getContext().getApplicationContext().getSystemService(WIFI_SERVICE);
WifiInfo info = wifi.getConnectionInfo();
if (info == null || !onlySSID.equals(info.getSSID())) {
App.log.info("Connected to wrong WiFi network (" + info.getSSID() + ", required: " + onlySSID + "), ignoring");
return false;
}
}
}
return true;
}
}
}

View file

@ -0,0 +1,95 @@
/*
* 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.PendingIntent
import android.app.Service
import android.content.*
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Bundle
import android.support.v4.app.NotificationCompat
import android.support.v4.app.NotificationManagerCompat
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.PermissionsActivity
import java.util.*
import java.util.logging.Level
abstract class SyncAdapterService: Service() {
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
abstract class SyncAdapter(
context: Context
): AbstractThreadedSyncAdapter(context, false) {
abstract fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult)
override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
App.log.log(Level.INFO, "$authority sync of $account has been initiated", extras.keySet().joinToString(", "))
// required for dav4android (ServiceLoader)
Thread.currentThread().contextClassLoader = context.classLoader
sync(account, extras, authority, provider, syncResult)
App.log.info("Sync for $authority complete")
}
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
App.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 = NotificationCompat.Builder(context)
.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))
.setCategory(NotificationCompat.CATEGORY_ERROR)
.build()
val nm = NotificationManagerCompat.from(context)
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
}
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
if (settings.getSyncWifiOnly()) {
val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetworkInfo
if (network == null || network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) {
App.log.info("Not on connected WiFi, stopping")
return false
}
settings.getSyncWifiOnlySSID()?.let { onlySSID ->
val quotedSSID = "\"$onlySSID\""
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val info = wifi.connectionInfo
if (info == null || info.ssid != quotedSSID) {
App.log.info("Connected to wrong WiFi network (${info.ssid}, required: $quotedSSID), ignoring")
return false
}
}
}
return true
}
}
}

View file

@ -1,484 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.annotation.TargetApi;
import android.app.PendingIntent;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.SyncResult;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v7.app.NotificationCompat;
import android.text.TextUtils;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.ConflictException;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.exception.PreconditionFailedException;
import at.bitfire.dav4android.exception.ServiceUnavailableException;
import at.bitfire.dav4android.exception.UnauthorizedException;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalCollection;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.ui.AccountSettingsActivity;
import at.bitfire.davdroid.ui.DebugInfoActivity;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.vcard4android.ContactsStorageException;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
abstract public class SyncManager {
protected final int SYNC_PHASE_PREPARE = 0,
SYNC_PHASE_QUERY_CAPABILITIES = 1,
SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2,
SYNC_PHASE_PREPARE_DIRTY = 3,
SYNC_PHASE_UPLOAD_DIRTY = 4,
SYNC_PHASE_CHECK_SYNC_STATE = 5,
SYNC_PHASE_LIST_LOCAL = 6,
SYNC_PHASE_LIST_REMOTE = 7,
SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8,
SYNC_PHASE_DOWNLOAD_REMOTE = 9,
SYNC_PHASE_POST_PROCESSING = 10,
SYNC_PHASE_SAVE_SYNC_STATE = 11;
protected final NotificationManagerCompat notificationManager;
protected final String uniqueCollectionId;
protected final Context context;
protected final Account account;
protected final Bundle extras;
protected final String authority;
protected final SyncResult syncResult;
protected final AccountSettings settings;
protected LocalCollection localCollection;
protected OkHttpClient httpClient;
protected HttpUrl collectionURL;
protected DavResource davCollection;
/** state information for debug info (local resource) */
protected LocalResource currentLocalResource;
/** state information for debug info (remote resource) */
protected DavResource currentDavResource;
/** remote CTag at the time of {@link #listRemote()} */
protected String remoteCTag = null;
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
protected Map<String, LocalResource> localResources;
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected Map<String, DavResource> remoteResources;
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected Set<DavResource> toDownload;
public SyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String uniqueCollectionId) {
this.context = context;
this.account = account;
this.settings = settings;
this.extras = extras;
this.authority = authority;
this.syncResult = syncResult;
// create HttpClient with given logger
httpClient = HttpClient.create(context, settings);
// dismiss previous error notifications
this.uniqueCollectionId = uniqueCollectionId;
notificationManager = NotificationManagerCompat.from(context);
notificationManager.cancel(uniqueCollectionId, notificationId());
}
protected abstract int notificationId();
protected abstract String getSyncErrorTitle();
@TargetApi(21)
public void performSync() {
int syncPhase = SYNC_PHASE_PREPARE;
try {
App.log.info("Preparing synchronization");
if (!prepare()) {
App.log.info("No reason to synchronize, aborting");
return;
}
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES;
App.log.info("Querying capabilities");
queryCapabilities();
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED;
App.log.info("Processing locally deleted entries");
processLocallyDeleted();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_PREPARE_DIRTY;
App.log.info("Locally preparing dirty entries");
prepareDirty();
syncPhase = SYNC_PHASE_UPLOAD_DIRTY;
App.log.info("Uploading dirty entries");
uploadDirty();
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE;
App.log.info("Checking sync state");
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL;
App.log.info("Listing local entries");
listLocal();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_LIST_REMOTE;
App.log.info("Listing remote entries");
listRemote();
if (Thread.interrupted())
return;
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE;
App.log.info("Comparing local/remote entries");
compareLocalRemote();
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE;
App.log.info("Downloading remote entries");
downloadRemote();
syncPhase = SYNC_PHASE_POST_PROCESSING;
App.log.info("Post-processing");
postProcess();
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE;
App.log.info("Saving sync state");
saveSyncState();
} else
App.log.info("Remote collection didn't change, skipping remote sync");
} catch(IOException|ServiceUnavailableException e) {
App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e);
syncResult.stats.numIoExceptions++;
if (e instanceof ServiceUnavailableException) {
Date retryAfter = ((ServiceUnavailableException) e).getRetryAfter();
if (retryAfter != null) {
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.getTime() - new Date().getTime()) / 1000;
}
}
} catch(Exception|OutOfMemoryError e) {
final int messageString;
if (e instanceof UnauthorizedException) {
App.log.log(Level.SEVERE, "Not authorized anymore", e);
messageString = R.string.sync_error_unauthorized;
syncResult.stats.numAuthExceptions++;
} else if (e instanceof HttpException || e instanceof DavException) {
App.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e);
messageString = R.string.sync_error_http_dav;
syncResult.stats.numParseExceptions++;
} else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) {
App.log.log(Level.SEVERE, "Couldn't access local storage", e);
messageString = R.string.sync_error_local_storage;
syncResult.databaseError = true;
} else {
App.log.log(Level.SEVERE, "Unknown sync error", e);
messageString = R.string.sync_error;
syncResult.stats.numParseExceptions++;
}
final Intent detailsIntent;
if (e instanceof UnauthorizedException) {
detailsIntent = new Intent(context, AccountSettingsActivity.class);
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account);
} else {
detailsIntent = new Intent(context, DebugInfoActivity.class);
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e);
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account);
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority);
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase);
if (currentLocalResource != null)
detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, currentLocalResource.toString());
if (currentDavResource != null)
detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, currentDavResource.toString());
}
// to make the PendingIntent unique
detailsIntent.setData(Uri.parse("uri://" + getClass().getName() + "/" + uniqueCollectionId));
NotificationCompat.Builder builder = new NotificationCompat.Builder(context);
builder .setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR);
try {
String[] phases = context.getResources().getStringArray(R.array.sync_error_phases);
String message = context.getString(messageString, phases[syncPhase]);
builder.setContentText(message);
} catch (IndexOutOfBoundsException ex) {
// should never happen
}
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build());
}
}
/** Prepares synchronization (for instance, allocates necessary resources).
* @return whether actual synchronization is required / can be made. true = synchronization
* shall be continued, false = synchronization can be skipped */
abstract protected boolean prepare() throws ContactsStorageException;
abstract protected void queryCapabilities() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException;
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void processLocallyDeleted() throws CalendarStorageException, ContactsStorageException {
// 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.
List<LocalResource> localList = localCollection.getDeleted();
for (final LocalResource local : localList) {
if (Thread.interrupted())
return;
currentLocalResource = local;
final String fileName = local.getFileName();
if (!TextUtils.isEmpty(fileName)) {
App.log.info(fileName + " has been deleted locally -> deleting from server");
final DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
currentDavResource = remote;
try {
remote.delete(local.getETag());
} catch (IOException|HttpException e) {
App.log.warning("Couldn't delete " + fileName + " from server; ignoring (may be downloaded again)");
}
} else
App.log.info("Removing local record #" + local.getId() + " which has been deleted locally and was never uploaded");
local.delete();
syncResult.stats.numDeletes++;
currentLocalResource = null;
currentDavResource = null;
}
}
protected void prepareDirty() throws CalendarStorageException, ContactsStorageException {
// assign file names and UIDs to new contacts so that we can use the file name as an index
App.log.info("Looking for contacts/groups without file name");
for (final LocalResource local : (Iterable<LocalResource>)localCollection.getWithoutFileName()) {
currentLocalResource = local;
App.log.fine("Found local record #" + local.getId() + " without file name; generating file name/UID if necessary");
local.prepareForUpload();
currentLocalResource = null;
}
}
abstract protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException, ContactsStorageException;
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected void uploadDirty() throws IOException, HttpException, CalendarStorageException, ContactsStorageException {
// upload dirty contacts
for (final LocalResource local : (Iterable<LocalResource>)localCollection.getDirty()) {
if (Thread.interrupted())
return;
currentLocalResource = local;
final String fileName = local.getFileName();
final DavResource remote = new DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build());
currentDavResource = remote;
// generate entity to upload (VCard, iCal, whatever)
RequestBody body = prepareUpload(local);
try {
if (local.getETag() == null) {
App.log.info("Uploading new record " + fileName);
remote.put(body, null, true);
} else {
App.log.info("Uploading locally modified record " + fileName);
remote.put(body, local.getETag(), false);
}
} catch (ConflictException|PreconditionFailedException e) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e);
}
String eTag = null;
GetETag newETag = (GetETag) remote.getProperties().get(GetETag.NAME);
if (newETag != null) {
eTag = newETag.getETag();
App.log.fine("Received new ETag=" + eTag + " after uploading");
} else
App.log.fine("Didn't receive new ETag after uploading, setting to null");
local.clearDirty(eTag);
currentLocalResource = null;
currentDavResource = null;
}
}
/**
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
* @return <ul>
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
* <li><code>false</code> if the remote collection hasn't changed</li>
* </ul>
*/
protected boolean checkSyncState() throws CalendarStorageException, ContactsStorageException {
// check CTag (ignore on manual sync)
GetCTag getCTag = (GetCTag)davCollection.getProperties().get(GetCTag.NAME);
if (getCTag != null)
remoteCTag = getCTag.getCTag();
String localCTag = null;
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL))
App.log.info("Manual sync, ignoring CTag");
else
localCTag = localCollection.getCTag();
if (remoteCTag != null && remoteCTag.equals(localCTag)) {
App.log.info("Remote collection didn't change (CTag=" + remoteCTag + "), no need to query children");
return false;
} else
return true;
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected void listLocal() throws CalendarStorageException, ContactsStorageException {
// fetch list of local contacts and build hash table to index file name
List<LocalResource> localList = localCollection.getAll();
localResources = new HashMap<>(localList.size());
for (LocalResource resource : localList) {
App.log.fine("Found local resource: " + resource.getFileName());
localResources.put(resource.getFileName(), resource);
}
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected void listRemote() throws IOException, HttpException, DavException;
/**
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
* <ul>
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
* </ul>
*/
protected void compareLocalRemote() throws IOException, HttpException, DavException, CalendarStorageException, ContactsStorageException {
/* check which contacts
1. are not present anymore remotely -> delete immediately on local side
2. updated remotely -> add to downloadNames
3. added remotely -> add to downloadNames
*/
toDownload = new HashSet<>();
for (String localName : localResources.keySet()) {
final DavResource remote = remoteResources.get(localName);
currentDavResource = remote;
if (remote == null) {
App.log.info(localName + " is not on server anymore, deleting");
final LocalResource local = localResources.get(localName);
currentLocalResource = local;
local.delete();
syncResult.stats.numDeletes++;
} else {
// contact is still on server, check whether it has been updated remotely
GetETag getETag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (getETag == null || getETag.getETag() == null)
throw new DavException("Server didn't provide ETag");
String localETag = localResources.get(localName).getETag(),
remoteETag = getETag.getETag();
if (remoteETag.equals(localETag))
syncResult.stats.numSkippedEntries++;
else {
App.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")");
toDownload.add(remote);
}
// remote entry has been seen, remove from list
remoteResources.remove(localName);
currentDavResource = null;
currentLocalResource = null;
}
}
// add all unseen (= remotely added) remote contacts
if (!remoteResources.isEmpty()) {
App.log.info("New resources have been found on the server: " + TextUtils.join(", ", remoteResources.keySet()));
toDownload.addAll(remoteResources.values());
}
}
/**
* Downloads the remote resources in {@link #toDownload} and stores them locally.
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
*/
abstract protected void downloadRemote() throws IOException, HttpException, DavException, ContactsStorageException, CalendarStorageException;
/**
* For post-processing of entries, for instance assigning groups.
*/
protected void postProcess() throws CalendarStorageException, ContactsStorageException {
}
protected void saveSyncState() throws CalendarStorageException, ContactsStorageException {
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
(for instance, because another client has uploaded changes), because this will simply
cause all remote entries to be listed at the next sync. */
App.log.info("Saving CTag=" + remoteCTag);
localCollection.setCTag(remoteCTag);
}
}

View file

@ -0,0 +1,456 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.app.PendingIntent
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.content.SyncResult
import android.net.Uri
import android.os.Bundle
import android.support.v4.app.NotificationManagerCompat
import android.support.v7.app.NotificationCompat
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.*
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.HttpClient
import at.bitfire.davdroid.R
import at.bitfire.davdroid.resource.LocalCollection
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.ui.AccountSettingsActivity
import at.bitfire.davdroid.ui.DebugInfoActivity
import at.bitfire.ical4android.CalendarStorageException
import at.bitfire.vcard4android.ContactsStorageException
import okhttp3.HttpUrl
import okhttp3.RequestBody
import java.io.IOException
import java.util.*
import java.util.logging.Level
abstract class SyncManager(
val context: Context,
val account: Account,
val settings: AccountSettings,
val extras: Bundle,
val authority: String,
val syncResult: SyncResult,
val uniqueCollectionId: String
) {
val SYNC_PHASE_PREPARE = 0
val SYNC_PHASE_QUERY_CAPABILITIES = 1
val SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2
val SYNC_PHASE_PREPARE_DIRTY = 3
val SYNC_PHASE_UPLOAD_DIRTY = 4
val SYNC_PHASE_CHECK_SYNC_STATE = 5
val SYNC_PHASE_LIST_LOCAL = 6
val SYNC_PHASE_LIST_REMOTE = 7
val SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8
val SYNC_PHASE_DOWNLOAD_REMOTE = 9
val SYNC_PHASE_POST_PROCESSING = 10
val SYNC_PHASE_SAVE_SYNC_STATE = 11
protected val notificationManager = NotificationManagerCompat.from(context)!!
protected lateinit var localCollection: LocalCollection<*>
protected val httpClient = HttpClient.create(context, settings)
protected lateinit var collectionURL: HttpUrl
protected lateinit var davCollection: DavResource
/** state information for debug info (local resource) */
protected var currentLocalResource: LocalResource? = null
/** state information for debug info (remote resource) */
protected var currentDavResource: DavResource? = null
/** remote CTag at the time of {@link #listRemote()} */
protected var remoteCTag: String? = null
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
protected lateinit var localResources: MutableMap<String, LocalResource>
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
protected lateinit var remoteResources: MutableMap<String, DavResource>
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
protected val toDownload = mutableSetOf<DavResource>()
protected abstract fun notificationId(): Int
protected abstract fun getSyncErrorTitle(): String
fun performSync() {
// dismiss previous error notifications
notificationManager.cancel(uniqueCollectionId, notificationId())
var syncPhase = SYNC_PHASE_PREPARE
try {
App.log.info("Preparing synchronization")
if (!prepare()) {
App.log.info("No reason to synchronize, aborting")
return
}
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES
App.log.info("Querying capabilities")
queryCapabilities()
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED
App.log.info("Processing locally deleted entries")
processLocallyDeleted()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_PREPARE_DIRTY
App.log.info("Locally preparing dirty entries")
prepareDirty()
syncPhase = SYNC_PHASE_UPLOAD_DIRTY
App.log.info("Uploading dirty entries")
uploadDirty()
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE
App.log.info("Checking sync state")
if (checkSyncState()) {
syncPhase = SYNC_PHASE_LIST_LOCAL
App.log.info("Listing local entries")
listLocal()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_LIST_REMOTE
App.log.info("Listing remote entries")
listRemote()
if (Thread.interrupted())
return
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE
App.log.info("Comparing local/remote entries")
compareLocalRemote()
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE
App.log.info("Downloading remote entries")
downloadRemote()
syncPhase = SYNC_PHASE_POST_PROCESSING
App.log.info("Post-processing")
postProcess()
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE
App.log.info("Saving sync state")
saveSyncState()
} else
App.log.info("Remote collection didn't change, skipping remote sync")
} catch(e: IOException) {
App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e)
syncResult.stats.numIoExceptions++
} catch(e: ServiceUnavailableException) {
App.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
syncResult.stats.numIoExceptions++
e.retryAfter?.let { retryAfter ->
// how many seconds to wait? getTime() returns ms, so divide by 1000
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
}
} catch(e: Throwable) {
val messageString: Int
when (e) {
is UnauthorizedException -> {
App.log.log(Level.SEVERE, "Not authorized anymore", e)
messageString = R.string.sync_error_unauthorized
syncResult.stats.numAuthExceptions++
}
is HttpException, is DavException -> {
App.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e)
messageString = R.string.sync_error_http_dav
syncResult.stats.numParseExceptions++
}
is CalendarStorageException, is ContactsStorageException -> {
App.log.log(Level.SEVERE, "Couldn't access local storage", e)
messageString = R.string.sync_error_local_storage
syncResult.databaseError = true
}
else -> {
App.log.log(Level.SEVERE, "Unknown sync error", e)
messageString = R.string.sync_error
syncResult.stats.numParseExceptions++
}
}
val detailsIntent: Intent
if (e is UnauthorizedException) {
detailsIntent = Intent(context, AccountSettingsActivity::class.java)
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
} else {
detailsIntent = Intent(context, DebugInfoActivity::class.java)
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
currentLocalResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, it.toString()) }
currentDavResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, it.toString()) }
}
// to make the PendingIntent unique
detailsIntent.data = Uri.parse("uri://${javaClass.name}/$uniqueCollectionId")
val builder = NotificationCompat.Builder(context)
builder .setSmallIcon(R.drawable.ic_error_light)
.setLargeIcon(App.getLauncherBitmap(context))
.setContentTitle(getSyncErrorTitle())
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
.setCategory(NotificationCompat.CATEGORY_ERROR)
try {
val phases = context.resources.getStringArray(R.array.sync_error_phases)
val message = context.getString(messageString, phases[syncPhase])
builder.setContentText(message)
} catch (ex: IndexOutOfBoundsException) {
// should never happen
}
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build())
}
}
/** Prepares synchronization (for instance, allocates necessary resources).
* @return whether actual synchronization is required / can be made. true = synchronization
* shall be continued, false = synchronization can be skipped */
abstract protected fun prepare(): Boolean
abstract protected fun queryCapabilities()
/**
* Process locally deleted entries (DELETE them on the server as well).
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected fun processLocallyDeleted() {
// 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.getDeleted()
for (local in localList) {
if (Thread.interrupted())
return
currentLocalResource = local
val fileName = local.fileName
if (fileName != null) {
App.log.info("$fileName has been deleted locally -> deleting from server")
val remote = DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
currentDavResource = remote
try {
remote.delete(local.eTag)
} catch (e: HttpException) {
App.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
}
} else
App.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
local.delete()
syncResult.stats.numDeletes++
currentLocalResource = null
currentDavResource = null
}
}
protected open fun prepareDirty() {
// assign file names and UIDs to new contacts so that we can use the file name as an index
App.log.info("Looking for contacts/groups without file name")
for (local in localCollection.getWithoutFileName()) {
currentLocalResource = local
App.log.fine("Found local record #${local.id} without file name; generating file name/UID if necessary")
local.prepareForUpload()
currentLocalResource = null
}
}
abstract protected fun prepareUpload(resource: LocalResource): RequestBody
/**
* Uploads dirty records to the server, using a PUT request for each record.
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
*/
protected fun uploadDirty() {
// upload dirty contacts
for (local in localCollection.getDirty()) {
if (Thread.interrupted())
return
currentLocalResource = local
val fileName = local.fileName
val remote = DavResource(httpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
currentDavResource = remote
// generate entity to upload (VCard, iCal, whatever)
val body = prepareUpload(local)
try {
if (local.eTag == null) {
App.log.info("Uploading new record $fileName")
remote.put(body, null, true)
} else {
App.log.info("Uploading locally modified record $fileName")
remote.put(body, local.eTag, false)
}
} catch(e: ConflictException) {
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
App.log.log(Level.INFO, "Edit conflict, ignoring", e)
} catch(e: PreconditionFailedException) {
App.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
}
val newETag = remote.properties[GetETag.NAME] as GetETag?
val eTag: String?
if (newETag != null) {
eTag = newETag.eTag
App.log.fine("Received new ETag=$eTag after uploading")
} else {
App.log.fine("Didn't receive new ETag after uploading, setting to null")
eTag = null
}
local.clearDirty(eTag)
currentLocalResource = null
currentDavResource = null
}
}
/**
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
* @return <ul>
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
* <li><code>false</code> if the remote collection hasn't changed</li>
* </ul>
*/
protected fun checkSyncState(): Boolean {
// check CTag (ignore on manual sync)
(davCollection.properties[GetCTag.NAME] as GetCTag?)?.let { remoteCTag = it.cTag }
val localCTag = if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
App.log.info("Manual sync, ignoring CTag")
null
} else
localCollection.getCTag()
return if (remoteCTag != null && remoteCTag == localCTag) {
App.log.info("Remote collection didn't change (CTag=$remoteCTag), no need to query children")
false
} else
true
}
/**
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
*/
protected fun listLocal() {
// fetch list of local contacts and build hash table to index file name
val localList = localCollection.getAll()
val resources = HashMap<String, LocalResource>(localList.size)
for (resource in localList) {
App.log.fine("Found local resource: ${resource.fileName}")
resource.fileName?.let { resources[it] = resource }
}
localResources = resources
}
/**
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
*/
abstract protected fun listRemote()
/**
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
* <ul>
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
* </ul>
*/
protected fun compareLocalRemote() {
/* check which contacts
1. are not present anymore remotely -> delete immediately on local side
2. updated remotely -> add to downloadNames
3. added remotely -> add to downloadNames
*/
toDownload.clear()
for ((name,local) in localResources) {
val remote = remoteResources[name]
currentDavResource = remote
if (remote == null) {
App.log.info("$name is not on server anymore, deleting")
currentLocalResource = local
local.delete()
syncResult.stats.numDeletes++
} else {
// contact is still on server, check whether it has been updated remotely
val getETag = remote.properties[GetETag.NAME] as GetETag?
if (getETag == null || getETag.eTag == null)
throw DavException("Server didn't provide ETag")
val localETag = local.eTag
val remoteETag = getETag.eTag
if (remoteETag == localETag) {
App.log.fine("$name has not been changed on server (ETag still $remoteETag)")
syncResult.stats.numSkippedEntries++
} else {
App.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
toDownload.add(remote)
}
// remote entry has been seen, remove from list
remoteResources.remove(name)
currentDavResource = null
currentLocalResource = null
}
}
// add all unseen (= remotely added) remote contacts
if (remoteResources.isNotEmpty()) {
App.log.info("New resources have been found on the server: ${remoteResources.keys.joinToString(", ")}")
toDownload.addAll(remoteResources.values)
}
}
/**
* Downloads the remote resources in {@link #toDownload} and stores them locally.
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
*/
abstract protected fun downloadRemote()
/**
* For post-processing of entries, for instance assigning groups.
*/
protected open fun postProcess() {}
protected fun saveSyncState() {
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
(for instance, because another client has uploaded changes), because this will simply
cause all remote entries to be listed at the next sync. */
App.log.info("Saving CTag=$remoteCTag")
localCollection.setCTag(remoteCTag)
}
}

View file

@ -1,156 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.AbstractThreadedSyncAdapter;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SyncResult;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import org.dmfs.provider.tasks.TaskContract;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.InvalidAccountException;
import at.bitfire.davdroid.model.CollectionInfo;
import at.bitfire.davdroid.model.ServiceDB;
import at.bitfire.davdroid.model.ServiceDB.Collections;
import at.bitfire.davdroid.model.ServiceDB.Services;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
public class TasksSyncAdapterService extends SyncAdapterService {
@Override
protected AbstractThreadedSyncAdapter syncAdapter() {
return new SyncAdapter(this);
}
private static class SyncAdapter extends SyncAdapterService.SyncAdapter {
public SyncAdapter(Context context) {
super(context);
}
@Override
public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient providerClient, SyncResult syncResult) {
super.onPerformSync(account, extras, authority, providerClient, syncResult);
try {
@Cleanup TaskProvider provider = TaskProvider.acquire(getContext().getContentResolver(), TaskProvider.ProviderName.OpenTasks);
if (provider == null)
throw new CalendarStorageException("Couldn't access OpenTasks provider");
AccountSettings settings = new AccountSettings(getContext(), account);
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return;
updateLocalTaskLists(provider, account, settings);
for (LocalTaskList taskList : LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, TaskContract.TaskLists.SYNC_ENABLED + "!=0", null)) {
App.log.info("Synchronizing task list #" + taskList.getId() + " [" + taskList.getSyncId() + "]");
TasksSyncManager syncManager = new TasksSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, taskList);
syncManager.performSync();
}
} catch (CalendarStorageException e) {
App.log.log(Level.SEVERE, "Couldn't enumerate local task lists", e);
} catch (InvalidAccountException e) {
App.log.log(Level.SEVERE, "Couldn't get account settings", e);
}
App.log.info("Task sync complete");
}
private void updateLocalTaskLists(TaskProvider provider, Account account, AccountSettings settings) throws CalendarStorageException {
SQLiteOpenHelper dbHelper = new ServiceDB.OpenHelper(getContext());
try {
// enumerate remote and local task lists
SQLiteDatabase db = dbHelper.getReadableDatabase();
Long service = getService(db, account);
Map<String, CollectionInfo> remote = remoteTaskLists(db, service);
List<LocalTaskList> local = LocalTaskList.find(account, provider, LocalTaskList.Factory.INSTANCE, null, null);
boolean updateColors = settings.getManageCalendarColors();
// delete obsolete local task lists
for (LocalTaskList list : local) {
String url = list.getSyncId();
if (!remote.containsKey(url)) {
App.log.fine("Deleting obsolete local task list " + url);
list.delete();
} else {
// remote CollectionInfo found for this local collection, update data
CollectionInfo info = remote.get(url);
App.log.fine("Updating local task list " + url + " with " + info);
list.update(info, updateColors);
// we already have a local task list for this remote collection, don't take into consideration anymore
remote.remove(url);
}
}
// create new local task lists
for (String url : remote.keySet()) {
CollectionInfo info = remote.get(url);
App.log.info("Adding local task list " + info);
LocalTaskList.create(account, provider, info);
}
} finally {
dbHelper.close();
}
}
@Nullable
Long getService(@NonNull SQLiteDatabase db, @NonNull Account account) {
@Cleanup Cursor c = db.query(Services._TABLE, new String[] { Services.ID },
Services.ACCOUNT_NAME + "=? AND " + Services.SERVICE + "=?", new String[] { account.name, Services.SERVICE_CALDAV }, null, null, null);
if (c.moveToNext())
return c.getLong(0);
else
return null;
}
@NonNull
private Map<String, CollectionInfo> remoteTaskLists(@NonNull SQLiteDatabase db, Long service) {
Map<String, CollectionInfo> collections = new LinkedHashMap<>();
if (service != null) {
@Cleanup Cursor cursor = db.query(Collections._TABLE, null,
Collections.SERVICE_ID + "=? AND " + Collections.SUPPORTS_VTODO + "!=0 AND " + Collections.SYNC,
new String[] { String.valueOf(service) }, null, null, null);
while (cursor.moveToNext()) {
ContentValues values = new ContentValues();
DatabaseUtils.cursorRowToContentValues(cursor, values);
CollectionInfo info = new CollectionInfo(values);
collections.put(info.getUrl(), info);
}
}
return collections;
}
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.*
import android.database.DatabaseUtils
import android.os.Bundle
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.Collections
import at.bitfire.davdroid.model.ServiceDB.Services
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.AndroidTaskList
import at.bitfire.ical4android.TaskProvider
import org.dmfs.provider.tasks.TaskContract
import java.util.logging.Level
/**
* Synchronization manager for CalDAV collections; handles tasks ({@code VTODO}).
*/
class TasksSyncAdapterService: SyncAdapterService() {
override fun syncAdapter() = SyncAdapter(this)
protected class SyncAdapter(
context: Context
): SyncAdapterService.SyncAdapter(context) {
override fun sync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
try {
val taskProvider = TaskProvider.fromProviderClient(provider)
val settings = AccountSettings(context, account)
/* don't run sync if
- sync conditions (e.g. "sync only in WiFi") are not met AND
- this is is an automatic sync (i.e. manual syncs are run regardless of sync conditions)
*/
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings))
return
updateLocalTaskLists(taskProvider, account, settings)
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
App.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
TasksSyncManager(context, account, settings, extras, authority, syncResult, taskProvider, taskList)
.performSync()
}
} catch (e: Exception) {
App.log.log(Level.SEVERE, "Couldn't sync task lists", e)
}
App.log.info("Task sync complete")
}
private fun updateLocalTaskLists(provider: TaskProvider, account: Account, settings: AccountSettings) {
ServiceDB.OpenHelper(context).use { dbHelper ->
val db = dbHelper.readableDatabase
fun getService() =
db.query(Services._TABLE, arrayOf(Services.ID),
"${Services.ACCOUNT_NAME}=? AND ${Services.SERVICE}=?",
arrayOf(account.name, Services.SERVICE_CALDAV), null, null, null)?.use { c ->
if (c.moveToNext())
c.getLong(0)
else
null
}
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
val collections = mutableMapOf<String, CollectionInfo>()
service?.let {
db.query(Collections._TABLE, null,
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
arrayOf(service.toString()), null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
val values = ContentValues()
DatabaseUtils.cursorRowToContentValues(cursor, values)
val info = CollectionInfo(values)
collections[info.url] = info
}
}
}
return collections
}
// enumerate remote and local task lists
val service = getService()
val remote = remoteTaskLists(service)
// delete/update local task lists
val updateColors = settings.getManageCalendarColors()
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
list.syncId?.let { url ->
val info = remote[url]
if (info == null) {
App.log.fine("Deleting obsolete local task list $url")
list.delete()
} else {
// remote CollectionInfo found for this local collection, update data
App.log.log(Level.FINE, "Updating local task list $url", info)
list.update(info, updateColors)
// we already have a local task list for this remote collection, don't take into consideration anymore
remote -= url
}
}
// create new local task lists
for ((_,info) in remote) {
App.log.log(Level.INFO, "Adding local task list", info)
LocalTaskList.create(account, provider, info)
}
}
}
}
}

View file

@ -1,232 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter;
import android.accounts.Account;
import android.content.Context;
import android.content.SyncResult;
import android.os.Bundle;
import org.apache.commons.codec.Charsets;
import org.apache.commons.lang3.StringUtils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import at.bitfire.dav4android.DavCalendar;
import at.bitfire.dav4android.DavResource;
import at.bitfire.dav4android.exception.DavException;
import at.bitfire.dav4android.exception.HttpException;
import at.bitfire.dav4android.property.CalendarData;
import at.bitfire.dav4android.property.GetCTag;
import at.bitfire.dav4android.property.GetContentType;
import at.bitfire.dav4android.property.GetETag;
import at.bitfire.davdroid.AccountSettings;
import at.bitfire.davdroid.App;
import at.bitfire.davdroid.ArrayUtils;
import at.bitfire.davdroid.Constants;
import at.bitfire.davdroid.R;
import at.bitfire.davdroid.resource.LocalResource;
import at.bitfire.davdroid.resource.LocalTask;
import at.bitfire.davdroid.resource.LocalTaskList;
import at.bitfire.ical4android.CalendarStorageException;
import at.bitfire.ical4android.InvalidCalendarException;
import at.bitfire.ical4android.Task;
import at.bitfire.ical4android.TaskProvider;
import lombok.Cleanup;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
public class TasksSyncManager extends SyncManager {
protected static final int MAX_MULTIGET = 30;
final protected TaskProvider provider;
public TasksSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, TaskProvider provider, SyncResult result, LocalTaskList taskList) {
super(context, account, settings, extras, authority, result, "taskList/" + taskList.getId());
this.provider = provider;
localCollection = taskList;
}
@Override
protected int notificationId() {
return Constants.NOTIFICATION_TASK_SYNC;
}
@Override
protected String getSyncErrorTitle() {
return context.getString(R.string.sync_error_tasks, account.name);
}
@Override
protected boolean prepare() {
collectionURL = HttpUrl.parse(localTaskList().getSyncId());
davCollection = new DavCalendar(httpClient, collectionURL);
return true;
}
@Override
protected void queryCapabilities() throws DavException, IOException, HttpException {
davCollection.propfind(0, GetCTag.NAME);
}
@Override
protected RequestBody prepareUpload(LocalResource resource) throws IOException, CalendarStorageException {
LocalTask local = (LocalTask)resource;
App.log.log(Level.FINE, "Preparing upload of task " + local.getFileName(), local.getTask() );
ByteArrayOutputStream os = new ByteArrayOutputStream();
local.getTask().write(os);
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
);
}
@Override
protected void listRemote() throws IOException, HttpException, DavException {
// fetch list of remote VTODOs and build hash table to index file name
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.calendarQuery("VTODO", null, null);
remoteResources = new HashMap<>(davCollection.getMembers().size());
for (DavResource vCard : davCollection.getMembers()) {
String fileName = vCard.fileName();
App.log.fine("Found remote VTODO: " + fileName);
remoteResources.put(fileName, vCard);
}
currentDavResource = null;
}
@Override
protected void downloadRemote() throws IOException, HttpException, DavException, CalendarStorageException {
App.log.info("Downloading " + toDownload.size() + " tasks (" + MAX_MULTIGET + " at once)");
// download new/updated iCalendars from server
for (DavResource[] bunch : ArrayUtils.partition(toDownload.toArray(new DavResource[toDownload.size()]), MAX_MULTIGET)) {
if (Thread.interrupted())
return;
App.log.info("Downloading " + StringUtils.join(bunch, ", "));
if (bunch.length == 1) {
// only one contact, use GET
final DavResource remote = bunch[0];
currentDavResource = remote;
ResponseBody body = remote.get("text/calendar");
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
GetETag eTag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (eTag == null || StringUtils.isEmpty(eTag.getETag()))
throw new DavException("Received CalDAV GET response without ETag for " + remote.getLocation());
Charset charset = Charsets.UTF_8;
MediaType contentType = body.contentType();
if (contentType != null)
charset = contentType.charset(Charsets.UTF_8);
@Cleanup InputStream stream = body.byteStream();
processVTodo(remote.fileName(), eTag.getETag(), stream, charset);
} else {
// multiple contacts, use multi-get
List<HttpUrl> urls = new LinkedList<>();
for (DavResource remote : bunch)
urls.add(remote.getLocation());
final DavCalendar calendar = davCalendar();
currentDavResource = calendar;
calendar.multiget(urls.toArray(new HttpUrl[urls.size()]));
// process multiget results
for (final DavResource remote : davCollection.getMembers()) {
currentDavResource = remote;
String eTag;
GetETag getETag = (GetETag)remote.getProperties().get(GetETag.NAME);
if (getETag != null)
eTag = getETag.getETag();
else
throw new DavException("Received multi-get response without ETag");
Charset charset = Charsets.UTF_8;
GetContentType getContentType = (GetContentType)remote.getProperties().get(GetContentType.NAME);
if (getContentType != null && getContentType.getType() != null) {
MediaType type = MediaType.parse(getContentType.getType());
if (type != null)
charset = type.charset(Charsets.UTF_8);
}
CalendarData calendarData = (CalendarData)remote.getProperties().get(CalendarData.NAME);
if (calendarData == null || calendarData.getICalendar() == null)
throw new DavException("Received multi-get response without address data");
@Cleanup InputStream stream = new ByteArrayInputStream(calendarData.getICalendar().getBytes());
processVTodo(remote.fileName(), eTag, stream, charset);
}
}
currentDavResource = null;
}
}
// helpers
private LocalTaskList localTaskList() { return ((LocalTaskList)localCollection); }
private DavCalendar davCalendar() { return (DavCalendar)davCollection; }
private void processVTodo(String fileName, String eTag, InputStream stream, Charset charset) throws IOException, CalendarStorageException {
List<Task> tasks;
try {
tasks = Task.fromStream(stream, charset);
} catch (InvalidCalendarException e) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e);
return;
}
if (tasks.size() == 1) {
Task newData = tasks.get(0);
// update local task, if it exists
LocalTask localTask = (LocalTask)localResources.get(fileName);
currentLocalResource = localTask;
if (localTask != null) {
App.log.info("Updating " + fileName + " in local tasklist");
localTask.setETag(eTag);
localTask.update(newData);
syncResult.stats.numUpdates++;
} else {
App.log.info("Adding " + fileName + " to local task list");
localTask = new LocalTask(localTaskList(), newData, fileName, eTag);
currentLocalResource = localTask;
localTask.add();
syncResult.stats.numInserts++;
}
} else
App.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring " + fileName);
}
}

View file

@ -0,0 +1,192 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import at.bitfire.dav4android.DavCalendar
import at.bitfire.dav4android.DavResource
import at.bitfire.dav4android.exception.DavException
import at.bitfire.dav4android.property.CalendarData
import at.bitfire.dav4android.property.GetCTag
import at.bitfire.dav4android.property.GetETag
import at.bitfire.davdroid.AccountSettings
import at.bitfire.davdroid.App
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.R
import at.bitfire.davdroid.resource.LocalResource
import at.bitfire.davdroid.resource.LocalTask
import at.bitfire.davdroid.resource.LocalTaskList
import at.bitfire.ical4android.InvalidCalendarException
import at.bitfire.ical4android.Task
import at.bitfire.ical4android.TaskProvider
import okhttp3.HttpUrl
import okhttp3.RequestBody
import org.apache.commons.collections4.ListUtils
import java.io.ByteArrayOutputStream
import java.io.Reader
import java.io.StringReader
import java.util.*
import java.util.logging.Level
class TasksSyncManager(
context: Context,
account: Account,
settings: AccountSettings,
extras: Bundle,
authority: String,
syncResult: SyncResult,
val provider: TaskProvider,
val localTaskList: LocalTaskList
): SyncManager(context, account, settings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
val MAX_MULTIGET = 30
init {
localCollection = localTaskList
}
override fun notificationId() = Constants.NOTIFICATION_TASK_SYNC
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_tasks, account.name)!!
override fun prepare(): Boolean {
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
davCollection = DavCalendar(httpClient, collectionURL)
return true
}
override fun queryCapabilities() {
davCollection.propfind(0, GetCTag.NAME)
}
override fun prepareUpload(resource: LocalResource): RequestBody {
if (resource is LocalTask) {
val task = requireNotNull(resource.task)
App.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
val os = ByteArrayOutputStream()
task.write(os)
return RequestBody.create(
DavCalendar.MIME_ICALENDAR,
os.toByteArray()
)
} else
throw IllegalArgumentException("resource must be a LocalTask")
}
override fun listRemote() {
// fetch list of remote VTODOs and build hash table to index file name
val calendar = davCalendar()
currentDavResource = calendar
calendar.calendarQuery("VTODO", null, null)
remoteResources = HashMap<String, DavResource>(davCollection.members.size)
for (vCard in davCollection.members) {
val fileName = vCard.fileName()
App.log.fine("Found remote VTODO: $fileName")
remoteResources[fileName] = vCard
}
currentDavResource = null
}
override fun downloadRemote() {
App.log.info("Downloading ${toDownload.size} tasks ($MAX_MULTIGET at once)")
// download new/updated iCalendars from server
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
if (Thread.interrupted())
return
App.log.info("Downloading ${bunch.joinToString(", ")}")
if (bunch.size == 1) {
// only one contact, use GET
val remote = bunch.first()
currentDavResource = remote
val body = remote.get("text/calendar")
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
val eTag = remote.properties[GetETag.NAME] as GetETag?
if (eTag == null || eTag.eTag.isNullOrEmpty())
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
body.charStream().use { reader ->
processVTodo(remote.fileName(), eTag.eTag!!, reader)
}
} else {
// multiple contacts, use multi-get
val calendar = davCalendar()
currentDavResource = calendar
calendar.multiget(bunch.map { it.location })
// process multiget results
for (remote in davCollection.members) {
currentDavResource = remote
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
?: throw DavException("Received multi-get response without ETag")
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
val iCalendar = calendarData?.iCalendar
?: throw DavException("Received multi-get response without task data")
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
}
}
currentDavResource = null
}
}
// helpers
private fun davCalendar() = davCollection as DavCalendar
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
val tasks: List<Task>
try {
tasks = Task.fromReader(reader)
} catch (e: InvalidCalendarException) {
App.log.log(Level.SEVERE, "Received invalid iCalendar, ignoring", e)
return
}
if (tasks.size == 1) {
val newData = tasks.first()
// update local task, if it exists
val localTask = localResources[fileName] as LocalTask?
currentLocalResource = localTask
if (localTask != null) {
App.log.info("Updating $fileName in local tasklist")
localTask.eTag = eTag
localTask.update(newData)
syncResult.stats.numUpdates++
} else {
App.log.info("Adding $fileName to local task list")
val newTask = LocalTask(localTaskList, newData, fileName, eTag)
currentLocalResource = newTask
newTask.add()
syncResult.stats.numInserts++
}
} else
App.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
}
}

View file

@ -420,10 +420,10 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu
info.carddav = new AccountInfo.ServiceInfo();
info.carddav.id = id;
info.carddav.refreshing = (davService != null && davService.isRefreshing(id)) ||
ContentResolver.isSyncActive(account, App.getAddressBooksAuthority());
ContentResolver.isSyncActive(account, App.addressBooksAuthority);
AccountManager accountManager = AccountManager.get(getContext());
for (Account addrBookAccount : accountManager.getAccountsByType(App.getAddressBookAccountType())) {
for (Account addrBookAccount : accountManager.getAccountsByType(App.addressBookAccountType)) {
LocalAddressBook addressBook = new LocalAddressBook(getContext(), addrBookAccount, null);
try {
if (account.equals(addressBook.getMainAccount()))
@ -594,7 +594,7 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu
// cancel maybe running synchronization
ContentResolver.cancelSync(oldAccount, null);
for (Account addrBookAccount : accountManager.getAccountsByType(App.getAddressBookAccountType()))
for (Account addrBookAccount : accountManager.getAccountsByType(App.addressBookAccountType))
ContentResolver.cancelSync(addrBookAccount, null);
// update account name references in database
@ -603,7 +603,7 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu
// update main account of address book accounts
try {
for (Account addrBookAccount : accountManager.getAccountsByType(App.getAddressBookAccountType())) {
for (Account addrBookAccount : accountManager.getAccountsByType(App.addressBookAccountType)) {
@Cleanup("release") ContentProviderClient provider = getContext().getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY);
if (provider != null) {
LocalAddressBook addressBook = new LocalAddressBook(getContext(), addrBookAccount, provider);
@ -675,7 +675,7 @@ public class AccountActivity extends AppCompatActivity implements Toolbar.OnMenu
protected static void requestSync(Account account) {
String authorities[] = {
App.getAddressBooksAuthority(),
App.addressBookAccountType,
CalendarContract.AUTHORITY,
TaskProvider.ProviderName.OpenTasks.getAuthority()
};

View file

@ -122,7 +122,7 @@ public class AccountSettingsActivity extends AppCompatActivity {
// category: synchronization
final ListPreference prefSyncContacts = (ListPreference)findPreference("sync_interval_contacts");
final Long syncIntervalContacts = settings.getSyncInterval(App.getAddressBooksAuthority());
final Long syncIntervalContacts = settings.getSyncInterval(App.addressBooksAuthority);
if (syncIntervalContacts != null) {
prefSyncContacts.setValue(syncIntervalContacts.toString());
if (syncIntervalContacts == AccountSettings.SYNC_INTERVAL_MANUALLY)
@ -132,7 +132,7 @@ public class AccountSettingsActivity extends AppCompatActivity {
prefSyncContacts.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
settings.setSyncInterval(App.getAddressBooksAuthority(), Long.parseLong((String)newValue));
settings.setSyncInterval(App.addressBooksAuthority, Long.parseLong((String)newValue));
getLoaderManager().restartLoader(0, getArguments(), AccountSettingsFragment.this);
return false;
}

View file

@ -138,9 +138,9 @@ public class AccountDetailsFragment extends Fragment {
settings.setGroupMethod(GroupMethod.valueOf(groupMethodName));
// contact sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
settings.setSyncInterval(App.getAddressBooksAuthority(), Constants.DEFAULT_SYNC_INTERVAL);
settings.setSyncInterval(App.addressBooksAuthority, Constants.DEFAULT_SYNC_INTERVAL);
} else
ContentResolver.setIsSyncable(account, App.getAddressBooksAuthority(), 0);
ContentResolver.setIsSyncable(account, App.addressBooksAuthority, 0);
if (config.calDAV != null) {
// insert CalDAV service

View file

@ -1,42 +0,0 @@
/*
* Copyright © 2013 2015 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 at.bitfire.davdroid;
import org.junit.Test;
import java.util.Arrays;
import static org.junit.Assert.assertTrue;
public class ArrayUtilsTest {
@Test
public void testPartition() {
// n == 0
assertTrue(Arrays.deepEquals(
new Long[0][0],
ArrayUtils.partition(new Long[] {}, 5)));
// n < max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l } },
ArrayUtils.partition(new Long[] { 1l, 2l }, 5)));
// n == max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l }, { 3l, 4l } },
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l }, 2)));
// n > max
assertTrue(Arrays.deepEquals(
new Long[][] { { 1l, 2l, 3l, 4l, 5l }, { 6l, 7l, 8l, 9l, 10l }, { 11l } },
ArrayUtils.partition(new Long[] { 1l, 2l, 3l, 4l, 5l, 6l, 7l, 8l, 9l, 10l, 11l }, 5)));
}
}

@ -1 +1 @@
Subproject commit 4babf10f52a48dbacff271823cdf1b65f6ec73b2
Subproject commit 7532ea31b134d2b993606731f089640964fbbe7b

@ -1 +1 @@
Subproject commit 8ab18b862a9d5baa20cac81bfe43bc4faa582054
Subproject commit f140ee2ebc6cd075a16e0f704673e288a47b2a0d

View file

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-3.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-4.0.1-bin.zip

@ -1 +1 @@
Subproject commit 1d4b2348faf07a45b95440479e3832ae958df989
Subproject commit 8a852a484e09cdd32fbf161286f1b504dc7be29e

@ -1 +1 @@
Subproject commit a9324986749ba7d2e3d0cef5091a5407ccf6eda1
Subproject commit d47a7d2e9c4c5b3976b3d4d28524d5025e350516