diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.kt b/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.kt index 30efe560..3f14c9cc 100644 --- a/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.kt +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/Crypto.kt @@ -171,7 +171,7 @@ object Crypto { return cipher } - internal fun decrypt(_data: ByteArray): ByteArray? { + fun decrypt(_data: ByteArray): ByteArray? { val iv = Arrays.copyOfRange(_data, 0, blockSize) val data = Arrays.copyOfRange(_data, blockSize, _data.size) @@ -194,7 +194,7 @@ object Crypto { return out } - internal fun encrypt(data: ByteArray): ByteArray? { + fun encrypt(data: ByteArray): ByteArray? { val iv = ByteArray(blockSize) random.nextBytes(iv) @@ -214,7 +214,7 @@ object Crypto { return buf } - internal fun hmac(data: ByteArray): ByteArray { + fun hmac(data: ByteArray): ByteArray { return if (version.toInt() == 1) { hmac256(hmacKey, data) } else { diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalEntryManager.kt b/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalEntryManager.kt index 64182f45..0fd0df28 100644 --- a/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalEntryManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/JournalEntryManager.kt @@ -75,7 +75,7 @@ class JournalEntryManager(httpClient: OkHttpClient, remote: HttpUrl, val uid: St class Entry : BaseManager.Base() { - fun update(crypto: Crypto.CryptoManager, content: String, previous: Entry) { + fun update(crypto: Crypto.CryptoManager, content: String, previous: Entry?) { setContent(crypto, content) uid = calculateHmac(crypto, previous) } diff --git a/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.kt b/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.kt index 4d6a65a0..d8ae1ffd 100644 --- a/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.kt +++ b/app/src/main/java/com/etesync/syncadapter/journalmanager/UserInfoManager.kt @@ -92,7 +92,7 @@ class UserInfoManager(httpClient: OkHttpClient, remote: HttpUrl) : BaseManager() return crypto.decrypt(content) } - internal fun setContent(crypto: Crypto.CryptoManager, rawContent: ByteArray) { + fun setContent(crypto: Crypto.CryptoManager, rawContent: ByteArray) { val content = crypto.encrypt(rawContent) this.content = Arrays.concatenate(calculateHmac(crypto, content), content) } diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.java deleted file mode 100644 index 180b69ad..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.java +++ /dev/null @@ -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 com.etesync.syncadapter.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 com.etesync.syncadapter.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; - } - - } -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.kt new file mode 100644 index 00000000..6d42b2b1 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/AccountAuthenticatorService.kt @@ -0,0 +1,77 @@ +/* + * 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 com.etesync.syncadapter.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 com.etesync.syncadapter.ui.setup.LoginActivity + +class AccountAuthenticatorService : Service() { + + private var accountAuthenticator: AccountAuthenticator? = null + + override fun onCreate() { + accountAuthenticator = AccountAuthenticator(this) + } + + override fun onBind(intent: Intent): IBinder? { + return if (intent.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT) accountAuthenticator!!.iBinder else null + } + + + private class AccountAuthenticator(internal val context: Context) : AbstractAccountAuthenticator(context) { + + @Throws(NetworkErrorException::class) + override fun addAccount(response: AccountAuthenticatorResponse, accountType: String, authTokenType: String, + requiredFeatures: Array, options: Bundle): Bundle { + val intent = Intent(context, LoginActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + val bundle = Bundle() + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + @Throws(NetworkErrorException::class) + override fun confirmCredentials(response: AccountAuthenticatorResponse, account: Account, options: Bundle): Bundle? { + return null + } + + override fun editProperties(response: AccountAuthenticatorResponse, accountType: String): Bundle? { + return null + } + + @Throws(NetworkErrorException::class) + override fun getAuthToken(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? { + return null + } + + override fun getAuthTokenLabel(authTokenType: String): String? { + return null + } + + @Throws(NetworkErrorException::class) + override fun hasFeatures(response: AccountAuthenticatorResponse, account: Account, features: Array): Bundle? { + return null + } + + @Throws(NetworkErrorException::class) + override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? { + return null + } + + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.java deleted file mode 100644 index 14bdf90d..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.java +++ /dev/null @@ -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 com.etesync.syncadapter.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; - } - -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.kt new file mode 100644 index 00000000..d205ab35 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBookProvider.kt @@ -0,0 +1,42 @@ +/* + * 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 com.etesync.syncadapter.syncadapter + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +class AddressBookProvider : ContentProvider() { + + override fun onCreate(): Boolean { + return false + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + return null + } + + override fun getType(uri: Uri): String? { + return null + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + return null + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + return 0 + } + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + return 0 + } + +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.java deleted file mode 100644 index 7e84cda7..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.java +++ /dev/null @@ -1,162 +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 com.etesync.syncadapter.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.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.database.sqlite.SQLiteException; -import android.os.Bundle; -import android.provider.ContactsContract; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.NotificationHelper; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.JournalModel; -import com.etesync.syncadapter.model.ServiceEntity; -import com.etesync.syncadapter.resource.LocalAddressBook; -import com.etesync.syncadapter.ui.DebugInfoActivity; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; - -import at.bitfire.vcard4android.ContactsStorageException; -import io.requery.Persistable; -import io.requery.sql.EntityDataStore; - -import static com.etesync.syncadapter.Constants.KEY_ACCOUNT; - -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 provider, SyncResult syncResult) { - super.onPerformSync(account, extras, authority, provider, syncResult); - - NotificationHelper notificationManager = new NotificationHelper(getContext(), "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC); - notificationManager.cancel(); - - try { - ContentProviderClient contactsProvider = getContext().getContentResolver().acquireContentProviderClient(ContactsContract.AUTHORITY); - if (contactsProvider == null) { - App.log.severe("Couldn't access contacts provider"); - syncResult.databaseError = true; - return; - } - - AccountSettings settings = new AccountSettings(getContext(), account); - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) - return; - - new RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run(); - - updateLocalAddressBooks(contactsProvider, account); - - contactsProvider.release(); - - AccountManager accountManager = AccountManager.get(getContext()); - for (Account addressBookAccount : accountManager.getAccountsByType(App.getAddressBookAccountType())) { - 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 (Exceptions.ServiceUnavailableException e) { - syncResult.stats.numIoExceptions++; - syncResult.delayUntil = (e.getRetryAfter() > 0) ? e.getRetryAfter() : Constants.DEFAULT_RETRY_DELAY; - } catch (Exception | OutOfMemoryError e) { - if (e instanceof ContactsStorageException || e instanceof SQLiteException) { - App.log.log(Level.SEVERE, "Couldn't prepare local address books", e); - syncResult.databaseError = true; - } - - int syncPhase = R.string.sync_phase_journals; - String title = getContext().getString(R.string.sync_error_contacts, account.name); - - notificationManager.setThrowable(e); - - final Intent detailsIntent = notificationManager.getDetailsIntent(); - detailsIntent.putExtra(KEY_ACCOUNT, account); - if (!(e instanceof Exceptions.UnauthorizedException)) { - detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority); - detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); - } - - notificationManager.notify(title, getContext().getString(syncPhase)); - } - - App.log.info("Address book sync complete"); - } - - - private void updateLocalAddressBooks(ContentProviderClient provider, Account account) throws ContactsStorageException, AuthenticatorException, OperationCanceledException, IOException { - final Context context = getContext(); - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); - ServiceEntity service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.ADDRESS_BOOK); - - Map remote = new HashMap<>(); - List remoteJournals = JournalEntity.getJournals(data, service); - for (JournalEntity journalEntity : remoteJournals) { - remote.put(journalEntity.getUid(), journalEntity); - } - - 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.fine("Deleting obsolete local address book " + url); - addressBook.delete(); - } else { - // remote CollectionInfo found for this local collection, update data - JournalEntity journalEntity = remote.get(url); - App.log.fine("Updating local address book " + url + " with " + journalEntity); - addressBook.update(journalEntity); - // we already have a local collection for this remote collection, don't take into consideration anymore - remote.remove(url); - } - } - - // create new local address books - for (String url : remote.keySet()) { - JournalEntity journalEntity = remote.get(url); - App.log.info("Adding local address book " + journalEntity); - LocalAddressBook.create(context, provider, account, journalEntity); - } - } - } - -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt new file mode 100644 index 00000000..8e7ea501 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/AddressBooksSyncAdapterService.kt @@ -0,0 +1,166 @@ +/* + * 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 com.etesync.syncadapter.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.Context +import android.content.Intent +import android.content.SyncResult +import android.database.sqlite.SQLiteException +import android.os.Bundle +import android.provider.ContactsContract + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.NotificationHelper +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.JournalModel +import com.etesync.syncadapter.model.ServiceEntity +import com.etesync.syncadapter.resource.LocalAddressBook +import com.etesync.syncadapter.ui.DebugInfoActivity + +import java.io.IOException +import java.util.HashMap +import java.util.logging.Level + +import at.bitfire.vcard4android.ContactsStorageException +import io.requery.Persistable +import io.requery.sql.EntityDataStore + +import com.etesync.syncadapter.Constants.KEY_ACCOUNT + +class AddressBooksSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter(): AbstractThreadedSyncAdapter { + return AddressBooksSyncAdapter(this) + } + + + private class AddressBooksSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { + + override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + super.onPerformSync(account, extras, authority, provider, syncResult) + + val notificationManager = NotificationHelper(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC) + notificationManager.cancel() + + try { + val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY) + if (contactsProvider == null) { + App.log.severe("Couldn't access contacts provider") + syncResult.databaseError = true + return + } + + val settings = AccountSettings(context, account) + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) + return + + RefreshCollections(account, CollectionInfo.Type.ADDRESS_BOOK).run() + + updateLocalAddressBooks(contactsProvider, account) + + contactsProvider.release() + + val accountManager = AccountManager.get(context) + for (addressBookAccount in accountManager.getAccountsByType(App.getAddressBookAccountType())) { + 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: Exceptions.ServiceUnavailableException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY + } catch (e: Exception) { + if (e is ContactsStorageException || e is SQLiteException) { + App.log.log(Level.SEVERE, "Couldn't prepare local address books", e) + syncResult.databaseError = true + } + + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_contacts, account.name) + + notificationManager.setThrowable(e) + + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + if (e !is Exceptions.UnauthorizedException) { + detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) + detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) + } + + notificationManager.notify(title, context.getString(syncPhase)) + } catch (e: OutOfMemoryError) { + if (e is ContactsStorageException || e is SQLiteException) { + App.log.log(Level.SEVERE, "Couldn't prepare local address books", e) + syncResult.databaseError = true + } + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_contacts, account.name) + notificationManager.setThrowable(e) + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + notificationManager.notify(title, context.getString(syncPhase)) + } + + App.log.info("Address book sync complete") + } + + + @Throws(ContactsStorageException::class, AuthenticatorException::class, OperationCanceledException::class, IOException::class) + private fun updateLocalAddressBooks(provider: ContentProviderClient, account: Account) { + val context = context + val data = (getContext().applicationContext as App).data + val service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.ADDRESS_BOOK) + + val remote = HashMap() + val remoteJournals = JournalEntity.getJournals(data, service) + for (journalEntity in remoteJournals) { + remote[journalEntity.uid] = journalEntity + } + + val local = LocalAddressBook.find(context, provider, account) + + // delete obsolete local address books + for (addressBook in local) { + val url = addressBook.url + val journalEntity = remote[url] + if (journalEntity == null) { + App.log.fine("Deleting obsolete local address book $url") + addressBook.delete() + } else { + // remote CollectionInfo found for this local collection, update data + App.log.fine("Updating local address book $url with $journalEntity") + addressBook.update(journalEntity) + // we already have a local collection for this remote collection, don't take into consideration anymore + remote.remove(url) + } + } + + // create new local address books + for (url in remote.keys) { + val journalEntity = remote[url]!! + App.log.info("Adding local address book $journalEntity") + LocalAddressBook.create(context, provider, account, journalEntity) + } + } + } + +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.java deleted file mode 100644 index f79a6118..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.java +++ /dev/null @@ -1,270 +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 com.etesync.syncadapter.syncadapter; - -import android.accounts.Account; -import android.content.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.NotificationHelper; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.journalmanager.JournalEntryManager; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.SyncEntry; -import com.etesync.syncadapter.resource.LocalCalendar; -import com.etesync.syncadapter.resource.LocalEvent; -import com.etesync.syncadapter.resource.LocalResource; - -import net.fortuna.ical4j.model.property.Attendee; - -import org.acra.attachment.AcraContentProvider; -import org.acra.util.IOUtils; -import org.apache.commons.codec.Charsets; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.TimeZone; - -import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.ical4android.Event; -import at.bitfire.ical4android.InvalidCalendarException; -import at.bitfire.vcard4android.ContactsStorageException; -import okhttp3.HttpUrl; - -/** - *

Synchronization manager for CardDAV collections; handles contacts and groups.

- */ -public class CalendarSyncManager extends SyncManager { - final private HttpUrl remote; - - public CalendarSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult result, LocalCalendar calendar, HttpUrl remote) throws Exceptions.IntegrityException, Exceptions.GenericCryptoException { - super(context, account, settings, extras, authority, result, calendar.getName(), CollectionInfo.Type.CALENDAR, account.name); - localCollection = calendar; - this.remote = remote; - } - - @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 String getSyncSuccessfullyTitle() { - return context.getString(R.string.sync_successfully_calendar, info.displayName, - account.name); - } - - @Override - protected boolean prepare() throws ContactsStorageException, CalendarStorageException { - if (!super.prepare()) - return false; - - journal = new JournalEntryManager(httpClient, remote, localCalendar().getName()); - return true; - } - - @Override - protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { - super.prepareDirty(); - - localCalendar().processDirtyExceptions(); - } - - - // helpers - - private LocalCalendar localCalendar() { - return (LocalCalendar) localCollection; - } - - protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException { - InputStream is = new ByteArrayInputStream(cEntry.getContent().getBytes(Charsets.UTF_8)); - - Event[] events = Event.fromStream(is, Charsets.UTF_8); - if (events.length == 0) { - App.log.warning("Received VCard without data, ignoring"); - return; - } else if (events.length > 1) { - App.log.warning("Received multiple VCALs, using first one"); - } - - Event event = events[0]; - LocalEvent local = (LocalEvent) localCollection.getByUid(event.uid); - - if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { - processEvent(event, local); - } else { - if (local != null) { - App.log.info("Removing local record #" + local.getId() + " which has been deleted on the server"); - local.delete(); - } else { - App.log.warning("Tried deleting a non-existent record: " + event.uid); - } - } - } - - protected void createLocalEntries() throws CalendarStorageException, ContactsStorageException, IOException { - super.createLocalEntries(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - createInviteAttendeesNotification(); - } - } - - private void createInviteAttendeesNotification() throws CalendarStorageException, ContactsStorageException, IOException { - for (LocalResource local : localDirty) { - Event event = ((LocalEvent) local).getEvent(); - - if (event.attendees.isEmpty()) { - return; - } - createInviteAttendeesNotification(event, local.getContent()); - } - } - - private void createInviteAttendeesNotification(Event event, String icsContent) { - NotificationHelper notificationHelper = new NotificationHelper(context, event.uid, event.uid.hashCode()); - Intent intent = new Intent(Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(Intent.EXTRA_EMAIL, getEmailAddresses(event.attendees ,false)); - final DateFormat dateFormatDate = - new SimpleDateFormat("EEEE, MMM dd", Locale.US); - intent.putExtra(Intent.EXTRA_SUBJECT, - context.getString(R.string.sync_calendar_attendees_email_subject, - event.summary, - dateFormatDate.format(event.dtStart.getDate()))); - intent.putExtra(Intent.EXTRA_TEXT, - context.getString(R.string.sync_calendar_attendees_email_content, - event.summary, - formatEventDates(event), - (event.location != null) ? event.location : "", - formatAttendees(event.attendees))); - Uri uri = createAttachmentFromString(context, event.uid, icsContent); - if (uri == null) { - App.log.severe("Unable to create attachment from calendar event"); - return; - } - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - intent.putExtra(Intent.EXTRA_STREAM, uri); - notificationHelper.notify( - context.getString( - R.string.sync_calendar_attendees_notification_title, event.summary), - context.getString(R.string.sync_calendar_attendees_notification_content), - null, - intent, - R.drawable.ic_email_black); - } - - private LocalResource processEvent(final Event newData, LocalEvent localEvent) throws IOException, ContactsStorageException, CalendarStorageException { - // delete local event, if it exists - if (localEvent != null) { - App.log.info("Updating " + newData.uid + " in local calendar"); - localEvent.setETag(newData.uid); - localEvent.update(newData); - syncResult.stats.numUpdates++; - } else { - App.log.info("Adding " + newData.uid + " to local calendar"); - localEvent = new LocalEvent(localCalendar(), newData, newData.uid, newData.uid); - localEvent.add(); - syncResult.stats.numInserts++; - } - - return localEvent; - } - - private String[] getEmailAddresses(List attendees, - boolean shouldIncludeAccount) { - List attendeesEmails = new ArrayList<>(attendees.size()); - for (Attendee attendee : attendees) { - String attendeeEmail = attendee.getValue().replace("mailto:", ""); - if (!shouldIncludeAccount && attendeeEmail.equals(account.name)) { - continue; - } - attendeesEmails.add(attendeeEmail); - } - return attendeesEmails.toArray(new String[0]); - } - - private String formatAttendees(List attendeesList) { - StringBuilder stringBuilder = new StringBuilder(); - String[] attendees = getEmailAddresses(attendeesList, true); - for (String attendee : attendees) { - stringBuilder.append("\n ").append(attendee); - } - return stringBuilder.toString(); - } - - private static String formatEventDates(Event event) { - final Locale locale = Locale.getDefault(); - final TimeZone timezone = (event.dtStart.getTimeZone() != null) ? event.dtStart.getTimeZone() : TimeZone.getTimeZone("UTC"); - final String dateFormatString = - event.isAllDay() ? "EEEE, MMM dd" : "EEEE, MMM dd @ hh:mm a"; - final DateFormat longDateFormat = - new SimpleDateFormat(dateFormatString, locale); - longDateFormat.setTimeZone(timezone); - final DateFormat shortDateFormat = - new SimpleDateFormat("hh:mm a", locale); - shortDateFormat.setTimeZone(timezone); - - Date startDate = event.dtStart.getDate(); - Date endDate = event.getEndDate(true).getDate(); - final String tzName = timezone.getDisplayName(timezone.inDaylightTime(startDate), TimeZone.SHORT); - - Calendar cal1 = Calendar.getInstance(); - Calendar cal2 = Calendar.getInstance(); - cal1.setTime(startDate); - cal2.setTime(endDate); - boolean sameDay = cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && - cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR); - if (sameDay && event.isAllDay()) { - return longDateFormat.format(startDate); - } - return sameDay ? - String.format("%s - %s (%s)", - longDateFormat.format(startDate), - shortDateFormat.format(endDate), - tzName) : - String.format("%s - %s (%s)", longDateFormat.format(startDate), longDateFormat.format(endDate), tzName); - } - - private Uri createAttachmentFromString(Context context, String name, String content) { - final File parentDir = new File (context.getCacheDir(), name); - parentDir.mkdirs(); - final File cache = new File(parentDir, "invite.ics"); - try { - IOUtils.writeStringToFile(cache, content); - return AcraContentProvider.getUriForFile(context, cache); - } catch (IOException e) { - e.printStackTrace(); - } - return null; - } -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt new file mode 100644 index 00000000..2daaf827 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarSyncManager.kt @@ -0,0 +1,264 @@ +/* + * 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 com.etesync.syncadapter.syncadapter + +import android.accounts.Account +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.net.Uri +import android.os.Build +import android.os.Bundle + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.NotificationHelper +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.journalmanager.JournalEntryManager +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.SyncEntry +import com.etesync.syncadapter.resource.LocalCalendar +import com.etesync.syncadapter.resource.LocalEvent +import com.etesync.syncadapter.resource.LocalResource + +import net.fortuna.ical4j.model.property.Attendee + +import org.acra.attachment.AcraContentProvider +import org.acra.util.IOUtils +import org.apache.commons.codec.Charsets + +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.ArrayList +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.vcard4android.ContactsStorageException +import okhttp3.HttpUrl + +/** + * + * Synchronization manager for CardDAV collections; handles contacts and groups. + */ +class CalendarSyncManager @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class) +constructor(context: Context, account: Account, settings: AccountSettings, extras: Bundle, authority: String, result: SyncResult, calendar: LocalCalendar, private val remote: HttpUrl) : SyncManager(context, account, settings, extras, authority, result, calendar.name, CollectionInfo.Type.CALENDAR, account.name) { + + protected override val syncErrorTitle: String + get() = context.getString(R.string.sync_error_calendar, account.name) + + protected override val syncSuccessfullyTitle: String + get() = context.getString(R.string.sync_successfully_calendar, info.displayName, + account.name) + + init { + localCollection = calendar + } + + override fun notificationId(): Int { + return Constants.NOTIFICATION_CALENDAR_SYNC + } + + @Throws(ContactsStorageException::class, CalendarStorageException::class) + override fun prepare(): Boolean { + if (!super.prepare()) + return false + + journal = JournalEntryManager(httpClient, remote, localCalendar().name) + return true + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class) + override fun prepareDirty() { + super.prepareDirty() + + localCalendar().processDirtyExceptions() + } + + + // helpers + + private fun localCalendar(): LocalCalendar { + return localCollection as LocalCalendar + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class) + override fun processSyncEntry(cEntry: SyncEntry) { + val `is` = ByteArrayInputStream(cEntry.content.toByteArray(Charsets.UTF_8)) + + val events = Event.fromStream(`is`, Charsets.UTF_8) + if (events.size == 0) { + App.log.warning("Received VCard without data, ignoring") + return + } else if (events.size > 1) { + App.log.warning("Received multiple VCALs, using first one") + } + + val event = events[0] + val local = localCollection!!.getByUid(event.uid) as LocalEvent + + if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { + processEvent(event, local) + } else { + if (local != null) { + App.log.info("Removing local record #" + local.id + " which has been deleted on the server") + local.delete() + } else { + App.log.warning("Tried deleting a non-existent record: " + event.uid) + } + } + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class) + override fun createLocalEntries() { + super.createLocalEntries() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + createInviteAttendeesNotification() + } + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class) + private fun createInviteAttendeesNotification() { + for (local in localDirty) { + val event = (local as LocalEvent).event + + if (event.attendees.isEmpty()) { + return + } + createInviteAttendeesNotification(event, local.getContent()) + } + } + + private fun createInviteAttendeesNotification(event: Event, icsContent: String) { + val notificationHelper = NotificationHelper(context, event.uid, event.uid.hashCode()) + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + intent.putExtra(Intent.EXTRA_EMAIL, getEmailAddresses(event.attendees, false)) + val dateFormatDate = SimpleDateFormat("EEEE, MMM dd", Locale.US) + intent.putExtra(Intent.EXTRA_SUBJECT, + context.getString(R.string.sync_calendar_attendees_email_subject, + event.summary, + dateFormatDate.format(event.dtStart.date))) + intent.putExtra(Intent.EXTRA_TEXT, + context.getString(R.string.sync_calendar_attendees_email_content, + event.summary, + formatEventDates(event), + if (event.location != null) event.location else "", + formatAttendees(event.attendees))) + val uri = createAttachmentFromString(context, event.uid, icsContent) + if (uri == null) { + App.log.severe("Unable to create attachment from calendar event") + return + } + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.putExtra(Intent.EXTRA_STREAM, uri) + notificationHelper.notify( + context.getString( + R.string.sync_calendar_attendees_notification_title, event.summary), + context.getString(R.string.sync_calendar_attendees_notification_content), null, + intent, + R.drawable.ic_email_black) + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class) + private fun processEvent(newData: Event, localEvent: LocalEvent?): LocalResource { + var localEvent = localEvent + // delete local event, if it exists + if (localEvent != null) { + App.log.info("Updating " + newData.uid + " in local calendar") + localEvent.eTag = newData.uid + localEvent.update(newData) + syncResult.stats.numUpdates++ + } else { + App.log.info("Adding " + newData.uid + " to local calendar") + localEvent = LocalEvent(localCalendar(), newData, newData.uid, newData.uid) + localEvent.add() + syncResult.stats.numInserts++ + } + + return localEvent + } + + private fun getEmailAddresses(attendees: List, + shouldIncludeAccount: Boolean): Array { + val attendeesEmails = ArrayList(attendees.size) + for (attendee in attendees) { + val attendeeEmail = attendee.value.replace("mailto:", "") + if (!shouldIncludeAccount && attendeeEmail == account.name) { + continue + } + attendeesEmails.add(attendeeEmail) + } + return attendeesEmails.toTypedArray() + } + + private fun formatAttendees(attendeesList: List): String { + val stringBuilder = StringBuilder() + val attendees = getEmailAddresses(attendeesList, true) + for (attendee in attendees) { + stringBuilder.append("\n ").append(attendee) + } + return stringBuilder.toString() + } + + private fun formatEventDates(event: Event): String { + val locale = Locale.getDefault() + val timezone = if (event.dtStart.timeZone != null) event.dtStart.timeZone else TimeZone.getTimeZone("UTC") + val dateFormatString = if (event.isAllDay) "EEEE, MMM dd" else "EEEE, MMM dd @ hh:mm a" + val longDateFormat = SimpleDateFormat(dateFormatString, locale) + longDateFormat.timeZone = timezone + val shortDateFormat = SimpleDateFormat("hh:mm a", locale) + shortDateFormat.timeZone = timezone + + val startDate = event.dtStart.date + val endDate = event.getEndDate(true)!!.date + val tzName = timezone.getDisplayName(timezone.inDaylightTime(startDate), TimeZone.SHORT) + + val cal1 = Calendar.getInstance() + val cal2 = Calendar.getInstance() + cal1.time = startDate + cal2.time = endDate + val sameDay = cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) && cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR) + if (sameDay && event.isAllDay) { + return longDateFormat.format(startDate) + } + return if (sameDay) + String.format("%s - %s (%s)", + longDateFormat.format(startDate), + shortDateFormat.format(endDate), + tzName) + else + String.format("%s - %s (%s)", longDateFormat.format(startDate), longDateFormat.format(endDate), tzName) + } + + private fun createAttachmentFromString(context: Context, name: String, content: String): Uri? { + val parentDir = File(context.cacheDir, name) + parentDir.mkdirs() + val cache = File(parentDir, "invite.ics") + try { + IOUtils.writeStringToFile(cache, content) + return AcraContentProvider.getUriForFile(context, cache) + } catch (e: IOException) { + e.printStackTrace() + } + + return null + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java deleted file mode 100644 index 14a53bf5..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.java +++ /dev/null @@ -1,149 +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 com.etesync.syncadapter.syncadapter; - -import android.accounts.Account; -import android.content.AbstractThreadedSyncAdapter; -import android.content.ContentProviderClient; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.database.sqlite.SQLiteException; -import android.os.Bundle; -import android.provider.CalendarContract; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.NotificationHelper; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.JournalModel; -import com.etesync.syncadapter.model.ServiceEntity; -import com.etesync.syncadapter.resource.LocalCalendar; -import com.etesync.syncadapter.ui.DebugInfoActivity; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; - -import at.bitfire.ical4android.CalendarStorageException; -import io.requery.Persistable; -import io.requery.sql.EntityDataStore; -import okhttp3.HttpUrl; - -import static com.etesync.syncadapter.Constants.KEY_ACCOUNT; - -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); - - NotificationHelper notificationManager = new NotificationHelper(getContext(), "journals-calendar", Constants.NOTIFICATION_CALENDAR_SYNC); - notificationManager.cancel(); - - try { - AccountSettings settings = new AccountSettings(getContext(), account); - if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) - return; - - new RefreshCollections(account, CollectionInfo.Type.CALENDAR).run(); - - updateLocalCalendars(provider, account, settings); - - HttpUrl principal = HttpUrl.get(settings.getUri()); - - for (LocalCalendar calendar : (LocalCalendar[]) 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, principal); - syncManager.performSync(); - } - } catch (Exceptions.ServiceUnavailableException e) { - syncResult.stats.numIoExceptions++; - syncResult.delayUntil = (e.getRetryAfter() > 0) ? e.getRetryAfter() : Constants.DEFAULT_RETRY_DELAY; - } catch (Exception | OutOfMemoryError e) { - if (e instanceof CalendarStorageException || e instanceof SQLiteException) { - App.log.log(Level.SEVERE, "Couldn't prepare local calendars", e); - syncResult.databaseError = true; - } - - int syncPhase = R.string.sync_phase_journals; - String title = getContext().getString(R.string.sync_error_calendar, account.name); - - notificationManager.setThrowable(e); - - final Intent detailsIntent = notificationManager.getDetailsIntent(); - detailsIntent.putExtra(KEY_ACCOUNT, account); - if (!(e instanceof Exceptions.UnauthorizedException)) { - detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority); - detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); - } - - notificationManager.notify(title, getContext().getString(syncPhase)); - } - - App.log.info("Calendar sync complete"); - } - - private void updateLocalCalendars(ContentProviderClient provider, Account account, AccountSettings settings) throws CalendarStorageException { - EntityDataStore data = ((App) getContext().getApplicationContext()).getData(); - ServiceEntity service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.CALENDAR); - - Map remote = new HashMap<>(); - List remoteJournals = JournalEntity.getJournals(data, service); - for (JournalEntity journalEntity : remoteJournals) { - remote.put(journalEntity.getUid(), journalEntity); - } - - LocalCalendar[] local = (LocalCalendar[]) LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null); - - boolean updateColors = settings.getManageCalendarColors(); - - // delete obsolete local calendar - 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 - JournalEntity journalEntity = remote.get(url); - App.log.fine("Updating local calendar " + url + " with " + journalEntity); - calendar.update(journalEntity, 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()) { - JournalEntity journalEntity = remote.get(url); - App.log.info("Adding local calendar list " + journalEntity); - LocalCalendar.create(account, provider, journalEntity); - } - } - } - -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt new file mode 100644 index 00000000..fe069285 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/CalendarsSyncAdapterService.kt @@ -0,0 +1,153 @@ +/* + * 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 com.etesync.syncadapter.syncadapter + +import android.accounts.Account +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.database.sqlite.SQLiteException +import android.os.Bundle +import android.provider.CalendarContract + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.NotificationHelper +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.JournalModel +import com.etesync.syncadapter.model.ServiceEntity +import com.etesync.syncadapter.resource.LocalCalendar +import com.etesync.syncadapter.ui.DebugInfoActivity + +import java.util.HashMap +import java.util.logging.Level + +import at.bitfire.ical4android.CalendarStorageException +import io.requery.Persistable +import io.requery.sql.EntityDataStore +import okhttp3.HttpUrl + +import com.etesync.syncadapter.Constants.KEY_ACCOUNT + +class CalendarsSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter(): AbstractThreadedSyncAdapter { + return SyncAdapter(this) + } + + + private class SyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { + + override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + super.onPerformSync(account, extras, authority, provider, syncResult) + + val notificationManager = NotificationHelper(context, "journals-calendar", Constants.NOTIFICATION_CALENDAR_SYNC) + notificationManager.cancel() + + try { + val settings = AccountSettings(context, account) + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) + return + + RefreshCollections(account, CollectionInfo.Type.CALENDAR).run() + + updateLocalCalendars(provider, account, settings) + + val principal = HttpUrl.get(settings.uri!!)!! + + for (calendar in LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, CalendarContract.Calendars.SYNC_EVENTS + "!=0", null) as Array) { + App.log.info("Synchronizing calendar #" + calendar.id + ", URL: " + calendar.name) + val syncManager = CalendarSyncManager(context, account, settings, extras, authority, syncResult, calendar, principal) + syncManager.performSync() + } + } catch (e: Exceptions.ServiceUnavailableException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY + } catch (e: Exception) { + if (e is CalendarStorageException || e is SQLiteException) { + App.log.log(Level.SEVERE, "Couldn't prepare local calendars", e) + syncResult.databaseError = true + } + + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_calendar, account.name) + + notificationManager.setThrowable(e) + + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + if (e !is Exceptions.UnauthorizedException) { + detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) + detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) + } + + notificationManager.notify(title, context.getString(syncPhase)) + } catch (e: OutOfMemoryError) { + if (e is CalendarStorageException || e is SQLiteException) { + App.log.log(Level.SEVERE, "Couldn't prepare local calendars", e) + syncResult.databaseError = true + } + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_calendar, account.name) + notificationManager.setThrowable(e) + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + notificationManager.notify(title, context.getString(syncPhase)) + } + + App.log.info("Calendar sync complete") + } + + @Throws(CalendarStorageException::class) + private fun updateLocalCalendars(provider: ContentProviderClient, account: Account, settings: AccountSettings) { + val data = (context.applicationContext as App).data + val service = JournalModel.Service.fetch(data, account.name, CollectionInfo.Type.CALENDAR) + + val remote = HashMap() + val remoteJournals = JournalEntity.getJournals(data, service) + for (journalEntity in remoteJournals) { + remote[journalEntity.uid] = journalEntity + } + + val local = LocalCalendar.find(account, provider, LocalCalendar.Factory.INSTANCE, null, null) as Array + + val updateColors = settings.manageCalendarColors + + // delete obsolete local calendar + for (calendar in local) { + val url = calendar.name + val journalEntity = remote[url] + if (journalEntity == null) { + App.log.fine("Deleting obsolete local calendar $url") + calendar.delete() + } else { + // remote CollectionInfo found for this local collection, update data + App.log.fine("Updating local calendar $url with $journalEntity") + calendar.update(journalEntity, 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 (url in remote.keys) { + val journalEntity = remote[url]!! + App.log.info("Adding local calendar list $journalEntity") + LocalCalendar.create(account, provider, journalEntity) + } + } + } + +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java deleted file mode 100644 index 2f2d6de7..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.java +++ /dev/null @@ -1,103 +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 com.etesync.syncadapter.syncadapter; - -import android.accounts.Account; -import android.content.AbstractThreadedSyncAdapter; -import android.content.ContentProviderClient; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.os.Bundle; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.InvalidAccountException; -import com.etesync.syncadapter.NotificationHelper; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.JournalModel; -import com.etesync.syncadapter.model.ServiceDB; -import com.etesync.syncadapter.model.ServiceEntity; -import com.etesync.syncadapter.resource.LocalAddressBook; -import com.etesync.syncadapter.ui.DebugInfoActivity; - -import java.util.logging.Level; - -import at.bitfire.vcard4android.ContactsStorageException; -import io.requery.Persistable; -import io.requery.sql.EntityDataStore; -import okhttp3.HttpUrl; - -import static com.etesync.syncadapter.Constants.KEY_ACCOUNT; - -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); - NotificationHelper notificationManager = new NotificationHelper(getContext(), "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC); - notificationManager.cancel(); - - try { - LocalAddressBook addressBook = new LocalAddressBook(getContext(), account, provider); - - AccountSettings settings; - try { - settings = new AccountSettings(getContext(), addressBook.getMainAccount()); - } catch (InvalidAccountException|ContactsStorageException e) { - App.log.info("Skipping sync due to invalid account."); - App.log.info(e.getLocalizedMessage()); - return; - } - - 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()); - - HttpUrl principal = HttpUrl.get(settings.getUri()); - ContactsSyncManager syncManager = new ContactsSyncManager(getContext(), account, settings, extras, authority, provider, syncResult, addressBook, principal); - syncManager.performSync(); - } catch (Exception | OutOfMemoryError e) { - int syncPhase = R.string.sync_phase_journals; - String title = getContext().getString(R.string.sync_error_contacts, account.name); - - notificationManager.setThrowable(e); - - final Intent detailsIntent = notificationManager.getDetailsIntent(); - detailsIntent.putExtra(KEY_ACCOUNT, account); - if (!(e instanceof Exceptions.UnauthorizedException)) { - detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority); - detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); - } - notificationManager.notify(title, getContext().getString(syncPhase)); - } - - App.log.info("Contacts sync complete"); - } - } - -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt new file mode 100644 index 00000000..e45ee1fc --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncAdapterService.kt @@ -0,0 +1,108 @@ +/* + * 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 com.etesync.syncadapter.syncadapter + +import android.accounts.Account +import android.content.AbstractThreadedSyncAdapter +import android.content.ContentProviderClient +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.os.Bundle + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.InvalidAccountException +import com.etesync.syncadapter.NotificationHelper +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.JournalModel +import com.etesync.syncadapter.model.ServiceDB +import com.etesync.syncadapter.model.ServiceEntity +import com.etesync.syncadapter.resource.LocalAddressBook +import com.etesync.syncadapter.ui.DebugInfoActivity + +import java.util.logging.Level + +import at.bitfire.vcard4android.ContactsStorageException +import io.requery.Persistable +import io.requery.sql.EntityDataStore +import okhttp3.HttpUrl + +import com.etesync.syncadapter.Constants.KEY_ACCOUNT + +class ContactsSyncAdapterService : SyncAdapterService() { + + override fun syncAdapter(): AbstractThreadedSyncAdapter { + return ContactsSyncAdapter(this) + } + + + private class ContactsSyncAdapter(context: Context) : SyncAdapterService.SyncAdapter(context) { + + override fun onPerformSync(account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) { + super.onPerformSync(account, extras, authority, provider, syncResult) + val notificationManager = NotificationHelper(context, "journals-contacts", Constants.NOTIFICATION_CONTACTS_SYNC) + notificationManager.cancel() + + try { + val addressBook = LocalAddressBook(context, account, provider) + + val settings: AccountSettings + try { + settings = AccountSettings(context, addressBook.mainAccount) + } catch (e: InvalidAccountException) { + App.log.info("Skipping sync due to invalid account.") + App.log.info(e.localizedMessage) + return + } catch (e: ContactsStorageException) { + App.log.info("Skipping sync due to invalid account.") + App.log.info(e.localizedMessage) + return + } + + if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(settings)) + return + + App.log.info("Synchronizing address book: " + addressBook.url) + App.log.info("Taking settings from: " + addressBook.mainAccount) + + val principal = HttpUrl.get(settings.uri!!)!! + val syncManager = ContactsSyncManager(context, account, settings, extras, authority, provider, syncResult, addressBook, principal) + syncManager.performSync() + } catch (e: Exception) { + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_contacts, account.name) + + notificationManager.setThrowable(e) + + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + if (e !is Exceptions.UnauthorizedException) { + detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) + detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) + } + notificationManager.notify(title, context.getString(syncPhase)) + } catch (e: OutOfMemoryError) { + val syncPhase = R.string.sync_phase_journals + val title = context.getString(R.string.sync_error_contacts, account.name) + notificationManager.setThrowable(e) + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + notificationManager.notify(title, context.getString(syncPhase)) + } + + App.log.info("Contacts sync complete") + } + } + +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.java deleted file mode 100644 index d0d7669e..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.java +++ /dev/null @@ -1,303 +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 com.etesync.syncadapter.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.os.Build; -import android.os.Bundle; -import android.provider.ContactsContract; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.HttpClient; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.journalmanager.JournalEntryManager; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.SyncEntry; -import com.etesync.syncadapter.resource.LocalAddressBook; -import com.etesync.syncadapter.resource.LocalContact; -import com.etesync.syncadapter.resource.LocalGroup; -import com.etesync.syncadapter.resource.LocalResource; - -import org.apache.commons.codec.Charsets; -import org.apache.commons.collections4.SetUtils; -import org.apache.commons.io.IOUtils; - -import java.io.ByteArrayInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.util.Set; -import java.util.logging.Level; - -import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.vcard4android.BatchOperation; -import at.bitfire.vcard4android.Contact; -import at.bitfire.vcard4android.ContactsStorageException; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; - -/** - *

Synchronization manager for CardDAV collections; handles contacts and groups.

- */ -public class ContactsSyncManager extends SyncManager { - final private ContentProviderClient provider; - final private HttpUrl remote; - - public ContactsSyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, ContentProviderClient provider, SyncResult result, LocalAddressBook localAddressBook, HttpUrl principal) throws Exceptions.IntegrityException, Exceptions.GenericCryptoException, ContactsStorageException { - super(context, account, settings, extras, authority, result, localAddressBook.getURL(), CollectionInfo.Type.ADDRESS_BOOK, localAddressBook.getMainAccount().name); - this.provider = provider; - this.remote = principal; - - 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 String getSyncSuccessfullyTitle() { - return context.getString(R.string.sync_successfully_contacts, account.name); - } - - @Override - protected boolean prepare() throws ContactsStorageException, CalendarStorageException { - if (!super.prepare()) - return false; - LocalAddressBook localAddressBook = localAddressBook(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - // workaround for Android 7 which sets DIRTY flag when only meta-data is changed - int reallyDirty = localAddressBook.verifyDirty(), - deleted = localAddressBook.getDeleted().length; - 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); - - journal = new JournalEntryManager(httpClient, remote, localAddressBook.getURL()); - - localAddressBook.includeGroups = true; - - return true; - } - - @Override - protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { - super.prepareDirty(); - - LocalAddressBook addressBook = localAddressBook(); - - /* groups as separate VCards: there are group contacts and individual contacts */ - - // mark groups with changed members as dirty - BatchOperation batch = new BatchOperation(addressBook.provider); - for (LocalContact contact : addressBook.getDirtyContacts()) { - try { - App.log.fine("Looking for changed group memberships of contact " + contact.getFileName()); - Set 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(ContactsContract.Groups.CONTENT_URI, groupID))) - .withValue(ContactsContract.Groups.DIRTY, 1) - .withYieldAllowed(true) - )); - } - } catch (FileNotFoundException ignored) { - } - } - batch.commit(); - } - - @Override - protected void postProcess() throws CalendarStorageException, ContactsStorageException { - super.postProcess(); - /* VCard4 group handling: there are group contacts and individual contacts */ - App.log.info("Assigning memberships of downloaded contact groups"); - LocalGroup.applyPendingMemberships(localAddressBook()); - } - - - // helpers - - private LocalAddressBook localAddressBook() { - return (LocalAddressBook) localCollection; - } - - protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException { - InputStream is = new ByteArrayInputStream(cEntry.getContent().getBytes(Charsets.UTF_8)); - Contact.Downloader downloader = new ResourceDownloader(context); - - Contact[] contacts = Contact.fromStream(is, Charsets.UTF_8, downloader); - if (contacts.length == 0) { - App.log.warning("Received VCard without data, ignoring"); - return; - } else if (contacts.length > 1) - App.log.warning("Received multiple VCards, using first one"); - - Contact contact = contacts[0]; - LocalResource local = (LocalResource) localCollection.getByUid(contact.uid); - - - if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { - processContact(contact, local); - } else { - if (local != null) { - App.log.info("Removing local record #" + local.getId() + " which has been deleted on the server"); - local.delete(); - } else { - App.log.warning("Tried deleting a non-existent record: " + contact.uid); - } - } - } - - private LocalResource processContact(final Contact newData, LocalResource local) throws IOException, ContactsStorageException { - String uuid = newData.uid; - // update local contact, if it exists - if (local != null) { - App.log.log(Level.INFO, "Updating " + uuid + " in local address book"); - - if (local instanceof LocalGroup && newData.group) { - // update group - LocalGroup group = (LocalGroup) local; - group.eTag = uuid; - group.updateFromServer(newData); - syncResult.stats.numUpdates++; - - } else if (local instanceof LocalContact && !newData.group) { - // update contact - LocalContact contact = (LocalContact) local; - contact.eTag = uuid; - 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.group) { - App.log.log(Level.INFO, "Creating local group", newData.uid); - LocalGroup group = new LocalGroup(localAddressBook(), newData, uuid, uuid); - group.create(); - - local = group; - } else { - App.log.log(Level.INFO, "Creating local contact", newData.uid); - LocalContact contact = new LocalContact(localAddressBook(), newData, uuid, uuid); - contact.create(); - - local = contact; - } - syncResult.stats.numInserts++; - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && local instanceof LocalContact) - // workaround for Android 7 which sets DIRTY flag when only meta-data is changed - ((LocalContact)local).updateHashCode(null); - - return local; - } - - // downloader helper class - - public static class ResourceDownloader implements Contact.Downloader { - Context context; - - public ResourceDownloader(Context context) { - this.context = context; - } - - @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) { - InputStream stream = body.byteStream(); - try { - if (response.isSuccessful() && stream != null) { - return IOUtils.toByteArray(stream); - } else - App.log.severe("Couldn't download external resource"); - } finally { - if (stream != null) - stream.close(); - } - } - } catch (IOException e) { - App.log.log(Level.SEVERE, "Couldn't download external resource", e); - } - return null; - } - } - -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt new file mode 100644 index 00000000..51109d21 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/ContactsSyncManager.kt @@ -0,0 +1,293 @@ +/* + * 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 com.etesync.syncadapter.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.os.Build +import android.os.Bundle +import android.provider.ContactsContract + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.HttpClient +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.journalmanager.JournalEntryManager +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.SyncEntry +import com.etesync.syncadapter.resource.LocalAddressBook +import com.etesync.syncadapter.resource.LocalContact +import com.etesync.syncadapter.resource.LocalGroup +import com.etesync.syncadapter.resource.LocalResource + +import org.apache.commons.codec.Charsets +import org.apache.commons.collections4.SetUtils +import org.apache.commons.io.IOUtils + +import java.io.ByteArrayInputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.util.logging.Level + +import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.vcard4android.BatchOperation +import at.bitfire.vcard4android.Contact +import at.bitfire.vcard4android.ContactsStorageException +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody + +/** + * + * Synchronization manager for CardDAV collections; handles contacts and groups. + */ +class ContactsSyncManager @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class, ContactsStorageException::class) +constructor(context: Context, account: Account, settings: AccountSettings, extras: Bundle, authority: String, private val provider: ContentProviderClient, result: SyncResult, localAddressBook: LocalAddressBook, private val remote: HttpUrl) : SyncManager(context, account, settings, extras, authority, result, localAddressBook.url, CollectionInfo.Type.ADDRESS_BOOK, localAddressBook.mainAccount.name) { + + protected override val syncErrorTitle: String + get() = context.getString(R.string.sync_error_contacts, account.name) + + protected override val syncSuccessfullyTitle: String + get() = context.getString(R.string.sync_successfully_contacts, account.name) + + init { + + localCollection = localAddressBook + } + + override fun notificationId(): Int { + return Constants.NOTIFICATION_CONTACTS_SYNC + } + + @Throws(ContactsStorageException::class, CalendarStorageException::class) + override fun prepare(): Boolean { + if (!super.prepare()) + return false + val localAddressBook = localAddressBook() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + val reallyDirty = localAddressBook.verifyDirty() + val deleted = localAddressBook.deleted.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) + + journal = JournalEntryManager(httpClient, remote, localAddressBook.url) + + localAddressBook.includeGroups = true + + return true + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class) + override fun prepareDirty() { + super.prepareDirty() + + val addressBook = localAddressBook() + + /* groups as separate VCards: there are group contacts and individual contacts */ + + // mark groups with changed members as dirty + val batch = BatchOperation(addressBook.provider) + for (contact in addressBook.dirtyContacts) { + try { + App.log.fine("Looking for changed group memberships of contact " + contact.fileName) + val cachedGroups = contact.cachedGroupMemberships + val currentGroups = contact.groupMemberships + for (groupID in SetUtils.disjunction(cachedGroups, currentGroups)) { + App.log.fine("Marking group as dirty: " + groupID!!) + batch.enqueue(BatchOperation.Operation( + ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(ContactsContract.Groups.CONTENT_URI, groupID))) + .withValue(ContactsContract.Groups.DIRTY, 1) + .withYieldAllowed(true) + )) + } + } catch (ignored: FileNotFoundException) { + } + + } + batch.commit() + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class) + override fun postProcess() { + super.postProcess() + /* 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 localAddressBook(): LocalAddressBook { + return localCollection as LocalAddressBook + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class) + override fun processSyncEntry(cEntry: SyncEntry) { + val `is` = ByteArrayInputStream(cEntry.content.toByteArray(Charsets.UTF_8)) + val downloader = ResourceDownloader(context) + + val contacts = Contact.fromStream(`is`, Charsets.UTF_8, 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") + + val contact = contacts[0] + val local = localCollection!!.getByUid(contact.uid) as LocalResource + + + if (cEntry.isAction(SyncEntry.Actions.ADD) || cEntry.isAction(SyncEntry.Actions.CHANGE)) { + processContact(contact, local) + } else { + if (local != null) { + App.log.info("Removing local record #" + local.id + " which has been deleted on the server") + local.delete() + } else { + App.log.warning("Tried deleting a non-existent record: " + contact.uid) + } + } + } + + @Throws(IOException::class, ContactsStorageException::class) + private fun processContact(newData: Contact, local: LocalResource?): LocalResource { + var local = local + val uuid = newData.uid + // update local contact, if it exists + if (local != null) { + App.log.log(Level.INFO, "Updating $uuid in local address book") + + if (local is LocalGroup && newData.group) { + // update group + val group = local as LocalGroup? + group!!.eTag = uuid + group.updateFromServer(newData) + syncResult.stats.numUpdates++ + + } else if (local is LocalContact && !newData.group) { + // update contact + val contact = local as LocalContact? + contact!!.eTag = uuid + contact.update(newData) + syncResult.stats.numUpdates++ + + } else { + // group has become an individual contact or vice versa + try { + local.delete() + local = null + } catch (e: CalendarStorageException) { + // CalendarStorageException is not used by LocalGroup and LocalContact + } + + } + } + + if (local == null) { + if (newData.group) { + App.log.log(Level.INFO, "Creating local group", newData.uid) + val group = LocalGroup(localAddressBook(), newData, uuid, uuid) + group.create() + + local = group + } else { + App.log.log(Level.INFO, "Creating local contact", newData.uid) + val contact = LocalContact(localAddressBook(), newData, uuid, uuid) + contact.create() + + local = contact + } + syncResult.stats.numInserts++ + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && local is LocalContact) + // workaround for Android 7 which sets DIRTY flag when only meta-data is changed + local.updateHashCode(null) + + return local + } + + // downloader helper class + + class ResourceDownloader(internal var context: Context) : 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 + // resourceClient = HttpClient.addAuthentication(resourceClient, baseUrl.host(), settings.username(), settings.password()); + + // allow redirects + resourceClient = resourceClient.newBuilder() + .followRedirects(true) + .build() + + try { + val response = resourceClient.newCall(Request.Builder() + .get() + .url(httpUrl) + .build()).execute() + + val body = response.body() + if (body != null) { + val stream = body.byteStream() + try { + if (response.isSuccessful && stream != null) { + return IOUtils.toByteArray(stream) + } else + App.log.severe("Couldn't download external resource") + } finally { + stream?.close() + } + } + } catch (e: IOException) { + App.log.log(Level.SEVERE, "Couldn't download external resource", e) + } + + return null + } + } + +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.java deleted file mode 100644 index 4bbda624..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.java +++ /dev/null @@ -1,97 +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 com.etesync.syncadapter.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 com.etesync.syncadapter.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; - } - - @Override - public Bundle getAccountRemovalAllowed(AccountAuthenticatorResponse response, Account account) { - Bundle result = new Bundle(); - boolean allowed = false; // we don't want users to explicitly delete inner accounts - result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, allowed); - return result; - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.kt new file mode 100644 index 00000000..879e60de --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/NullAuthenticatorService.kt @@ -0,0 +1,84 @@ +/* + * 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 com.etesync.syncadapter.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 com.etesync.syncadapter.ui.AccountsActivity + +class NullAuthenticatorService : Service() { + + private var accountAuthenticator: AccountAuthenticator? = null + + override fun onCreate() { + accountAuthenticator = NullAuthenticatorService.AccountAuthenticator(this) + } + + override fun onBind(intent: Intent): IBinder? { + return if (intent.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT) accountAuthenticator!!.iBinder else null + } + + + private class AccountAuthenticator(internal val context: Context) : AbstractAccountAuthenticator(context) { + + override fun editProperties(response: AccountAuthenticatorResponse, accountType: String): Bundle? { + return null + } + + @Throws(NetworkErrorException::class) + override fun addAccount(response: AccountAuthenticatorResponse, accountType: String, authTokenType: String, requiredFeatures: Array, options: Bundle): Bundle { + val intent = Intent(context, AccountsActivity::class.java) + intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response) + val bundle = Bundle() + bundle.putParcelable(AccountManager.KEY_INTENT, intent) + return bundle + } + + @Throws(NetworkErrorException::class) + override fun confirmCredentials(response: AccountAuthenticatorResponse, account: Account, options: Bundle): Bundle? { + return null + } + + @Throws(NetworkErrorException::class) + override fun getAuthToken(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? { + return null + } + + override fun getAuthTokenLabel(authTokenType: String): String? { + return null + } + + @Throws(NetworkErrorException::class) + override fun updateCredentials(response: AccountAuthenticatorResponse, account: Account, authTokenType: String, options: Bundle): Bundle? { + return null + } + + @Throws(NetworkErrorException::class) + override fun hasFeatures(response: AccountAuthenticatorResponse, account: Account, features: Array): Bundle? { + return null + } + + override fun getAccountRemovalAllowed(response: AccountAuthenticatorResponse, account: Account): Bundle { + val result = Bundle() + val allowed = false // we don't want users to explicitly delete inner accounts + result.putBoolean(AccountManager.KEY_BOOLEAN_RESULT, allowed) + return result + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java deleted file mode 100644 index 9b5a92f3..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.java +++ /dev/null @@ -1,217 +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 com.etesync.syncadapter.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 android.support.v4.util.Pair; - -import java.util.HashMap; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.HttpClient; -import com.etesync.syncadapter.InvalidAccountException; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Crypto; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.journalmanager.JournalManager; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.JournalModel; -import com.etesync.syncadapter.model.ServiceEntity; -import com.etesync.syncadapter.ui.PermissionsActivity; - -import io.requery.Persistable; -import io.requery.sql.EntityDataStore; -import okhttp3.HttpUrl; -import okhttp3.OkHttpClient; - -//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; - } - - protected class RefreshCollections { - final private Account account; - final private Context context; - final private CollectionInfo.Type serviceType; - - RefreshCollections(Account account, CollectionInfo.Type serviceType) { - this.account = account; - this.serviceType = serviceType; - context = getContext(); - } - - void run() throws Exceptions.HttpException, Exceptions.IntegrityException, InvalidAccountException, Exceptions.GenericCryptoException { - App.log.info("Refreshing " + serviceType + " collections of service #" + serviceType.toString()); - - AccountSettings settings = new AccountSettings(context, account); - OkHttpClient httpClient = HttpClient.create(context, settings); - - JournalManager journalsManager = new JournalManager(httpClient, HttpUrl.get(settings.getUri())); - - List> journals = new LinkedList<>(); - - for (JournalManager.Journal journal : journalsManager.list()) { - Crypto.CryptoManager crypto; - if (journal.getKey() != null) { - crypto = new Crypto.CryptoManager(journal.getVersion(), settings.getKeyPair(), journal.getKey()); - } else { - crypto = new Crypto.CryptoManager(journal.getVersion(), settings.password(), journal.getUid()); - } - - journal.verify(crypto); - - CollectionInfo info = CollectionInfo.fromJson(journal.getContent(crypto)); - info.updateFromJournal(journal); - - if (info.type.equals(serviceType)) { - journals.add(new Pair<>(journal, info)); - } - } - - if (journals.isEmpty()) { - CollectionInfo info = CollectionInfo.defaultForServiceType(serviceType); - info.uid = JournalManager.Journal.genUid(); - Crypto.CryptoManager crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); - JournalManager.Journal journal = new JournalManager.Journal(crypto, info.toJson(), info.uid); - journalsManager.create(journal); - journals.add(new Pair<>(journal, info)); - } - - saveCollections(journals); - } - - private void saveCollections(Iterable> journals) { - EntityDataStore data = ((App) context.getApplicationContext()).getData(); - ServiceEntity service = JournalModel.Service.fetch(data, account.name, serviceType); - - Map existing = new HashMap<>(); - for (JournalEntity journalEntity : JournalEntity.getJournals(data, service)) { - existing.put(journalEntity.getUid(), journalEntity); - } - - for (Pair pair : journals) { - JournalManager.Journal journal = pair.first; - CollectionInfo collection = pair.second; - App.log.log(Level.FINE, "Saving collection", journal.getUid()); - - collection.serviceID = service.getId(); - JournalEntity journalEntity = JournalEntity.fetchOrCreate(data, collection); - journalEntity.setOwner(journal.getOwner()); - journalEntity.setEncryptedKey(journal.getKey()); - journalEntity.setReadOnly(journal.isReadOnly()); - journalEntity.setDeleted(false); - data.upsert(journalEntity); - - existing.remove(collection.uid); - } - - for (JournalEntity journalEntity : existing.values()) { - App.log.log(Level.FINE, "Deleting collection", journalEntity.getUid()); - - journalEntity.setDeleted(true); - data.update(journalEntity); - } - } - } - } -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt new file mode 100644 index 00000000..5311b5aa --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncAdapterService.kt @@ -0,0 +1,202 @@ +/* + * Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package com.etesync.syncadapter.syncadapter + +import android.accounts.Account +import android.app.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.v4.app.NotificationCompat +import android.support.v4.app.NotificationManagerCompat +import android.support.v4.util.Pair + +import java.util.HashMap +import java.util.LinkedList +import java.util.logging.Level + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.HttpClient +import com.etesync.syncadapter.InvalidAccountException +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Crypto +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.journalmanager.JournalManager +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.JournalModel +import com.etesync.syncadapter.model.ServiceEntity +import com.etesync.syncadapter.ui.PermissionsActivity + +import io.requery.Persistable +import io.requery.sql.EntityDataStore +import okhttp3.HttpUrl +import okhttp3.OkHttpClient + +//import com.android.vending.billing.IInAppBillingService; + +abstract class SyncAdapterService : Service() { + + protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter + + override fun onBind(intent: Intent): IBinder? { + return syncAdapter().syncAdapterBinder + } + + + abstract class SyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, false) { + + 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().toTypedArray()) + + // required for dav4android (ServiceLoader) + Thread.currentThread().contextClassLoader = context.classLoader + } + + 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.syncWifiOnly) { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val network = cm.activeNetworkInfo + if (network == null) { + App.log.info("No network available, stopping") + return false + } + if (network.type != ConnectivityManager.TYPE_WIFI || !network.isConnected) { + App.log.info("Not on connected WiFi, stopping") + return false + } + + var onlySSID = settings.syncWifiOnlySSID + if (onlySSID != null) { + onlySSID = "\"" + onlySSID + "\"" + val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val info = wifi.connectionInfo + if (info == null || onlySSID != info.ssid) { + App.log.info("Connected to wrong WiFi network (" + info!!.ssid + ", required: " + onlySSID + "), ignoring") + return false + } + } + } + return true + } + + inner class RefreshCollections internal constructor(private val account: Account, private val serviceType: CollectionInfo.Type) { + private val context: Context + + init { + context = getContext() + } + + @Throws(Exceptions.HttpException::class, Exceptions.IntegrityException::class, InvalidAccountException::class, Exceptions.GenericCryptoException::class) + internal fun run() { + App.log.info("Refreshing " + serviceType + " collections of service #" + serviceType.toString()) + + val settings = AccountSettings(context, account) + val httpClient = HttpClient.create(context, settings) + + val journalsManager = JournalManager(httpClient, HttpUrl.get(settings.uri!!)!!) + + val journals = LinkedList>() + + for (journal in journalsManager.list()) { + val crypto: Crypto.CryptoManager + if (journal.key != null) { + crypto = Crypto.CryptoManager(journal.version, settings.keyPair!!, journal.key) + } else { + crypto = Crypto.CryptoManager(journal.version, settings.password(), journal.uid!!) + } + + journal.verify(crypto) + + val info = CollectionInfo.fromJson(journal.getContent(crypto)) + info.updateFromJournal(journal) + + if (info.type == serviceType) { + journals.add(Pair(journal, info)) + } + } + + if (journals.isEmpty()) { + val info = CollectionInfo.defaultForServiceType(serviceType) + info.uid = JournalManager.Journal.genUid() + val crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid) + val journal = JournalManager.Journal(crypto, info.toJson(), info.uid) + journalsManager.create(journal) + journals.add(Pair(journal, info)) + } + + saveCollections(journals) + } + + private fun saveCollections(journals: Iterable>) { + val data = (context.applicationContext as App).data + val service = JournalModel.Service.fetch(data, account.name, serviceType) + + val existing = HashMap() + for (journalEntity in JournalEntity.getJournals(data, service)) { + existing[journalEntity.uid] = journalEntity + } + + for (pair in journals) { + val journal = pair.first + val collection = pair.second + App.log.log(Level.FINE, "Saving collection", journal!!.uid) + + collection!!.serviceID = service.id + val journalEntity = JournalEntity.fetchOrCreate(data, collection) + journalEntity.owner = journal.owner + journalEntity.encryptedKey = journal.key + journalEntity.isReadOnly = journal.isReadOnly + journalEntity.isDeleted = false + data.upsert(journalEntity) + + existing.remove(collection.uid) + } + + for (journalEntity in existing.values) { + App.log.log(Level.FINE, "Deleting collection", journalEntity.uid) + + journalEntity.isDeleted = true + data.update(journalEntity) + } + } + } + } +} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java deleted file mode 100644 index af9bd527..00000000 --- a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.java +++ /dev/null @@ -1,522 +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 com.etesync.syncadapter.syncadapter; - -import android.accounts.Account; -import android.annotation.TargetApi; -import android.content.Context; -import android.content.Intent; -import android.content.SyncResult; -import android.content.res.Resources; -import android.os.Bundle; - -import com.etesync.syncadapter.AccountSettings; -import com.etesync.syncadapter.App; -import com.etesync.syncadapter.Constants; -import com.etesync.syncadapter.HttpClient; -import com.etesync.syncadapter.NotificationHelper; -import com.etesync.syncadapter.R; -import com.etesync.syncadapter.journalmanager.Crypto; -import com.etesync.syncadapter.journalmanager.Exceptions; -import com.etesync.syncadapter.journalmanager.JournalEntryManager; -import com.etesync.syncadapter.model.CollectionInfo; -import com.etesync.syncadapter.model.EntryEntity; -import com.etesync.syncadapter.model.JournalEntity; -import com.etesync.syncadapter.model.JournalModel; -import com.etesync.syncadapter.model.ServiceDB; -import com.etesync.syncadapter.model.ServiceEntity; -import com.etesync.syncadapter.model.Settings; -import com.etesync.syncadapter.model.SyncEntry; -import com.etesync.syncadapter.resource.LocalCollection; -import com.etesync.syncadapter.resource.LocalResource; -import com.etesync.syncadapter.ui.DebugInfoActivity; -import com.etesync.syncadapter.ui.ViewCollectionActivity; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.logging.Level; - -import at.bitfire.ical4android.CalendarStorageException; -import at.bitfire.ical4android.InvalidCalendarException; -import at.bitfire.vcard4android.ContactsStorageException; -import io.requery.Persistable; -import io.requery.sql.EntityDataStore; -import okhttp3.OkHttpClient; - -import static com.etesync.syncadapter.Constants.KEY_ACCOUNT; -import static com.etesync.syncadapter.model.SyncEntry.Actions.ADD; - -abstract public class SyncManager { - private static final int MAX_FETCH = 50; - private static final int MAX_PUSH = 30; - - protected final NotificationHelper notificationManager; - protected final CollectionInfo info; - - protected final Context context; - protected final Account account; - protected final Bundle extras; - protected final String authority; - protected final SyncResult syncResult; - protected final CollectionInfo.Type serviceType; - - protected final AccountSettings settings; - protected LocalCollection localCollection; - - protected OkHttpClient httpClient; - - protected JournalEntryManager journal; - private JournalEntity _journalEntity; - - private final Crypto.CryptoManager crypto; - - private EntityDataStore data; - - /** - * remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works. - */ - private String remoteCTag = null; - - /** - * Syncable local journal entries. - */ - private List localEntries; - - /** - * Syncable remote journal entries (fetch from server). - */ - private List remoteEntries; - - /** - * Dirty and deleted resources. We need to save them so we safely ignore ones that were added after we started. - */ - private List localDeleted; - protected LocalResource[] localDirty; - - public SyncManager(Context context, Account account, AccountSettings settings, Bundle extras, String authority, SyncResult syncResult, String journalUid, CollectionInfo.Type serviceType, String accountName) throws Exceptions.IntegrityException, Exceptions.GenericCryptoException { - this.context = context; - this.account = account; - this.settings = settings; - this.extras = extras; - this.authority = authority; - this.syncResult = syncResult; - this.serviceType = serviceType; - - // create HttpClient with given logger - httpClient = HttpClient.create(context, settings); - - data = ((App) context.getApplicationContext()).getData(); - ServiceEntity serviceEntity = JournalModel.Service.fetch(data, accountName, serviceType); - info = JournalEntity.fetch(data, serviceEntity, journalUid).getInfo(); - - // dismiss previous error notifications - notificationManager = new NotificationHelper(context, journalUid, notificationId()); - notificationManager.cancel(); - - App.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version)); - - if (getJournalEntity().getEncryptedKey() != null) { - crypto = new Crypto.CryptoManager(info.version, settings.getKeyPair(), getJournalEntity().getEncryptedKey()); - } else { - crypto = new Crypto.CryptoManager(info.version, settings.password(), info.uid); - } - } - - protected abstract int notificationId(); - - protected abstract String getSyncErrorTitle(); - - protected abstract String getSyncSuccessfullyTitle(); - - @TargetApi(21) - public void performSync() { - int syncPhase = R.string.sync_phase_prepare; - try { - App.log.info("Sync phase: " + context.getString(syncPhase)); - if (!prepare()) { - App.log.info("No reason to synchronize, aborting"); - return; - } - - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_query_capabilities; - App.log.info("Sync phase: " + context.getString(syncPhase)); - queryCapabilities(); - - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_prepare_local; - App.log.info("Sync phase: " + context.getString(syncPhase)); - prepareLocal(); - - do { - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_fetch_entries; - App.log.info("Sync phase: " + context.getString(syncPhase)); - fetchEntries(); - - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_apply_remote_entries; - App.log.info("Sync phase: " + context.getString(syncPhase)); - applyRemoteEntries(); - } while (remoteEntries.size() == MAX_FETCH); - - do { - /* Create journal entries out of local changes. */ - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_create_local_entries; - App.log.info("Sync phase: " + context.getString(syncPhase)); - createLocalEntries(); - - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_apply_local_entries; - App.log.info("Sync phase: " + context.getString(syncPhase)); - /* FIXME: Skipping this now, because we already override with remote. - applyLocalEntries(); - */ - - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_push_entries; - App.log.info("Sync phase: " + context.getString(syncPhase)); - pushEntries(); - } while (localEntries.size() == MAX_PUSH); - - /* Cleanup and finalize changes */ - if (Thread.interrupted()) - throw new InterruptedException(); - syncPhase = R.string.sync_phase_post_processing; - App.log.info("Sync phase: " + context.getString(syncPhase)); - postProcess(); - - notifyUserOnSync(); - - App.log.info("Finished sync with CTag=" + remoteCTag); - } catch (IOException e) { - App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e); - syncResult.stats.numIoExceptions++; - } catch (Exceptions.ServiceUnavailableException e) { - syncResult.stats.numIoExceptions++; - syncResult.delayUntil = (e.getRetryAfter() > 0) ? e.getRetryAfter() : Constants.DEFAULT_RETRY_DELAY; - } catch (InterruptedException e) { - // Restart sync if interrupted - syncResult.fullSyncRequested = true; - } catch (Exception | OutOfMemoryError e) { - if (e instanceof Exceptions.UnauthorizedException) { - syncResult.stats.numAuthExceptions++; - } else if (e instanceof Exceptions.HttpException) { - syncResult.stats.numParseExceptions++; - } else if (e instanceof CalendarStorageException || e instanceof ContactsStorageException) { - syncResult.databaseError = true; - } else if (e instanceof Exceptions.IntegrityException) { - syncResult.stats.numParseExceptions++; - } else { - syncResult.stats.numParseExceptions++; - } - - notificationManager.setThrowable(e); - - final Intent detailsIntent = notificationManager.getDetailsIntent(); - detailsIntent.putExtra(KEY_ACCOUNT, account); - if (!(e instanceof Exceptions.UnauthorizedException)) { - detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority); - detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); - } - - notificationManager.notify(getSyncErrorTitle(), context.getString(syncPhase)); - } - } - - private void notifyUserOnSync() { - Settings.ChangeNotification changeNotification = - new Settings(new ServiceDB.OpenHelper(context).getReadableDatabase()) - .getChangeNotification(App.CHANGE_NOTIFICATION); - if (remoteEntries.isEmpty() || - changeNotification.equals(Settings.ChangeNotification.NONE)) { - return; - } - NotificationHelper notificationHelper = new NotificationHelper(context, - String.valueOf(System.currentTimeMillis()), notificationId()); - - int deleted = 0; - int added = 0; - int changed = 0; - for (JournalEntryManager.Entry entry : remoteEntries) { - SyncEntry cEntry = SyncEntry.fromJournalEntry(crypto, entry); - SyncEntry.Actions action = cEntry.getAction(); - switch (action) { - case ADD: - added++; - break; - case DELETE: - deleted++; - break; - case CHANGE: - changed++; - break; - } - } - - Resources resources = context.getResources(); - Intent intent = ViewCollectionActivity.newIntent(context, account, info); - notificationHelper.notify(getSyncSuccessfullyTitle(), - String.format(context.getString(R.string.sync_successfully_modified), - resources.getQuantityString(R.plurals.sync_successfully, - remoteEntries.size(), remoteEntries.size())), - String.format(context.getString(R.string.sync_successfully_modified_full), - resources.getQuantityString(R.plurals.sync_successfully, - added, added), - resources.getQuantityString(R.plurals.sync_successfully, - changed, changed), - resources.getQuantityString(R.plurals.sync_successfully, - deleted, deleted)), - intent); - } - - /** - * 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 - */ - protected boolean prepare() throws ContactsStorageException, CalendarStorageException { - return true; - } - - abstract protected void processSyncEntry(SyncEntry cEntry) throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException; - - private JournalEntity getJournalEntity() { - if (_journalEntity == null) - _journalEntity = JournalModel.Journal.fetch(data, info.getServiceEntity(data), info.uid); - return _journalEntity; - } - - private void persistSyncEntry(String uid, SyncEntry syncEntry) { - EntryEntity entry = new EntryEntity(); - entry.setUid(uid); - entry.setContent(syncEntry); - entry.setJournal(getJournalEntity()); - data.insert(entry); - } - - protected void applyLocalEntries() throws IOException, ContactsStorageException, CalendarStorageException, Exceptions.HttpException, InvalidCalendarException, InterruptedException { - // FIXME: Need a better strategy - // We re-apply local entries so our changes override whatever was written in the remote. - String strTotal = String.valueOf(localEntries.size()); - int i = 0; - - for (JournalEntryManager.Entry entry : localEntries) { - if (Thread.interrupted()) { - throw new InterruptedException(); - } - i++; - App.log.info("Processing (" + String.valueOf(i) + "/" + strTotal + ") " + entry.toString()); - - SyncEntry cEntry = SyncEntry.fromJournalEntry(crypto, entry); - if (cEntry.isAction(SyncEntry.Actions.DELETE)) { - continue; - } - App.log.info("Processing resource for journal entry"); - processSyncEntry(cEntry); - } - } - - protected void queryCapabilities() throws IOException, CalendarStorageException, ContactsStorageException { - } - - protected void fetchEntries() throws Exceptions.HttpException, ContactsStorageException, CalendarStorageException, Exceptions.IntegrityException { - int count = data.count(EntryEntity.class).where(EntryEntity.JOURNAL.eq(getJournalEntity())).get().value(); - if ((remoteCTag != null) && (count == 0)) { - // If we are updating an existing installation with no saved journal, we need to add - remoteEntries = journal.list(crypto, null, MAX_FETCH); - int i = 0; - for (JournalEntryManager.Entry entry : remoteEntries) { - SyncEntry cEntry = SyncEntry.fromJournalEntry(crypto, entry); - persistSyncEntry(entry.getUid(), cEntry); - i++; - if (remoteCTag.equals(entry.getUid())) { - remoteEntries.subList(0, i).clear(); - break; - } - } - } else { - remoteEntries = journal.list(crypto, remoteCTag, MAX_FETCH); - } - - App.log.info("Fetched " + String.valueOf(remoteEntries.size()) + " entries"); - } - - protected void applyRemoteEntries() throws IOException, ContactsStorageException, CalendarStorageException, InvalidCalendarException, InterruptedException { - // Process new vcards from server - String strTotal = String.valueOf(remoteEntries.size()); - int i = 0; - - for (JournalEntryManager.Entry entry : remoteEntries) { - if (Thread.interrupted()) { - throw new InterruptedException(); - } - i++; - App.log.info("Processing (" + String.valueOf(i) + "/" + strTotal + ") " + entry.toString()); - - SyncEntry cEntry = SyncEntry.fromJournalEntry(crypto, entry); - App.log.info("Processing resource for journal entry"); - processSyncEntry(cEntry); - - persistSyncEntry(entry.getUid(), cEntry); - - remoteCTag = entry.getUid(); - } - } - - protected void pushEntries() throws Exceptions.HttpException, IOException, ContactsStorageException, CalendarStorageException { - // upload dirty contacts - int pushed = 0; - // FIXME: Deal with failure (someone else uploaded before we go here) - try { - if (!localEntries.isEmpty()) { - List entries = localEntries; - journal.create(entries, remoteCTag); - // Persist the entries after they've been pushed - for (JournalEntryManager.Entry entry : entries) { - SyncEntry cEntry = SyncEntry.fromJournalEntry(crypto, entry); - persistSyncEntry(entry.getUid(), cEntry); - } - remoteCTag = entries.get(entries.size() - 1).getUid(); - pushed += entries.size(); - } - } finally { - // FIXME: A bit fragile, we assume the order in createLocalEntries - int left = pushed; - for (LocalResource local : localDeleted) { - if (pushed-- <= 0) { - break; - } - local.delete(); - } - if (left > 0) { - localDeleted.subList(0, Math.min(left, localDeleted.size())).clear(); - } - - left = pushed; - for (LocalResource local : localDirty) { - if (pushed-- <= 0) { - break; - } - App.log.info("Added/changed resource with UUID: " + local.getUuid()); - local.clearDirty(local.getUuid()); - } - if (left > 0) { - localDirty = Arrays.copyOfRange(localDirty, left, localDirty.length); - } - - if (pushed > 0) { - App.log.severe("Unprocessed localentries left, this should never happen!"); - } - } - } - - protected void createLocalEntries() throws CalendarStorageException, ContactsStorageException, IOException { - localEntries = new LinkedList<>(); - - // Not saving, just creating a fake one until we load it from a local db - JournalEntryManager.Entry previousEntry = (remoteCTag != null) ? JournalEntryManager.Entry.getFakeWithUid(remoteCTag) : null; - - for (LocalResource local : localDeleted) { - SyncEntry entry = new SyncEntry(local.getContent(), SyncEntry.Actions.DELETE); - JournalEntryManager.Entry tmp = new JournalEntryManager.Entry(); - tmp.update(crypto, entry.toJson(), previousEntry); - previousEntry = tmp; - localEntries.add(previousEntry); - - if (localEntries.size() == MAX_PUSH) { - return; - } - } - - for (LocalResource local : localDirty) { - SyncEntry.Actions action; - if (local.isLocalOnly()) { - action = ADD; - } else { - action = SyncEntry.Actions.CHANGE; - } - - SyncEntry entry = new SyncEntry(local.getContent(), action); - JournalEntryManager.Entry tmp = new JournalEntryManager.Entry(); - tmp.update(crypto, entry.toJson(), previousEntry); - previousEntry = tmp; - localEntries.add(previousEntry); - - if (localEntries.size() == MAX_PUSH) { - return; - } - } - } - - /** - */ - protected void prepareLocal() throws CalendarStorageException, ContactsStorageException, FileNotFoundException { - remoteCTag = getJournalEntity().getLastUid(data); - - localDeleted = processLocallyDeleted(); - localDirty = localCollection.getDirty(); - // This is done after fetching the local dirty so all the ones we are using will be prepared - prepareDirty(); - } - - - /** - * Delete unpublished locally deleted, and return the rest. - * Checks Thread.interrupted() before each request to allow quick sync cancellation. - */ - private List processLocallyDeleted() throws CalendarStorageException, ContactsStorageException { - LocalResource[] localList = localCollection.getDeleted(); - List ret = new ArrayList<>(localList.length); - - for (LocalResource local : localList) { - if (Thread.interrupted()) - return ret; - - App.log.info(local.getUuid() + " has been deleted locally -> deleting from server"); - ret.add(local); - - syncResult.stats.numDeletes++; - } - - return ret; - } - - protected void prepareDirty() throws CalendarStorageException, ContactsStorageException { - // assign file names and UIDs to new entries - App.log.info("Looking for local entries without a uuid"); - for (LocalResource local : localDirty) { - if (local.getUuid() != null) { - continue; - } - - App.log.fine("Found local record #" + local.getId() + " without file name; generating file name/UID if necessary"); - local.prepareForUpload(); - } - } - - /** - * For post-processing of entries, for instance assigning groups. - */ - protected void postProcess() throws CalendarStorageException, ContactsStorageException { - } -} diff --git a/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt new file mode 100644 index 00000000..760d1064 --- /dev/null +++ b/app/src/main/java/com/etesync/syncadapter/syncadapter/SyncManager.kt @@ -0,0 +1,523 @@ +/* +* 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 com.etesync.syncadapter.syncadapter + +import android.accounts.Account +import android.annotation.TargetApi +import android.content.Context +import android.content.Intent +import android.content.SyncResult +import android.content.res.Resources +import android.os.Bundle + +import com.etesync.syncadapter.AccountSettings +import com.etesync.syncadapter.App +import com.etesync.syncadapter.Constants +import com.etesync.syncadapter.HttpClient +import com.etesync.syncadapter.NotificationHelper +import com.etesync.syncadapter.R +import com.etesync.syncadapter.journalmanager.Crypto +import com.etesync.syncadapter.journalmanager.Exceptions +import com.etesync.syncadapter.journalmanager.JournalEntryManager +import com.etesync.syncadapter.model.CollectionInfo +import com.etesync.syncadapter.model.EntryEntity +import com.etesync.syncadapter.model.JournalEntity +import com.etesync.syncadapter.model.JournalModel +import com.etesync.syncadapter.model.ServiceDB +import com.etesync.syncadapter.model.ServiceEntity +import com.etesync.syncadapter.model.Settings +import com.etesync.syncadapter.model.SyncEntry +import com.etesync.syncadapter.resource.LocalCollection +import com.etesync.syncadapter.resource.LocalResource +import com.etesync.syncadapter.ui.DebugInfoActivity +import com.etesync.syncadapter.ui.ViewCollectionActivity + +import java.io.FileNotFoundException +import java.io.IOException +import java.util.ArrayList +import java.util.Arrays +import java.util.LinkedList +import java.util.Locale +import java.util.logging.Level + +import at.bitfire.ical4android.CalendarStorageException +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.vcard4android.ContactsStorageException +import io.requery.Persistable +import io.requery.sql.EntityDataStore +import okhttp3.OkHttpClient + +import com.etesync.syncadapter.Constants.KEY_ACCOUNT +import com.etesync.syncadapter.model.SyncEntry.Actions.ADD + +abstract class SyncManager @Throws(Exceptions.IntegrityException::class, Exceptions.GenericCryptoException::class) +constructor(protected val context: Context, protected val account: Account, protected val settings: AccountSettings, protected val extras: Bundle, protected val authority: String, protected val syncResult: SyncResult, journalUid: String, protected val serviceType: CollectionInfo.Type, accountName: String) { + + protected val notificationManager: NotificationHelper + protected val info: CollectionInfo + protected var localCollection: LocalCollection? = null + + protected var httpClient: OkHttpClient + + protected var journal: JournalEntryManager? = null + private var _journalEntity: JournalEntity? = null + + private val crypto: Crypto.CryptoManager + + private val data: EntityDataStore + + /** + * remote CTag (uuid of the last entry on the server). We update it when we fetch/push and save when everything works. + */ + private var remoteCTag: String? = null + + /** + * Syncable local journal entries. + */ + private var localEntries: MutableList? = null + + /** + * Syncable remote journal entries (fetch from server). + */ + private var remoteEntries: List? = null + + /** + * Dirty and deleted resources. We need to save them so we safely ignore ones that were added after we started. + */ + private var localDeleted: List? = null + protected var localDirty: Array = arrayOf() + + protected abstract val syncErrorTitle: String + + protected abstract val syncSuccessfullyTitle: String + + private val journalEntity: JournalEntity + get() = JournalModel.Journal.fetch(data, info.getServiceEntity(data), info.uid) + + init { + + // create HttpClient with given logger + httpClient = HttpClient.create(context, settings) + + data = (context.applicationContext as App).data + val serviceEntity = JournalModel.Service.fetch(data, accountName, serviceType) + info = JournalEntity.fetch(data, serviceEntity, journalUid)!!.info + + // dismiss previous error notifications + notificationManager = NotificationHelper(context, journalUid, notificationId()) + notificationManager.cancel() + + App.log.info(String.format(Locale.getDefault(), "Syncing collection %s (version: %d)", journalUid, info.version)) + + if (journalEntity.encryptedKey != null) { + crypto = Crypto.CryptoManager(info.version, settings.keyPair!!, journalEntity.encryptedKey) + } else { + crypto = Crypto.CryptoManager(info.version, settings.password(), info.uid) + } + } + + protected abstract fun notificationId(): Int + + @TargetApi(21) + fun performSync() { + var syncPhase = R.string.sync_phase_prepare + try { + App.log.info("Sync phase: " + context.getString(syncPhase)) + if (!prepare()) { + App.log.info("No reason to synchronize, aborting") + return + } + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_query_capabilities + App.log.info("Sync phase: " + context.getString(syncPhase)) + queryCapabilities() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_prepare_local + App.log.info("Sync phase: " + context.getString(syncPhase)) + prepareLocal() + + do { + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_fetch_entries + App.log.info("Sync phase: " + context.getString(syncPhase)) + fetchEntries() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_apply_remote_entries + App.log.info("Sync phase: " + context.getString(syncPhase)) + applyRemoteEntries() + } while (remoteEntries!!.size == MAX_FETCH) + + do { + /* Create journal entries out of local changes. */ + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_create_local_entries + App.log.info("Sync phase: " + context.getString(syncPhase)) + createLocalEntries() + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_apply_local_entries + App.log.info("Sync phase: " + context.getString(syncPhase)) + /* FIXME: Skipping this now, because we already override with remote. + applyLocalEntries(); + */ + + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_push_entries + App.log.info("Sync phase: " + context.getString(syncPhase)) + pushEntries() + } while (localEntries!!.size == MAX_PUSH) + + /* Cleanup and finalize changes */ + if (Thread.interrupted()) + throw InterruptedException() + syncPhase = R.string.sync_phase_post_processing + App.log.info("Sync phase: " + context.getString(syncPhase)) + postProcess() + + notifyUserOnSync() + + App.log.info("Finished sync with CTag=" + remoteCTag!!) + } catch (e: IOException) { + App.log.log(Level.WARNING, "I/O exception during sync, trying again later", e) + syncResult.stats.numIoExceptions++ + } catch (e: Exceptions.ServiceUnavailableException) { + syncResult.stats.numIoExceptions++ + syncResult.delayUntil = if (e.retryAfter > 0) e.retryAfter else Constants.DEFAULT_RETRY_DELAY + } catch (e: InterruptedException) { + // Restart sync if interrupted + syncResult.fullSyncRequested = true + } catch (e: Exception) { + if (e is Exceptions.UnauthorizedException) { + syncResult.stats.numAuthExceptions++ + } else if (e is Exceptions.HttpException) { + syncResult.stats.numParseExceptions++ + } else if (e is CalendarStorageException || e is ContactsStorageException) { + syncResult.databaseError = true + } else if (e is Exceptions.IntegrityException) { + syncResult.stats.numParseExceptions++ + } else { + syncResult.stats.numParseExceptions++ + } + + notificationManager.setThrowable(e) + + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + if (e !is Exceptions.UnauthorizedException) { + detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority) + detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase) + } + + notificationManager.notify(syncErrorTitle, context.getString(syncPhase)) + } catch (e: OutOfMemoryError) { + if (e is Exceptions.HttpException) { + syncResult.stats.numParseExceptions++ + } else if (e is CalendarStorageException || e is ContactsStorageException) { + syncResult.databaseError = true + } else { + syncResult.stats.numParseExceptions++ + } + notificationManager.setThrowable(e) + val detailsIntent = notificationManager.detailsIntent + detailsIntent.putExtra(KEY_ACCOUNT, account) + notificationManager.notify(syncErrorTitle, context.getString(syncPhase)) + } + + } + + private fun notifyUserOnSync() { + val changeNotification = Settings(ServiceDB.OpenHelper(context).readableDatabase) + .getChangeNotification(App.CHANGE_NOTIFICATION) + if (remoteEntries!!.isEmpty() || changeNotification == Settings.ChangeNotification.NONE) { + return + } + val notificationHelper = NotificationHelper(context, + System.currentTimeMillis().toString(), notificationId()) + + var deleted = 0 + var added = 0 + var changed = 0 + for (entry in remoteEntries!!) { + val cEntry = SyncEntry.fromJournalEntry(crypto, entry) + val action = cEntry.action + when (action) { + ADD -> added++ + SyncEntry.Actions.DELETE -> deleted++ + SyncEntry.Actions.CHANGE -> changed++ + } + } + + val resources = context.resources + val intent = ViewCollectionActivity.newIntent(context, account, info) + notificationHelper.notify(syncSuccessfullyTitle, + String.format(context.getString(R.string.sync_successfully_modified), + resources.getQuantityString(R.plurals.sync_successfully, + remoteEntries!!.size, remoteEntries!!.size)), + String.format(context.getString(R.string.sync_successfully_modified_full), + resources.getQuantityString(R.plurals.sync_successfully, + added, added), + resources.getQuantityString(R.plurals.sync_successfully, + changed, changed), + resources.getQuantityString(R.plurals.sync_successfully, + deleted, deleted)), + intent) + } + + /** + * 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 + */ + @Throws(ContactsStorageException::class, CalendarStorageException::class) + protected open fun prepare(): Boolean { + return true + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class) + protected abstract fun processSyncEntry(cEntry: SyncEntry) + + private fun persistSyncEntry(uid: String?, syncEntry: SyncEntry) { + val entry = EntryEntity() + entry.uid = uid + entry.content = syncEntry + entry.journal = journalEntity + data.insert(entry) + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.HttpException::class, InvalidCalendarException::class, InterruptedException::class) + protected fun applyLocalEntries() { + // FIXME: Need a better strategy + // We re-apply local entries so our changes override whatever was written in the remote. + val strTotal = localEntries!!.size.toString() + var i = 0 + + for (entry in localEntries!!) { + if (Thread.interrupted()) { + throw InterruptedException() + } + i++ + App.log.info("Processing (" + i.toString() + "/" + strTotal + ") " + entry.toString()) + + val cEntry = SyncEntry.fromJournalEntry(crypto, entry) + if (cEntry.isAction(SyncEntry.Actions.DELETE)) { + continue + } + App.log.info("Processing resource for journal entry") + processSyncEntry(cEntry) + } + } + + @Throws(IOException::class, CalendarStorageException::class, ContactsStorageException::class) + protected fun queryCapabilities() { + } + + @Throws(Exceptions.HttpException::class, ContactsStorageException::class, CalendarStorageException::class, Exceptions.IntegrityException::class) + protected fun fetchEntries() { + val count = data.count(EntryEntity::class.java).where(EntryEntity.JOURNAL.eq(journalEntity)).get().value() + if (remoteCTag != null && count == 0) { + // If we are updating an existing installation with no saved journal, we need to add + remoteEntries = journal!!.list(crypto, null, MAX_FETCH) + var i = 0 + for (entry in remoteEntries!!) { + val cEntry = SyncEntry.fromJournalEntry(crypto, entry) + persistSyncEntry(entry.uid, cEntry) + i++ + if (remoteCTag == entry.uid) { + remoteEntries = remoteEntries?.drop(i) + break + } + } + } else { + remoteEntries = journal!!.list(crypto, remoteCTag, MAX_FETCH) + } + + App.log.info("Fetched " + remoteEntries!!.size.toString() + " entries") + } + + @Throws(IOException::class, ContactsStorageException::class, CalendarStorageException::class, InvalidCalendarException::class, InterruptedException::class) + protected fun applyRemoteEntries() { + // Process new vcards from server + val strTotal = remoteEntries!!.size.toString() + var i = 0 + + for (entry in remoteEntries!!) { + if (Thread.interrupted()) { + throw InterruptedException() + } + i++ + App.log.info("Processing (" + i.toString() + "/" + strTotal + ") " + entry.toString()) + + val cEntry = SyncEntry.fromJournalEntry(crypto, entry) + App.log.info("Processing resource for journal entry") + processSyncEntry(cEntry) + + persistSyncEntry(entry.uid, cEntry) + + remoteCTag = entry.uid + } + } + + @Throws(Exceptions.HttpException::class, IOException::class, ContactsStorageException::class, CalendarStorageException::class) + protected fun pushEntries() { + // upload dirty contacts + var pushed = 0 + // FIXME: Deal with failure (someone else uploaded before we go here) + try { + if (!localEntries!!.isEmpty()) { + val entries = localEntries + journal!!.create(entries!!, remoteCTag) + // Persist the entries after they've been pushed + for (entry in entries) { + val cEntry = SyncEntry.fromJournalEntry(crypto, entry) + persistSyncEntry(entry.uid, cEntry) + } + remoteCTag = entries[entries.size - 1].uid + pushed += entries.size + } + } finally { + // FIXME: A bit fragile, we assume the order in createLocalEntries + var left = pushed + for (local in localDeleted!!) { + if (pushed-- <= 0) { + break + } + local.delete() + } + if (left > 0) { + localDeleted?.drop(left) + } + + left = pushed + for (local in localDirty) { + if (pushed-- <= 0) { + break + } + App.log.info("Added/changed resource with UUID: " + local.uuid) + local.clearDirty(local.uuid) + } + if (left > 0) { + localDirty = Arrays.copyOfRange(localDirty, left, localDirty.size) + } + + if (pushed > 0) { + App.log.severe("Unprocessed localentries left, this should never happen!") + } + } + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class, IOException::class) + protected open fun createLocalEntries() { + localEntries = LinkedList() + + // Not saving, just creating a fake one until we load it from a local db + var previousEntry: JournalEntryManager.Entry? = if (remoteCTag != null) JournalEntryManager.Entry.getFakeWithUid(remoteCTag!!) else null + + for (local in localDeleted!!) { + val entry = SyncEntry(local.content, SyncEntry.Actions.DELETE) + val tmp = JournalEntryManager.Entry() + tmp.update(crypto, entry.toJson(), previousEntry!!) + previousEntry = tmp + localEntries!!.add(previousEntry) + + if (localEntries!!.size == MAX_PUSH) { + return + } + } + + for (local in localDirty) { + val action: SyncEntry.Actions + if (local.isLocalOnly) { + action = ADD + } else { + action = SyncEntry.Actions.CHANGE + } + + val entry = SyncEntry(local.content, action) + val tmp = JournalEntryManager.Entry() + tmp.update(crypto, entry.toJson(), previousEntry!!) + previousEntry = tmp + localEntries!!.add(previousEntry) + + if (localEntries!!.size == MAX_PUSH) { + return + } + } + } + + /** + */ + @Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class) + protected fun prepareLocal() { + remoteCTag = journalEntity.getLastUid(data) + + localDeleted = processLocallyDeleted() + localDirty = localCollection!!.dirty + // This is done after fetching the local dirty so all the ones we are using will be prepared + prepareDirty() + } + + + /** + * Delete unpublished locally deleted, and return the rest. + * Checks Thread.interrupted() before each request to allow quick sync cancellation. + */ + @Throws(CalendarStorageException::class, ContactsStorageException::class) + private fun processLocallyDeleted(): List { + val localList = localCollection!!.deleted + val ret = ArrayList(localList.size) + + for (local in localList) { + if (Thread.interrupted()) + return ret + + App.log.info(local.uuid + " has been deleted locally -> deleting from server") + ret.add(local) + + syncResult.stats.numDeletes++ + } + + return ret + } + + @Throws(CalendarStorageException::class, ContactsStorageException::class) + protected open fun prepareDirty() { + // assign file names and UIDs to new entries + App.log.info("Looking for local entries without a uuid") + for (local in localDirty) { + if (local.uuid != null) { + continue + } + + App.log.fine("Found local record #" + local.id + " without file name; generating file name/UID if necessary") + local.prepareForUpload() + } + } + + /** + * For post-processing of entries, for instance assigning groups. + */ + @Throws(CalendarStorageException::class, ContactsStorageException::class) + protected open fun postProcess() { + } + + companion object { + private val MAX_FETCH = 50 + private val MAX_PUSH = 30 + } +}