diff --git a/app/build.gradle b/app/build.gradle index 0085164b..d93134a0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,8 @@ android { minSdkVersion 14 targetSdkVersion 23 - versionCode 73 - versionName "0.9-alpha1" + versionCode 74 + versionName "0.9-alpha2" buildConfigField "java.util.Date", "buildTime", "new java.util.Date()" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67fc06ad..86085913 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -86,6 +86,10 @@ + + diff --git a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java index 86cd3e3d..26389ae1 100644 --- a/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java +++ b/app/src/main/java/at/bitfire/davdroid/syncadapter/ContactsSyncAdapterService.java @@ -8,6 +8,9 @@ package at.bitfire.davdroid.syncadapter; import android.accounts.Account; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; import android.app.Service; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; @@ -15,14 +18,13 @@ import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.SyncResult; +import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.text.TextUtils; -import android.util.Log; import com.squareup.okhttp.HttpUrl; import com.squareup.okhttp.MediaType; -import com.squareup.okhttp.OkHttpClient; import com.squareup.okhttp.Request; import com.squareup.okhttp.RequestBody; import com.squareup.okhttp.Response; @@ -33,7 +35,6 @@ import org.apache.commons.io.Charsets; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; -import java.net.URI; import java.nio.charset.Charset; import java.util.HashMap; import java.util.HashSet; @@ -56,12 +57,13 @@ import at.bitfire.dav4android.property.SupportedAddressData; import at.bitfire.davdroid.ArrayUtils; import at.bitfire.davdroid.Constants; import at.bitfire.davdroid.HttpClient; +import at.bitfire.davdroid.R; import at.bitfire.davdroid.resource.LocalAddressBook; import at.bitfire.davdroid.resource.LocalContact; +import at.bitfire.davdroid.ui.DebugInfoActivity; import at.bitfire.vcard4android.Contact; import at.bitfire.vcard4android.ContactsStorageException; import ezvcard.VCardVersion; -import ezvcard.property.Uid; import ezvcard.util.IOUtils; import lombok.Cleanup; import lombok.RequiredArgsConstructor; @@ -90,6 +92,20 @@ public class ContactsSyncAdapterService extends Service { private static class ContactsSyncAdapter extends AbstractThreadedSyncAdapter { + private static final int + NOTIFICATION_ERROR = 1, + + SYNC_PHASE_QUERY_CAPABILITIES = 0, + SYNC_PHASE_PROCESS_LOCALLY_DELETED = 1, + SYNC_PHASE_PREPARE_LOCALLY_CREATED = 2, + SYNC_PHASE_UPLOAD_DIRTY = 3, + SYNC_PHASE_CHECK_STATE = 4, + SYNC_PHASE_LIST_LOCAL = 5, + SYNC_PHASE_LIST_REMOTE = 6, + SYNC_PHASE_COMPARE_ENTRIES = 7, + SYNC_PHASE_DOWNLOAD_REMOTE = 8, + SYNC_PHASE_SAVE_STATE = 9; + public ContactsSyncAdapter(Context context) { super(context, false); } @@ -103,10 +119,16 @@ public class ContactsSyncAdapterService extends Service { HttpUrl addressBookURL = HttpUrl.parse(settings.getAddressBookURL()); DavAddressBook dav = new DavAddressBook(httpClient, addressBookURL); - try { - // prepare local address book - LocalAddressBook addressBook = new LocalAddressBook(account, provider); + // dismiss previous error notifications + NotificationManager notificationManager = (NotificationManager)getContext().getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(account.name, NOTIFICATION_ERROR); + + // prepare local address book + LocalAddressBook addressBook = new LocalAddressBook(account, provider); + + int syncPhase = SYNC_PHASE_QUERY_CAPABILITIES; + try { // prepare remote address book boolean hasVCard4 = false; dav.propfind(0, SupportedAddressData.NAME, GetCTag.NAME); @@ -117,6 +139,7 @@ public class ContactsSyncAdapterService extends Service { hasVCard4 = true; Constants.log.info("Server advertises VCard/4 support: " + hasVCard4); + syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED; // Remove locally deleted contacts from server (if they have a name, i.e. if they were uploaded before), // but only if they don't have changed on the server. Then finally remove them from the local address book. LocalContact[] localList = addressBook.getDeleted(); @@ -133,8 +156,10 @@ public class ContactsSyncAdapterService extends Service { } else Constants.log.info("Removing local contact #" + local.getId() + " which has been deleted locally and was never uploaded"); local.delete(); + syncResult.stats.numDeletes++; } + syncPhase = SYNC_PHASE_PREPARE_LOCALLY_CREATED; // assign file names and UIDs to new contacts so that we can use the file name as an index localList = addressBook.getWithoutFileName(); for (LocalContact local : localList) { @@ -143,6 +168,7 @@ public class ContactsSyncAdapterService extends Service { local.updateFileNameAndUID(uuid); } + syncPhase = SYNC_PHASE_UPLOAD_DIRTY; // upload dirty contacts localList = addressBook.getDirty(); for (LocalContact local : localList) { @@ -181,6 +207,7 @@ public class ContactsSyncAdapterService extends Service { local.clearDirty(eTag); } + syncPhase = SYNC_PHASE_CHECK_STATE; // check CTag (ignore on manual sync) String currentCTag = null; GetCTag getCTag = (GetCTag) dav.properties.get(GetCTag.NAME); @@ -197,6 +224,7 @@ public class ContactsSyncAdapterService extends Service { Constants.log.info("Remote address book didn't change (CTag=" + currentCTag + "), no need to list VCards"); } else /* remote CTag has changed */ { + syncPhase = SYNC_PHASE_LIST_LOCAL; // fetch list of local contacts and build hash table to index file name localList = addressBook.getAll(); Map localContacts = new HashMap<>(localList.length); @@ -205,6 +233,7 @@ public class ContactsSyncAdapterService extends Service { localContacts.put(contact.getFileName(), contact); } + syncPhase = SYNC_PHASE_LIST_REMOTE; // fetch list of remote VCards and build hash table to index file name Constants.log.info("Listing remote VCards"); dav.queryMemberETags(); @@ -215,6 +244,7 @@ public class ContactsSyncAdapterService extends Service { remoteContacts.put(fileName, vCard); } + syncPhase = SYNC_PHASE_COMPARE_ENTRIES; /* check which contacts 1. are not present anymore remotely -> delete immediately on local side 2. updated remotely -> add to downloadNames @@ -226,6 +256,7 @@ public class ContactsSyncAdapterService extends Service { if (remote == null) { Constants.log.info(localName + " is not on server anymore, deleting"); localContacts.get(localName).delete(); + syncResult.stats.numDeletes++; } else { // contact is still on server, check whether it has been updated remotely GetETag getETag = (GetETag)remote.properties.get(GetETag.NAME); @@ -233,7 +264,9 @@ public class ContactsSyncAdapterService extends Service { throw new DavException("Server didn't provide ETag"); String localETag = localContacts.get(localName).eTag, remoteETag = getETag.eTag; - if (!remoteETag.equals(localETag)) { + if (remoteETag.equals(localETag)) + syncResult.stats.numSkippedEntries++; + else { Constants.log.info(localName + " has been changed on server (current ETag=" + remoteETag + ", last known ETag=" + localETag + ")"); toDownload.add(remote); } @@ -249,6 +282,7 @@ public class ContactsSyncAdapterService extends Service { toDownload.addAll(remoteContacts.values()); } + syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE; Constants.log.info("Downloading " + toDownload.size() + " contacts (" + MAX_MULTIGET + " at once)"); // prepare downloader which may be used to download external resource like contact photos @@ -267,7 +301,7 @@ public class ContactsSyncAdapterService extends Service { String eTag = ((GetETag)remote.properties.get(GetETag.NAME)).eTag; @Cleanup InputStream stream = body.byteStream(); - processVCard(addressBook, localContacts, remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8), downloader); + processVCard(syncResult, addressBook, localContacts, remote.fileName(), eTag, stream, body.contentType().charset(Charsets.UTF_8), downloader); } else { // multiple contacts, use multi-get @@ -298,11 +332,12 @@ public class ContactsSyncAdapterService extends Service { throw new DavException("Received multi-get response without address data"); @Cleanup InputStream stream = new ByteArrayInputStream(addressData.vCard.getBytes()); - processVCard(addressBook, localContacts, remote.fileName(), eTag, stream, charset, downloader); + processVCard(syncResult, addressBook, localContacts, remote.fileName(), eTag, stream, charset, downloader); } } } + syncPhase = SYNC_PHASE_SAVE_STATE; /* Save sync state (CTag). It doesn't matter if it has changed during the sync process (for instance, because another client has uploaded changes), because this will simply cause all remote entries to be listed at the next sync. */ @@ -310,15 +345,49 @@ public class ContactsSyncAdapterService extends Service { addressBook.setCTag(currentCTag); } - } catch (Exception e) { - Log.e("davdroid", "XXX", e); + } catch (IOException e) { + Constants.log.error("I/O exception during sync, trying again later", e); + syncResult.stats.numIoExceptions++; + + } catch(HttpException e) { + Constants.log.error("HTTP Exception during sync", e); + syncResult.stats.numParseExceptions++; + + Intent detailsIntent = new Intent(getContext(), DebugInfoActivity.class); + detailsIntent.putExtra(DebugInfoActivity.KEY_EXCEPTION, e); + detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account); + detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase); + + Notification.Builder builder = new Notification.Builder(getContext()); + Notification notification = null; + builder .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle(getContext().getString(R.string.sync_error_title, account.name)) + .setContentIntent(PendingIntent.getActivity(getContext(), 0, detailsIntent, PendingIntent.FLAG_UPDATE_CURRENT)); + + String[] phases = getContext().getResources().getStringArray(R.array.sync_error_phases); + if (phases.length > syncPhase) + builder.setContentText(getContext().getString(R.string.sync_error_http, phases[syncPhase])); + + if (Build.VERSION.SDK_INT >= 16) { + if (Build.VERSION.SDK_INT >= 21) + builder.setCategory(Notification.CATEGORY_ERROR); + notification = builder.build(); + } else { + notification = builder.getNotification(); + } + notificationManager.notify(account.name, NOTIFICATION_ERROR, notification); + + } catch(DavException e) { + ; + } catch(ContactsStorageException e) { + syncResult.databaseError = true; } Constants.log.info("Sync complete for authority " + authority); } - private void processVCard(LocalAddressBook addressBook, MaplocalContacts, String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { + private void processVCard(SyncResult syncResult, LocalAddressBook addressBook, MaplocalContacts, String fileName, String eTag, InputStream stream, Charset charset, Contact.Downloader downloader) throws IOException, ContactsStorageException { Contact contacts[] = Contact.fromStream(stream, charset, downloader); if (contacts.length == 1) { Contact newData = contacts[0]; @@ -329,16 +398,17 @@ public class ContactsSyncAdapterService extends Service { Constants.log.info("Updating " + fileName + " in local address book"); localContact.eTag = eTag; localContact.update(newData); + syncResult.stats.numUpdates++; } else { Constants.log.info("Adding " + fileName + " to local address book"); localContact = new LocalContact(addressBook, newData, fileName, eTag); localContact.add(); + syncResult.stats.numInserts++; } } else Constants.log.error("Received VCard with not exactly one VCARD, ignoring " + fileName); } - } diff --git a/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java new file mode 100644 index 00000000..9c02c54d --- /dev/null +++ b/app/src/main/java/at/bitfire/davdroid/ui/DebugInfoActivity.java @@ -0,0 +1,157 @@ +/* + * Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering). + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Public License v3.0 + * which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/gpl.html + */ + +package at.bitfire.davdroid.ui; + +import android.accounts.Account; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.provider.CalendarContract; +import android.provider.ContactsContract; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.TextView; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import at.bitfire.dav4android.exception.HttpException; +import at.bitfire.davdroid.BuildConfig; +import at.bitfire.davdroid.Constants; +import at.bitfire.davdroid.R; +import lombok.Cleanup; + +public class DebugInfoActivity extends Activity { + public static final String + KEY_EXCEPTION = "exception", + KEY_ACCOUNT = "account", + KEY_PHASE = "phase"; + + private static final String APP_ID = "at.bitfire.davdroid"; + + String report; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.debug_info_activity); + + TextView tvReport = (TextView)findViewById(R.id.text_report); + tvReport.setText(generateReport(getIntent().getExtras())); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.exception_details_activity, menu); + return true; + } + + + public void onShare(MenuItem item) { + if (!TextUtils.isEmpty(report)) { + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + sendIntent.putExtra(Intent.EXTRA_SUBJECT, "DAVdroid Exception Details"); + sendIntent.putExtra(Intent.EXTRA_TEXT, report); + sendIntent.setType("text/plain"); + startActivity(sendIntent); + } + } + + + protected String generateReport(Bundle extras) { + Exception exception = null; + Account account = null; + Integer phase = null; + + if (extras != null) { + exception = (Exception) extras.getSerializable(KEY_EXCEPTION); + account = (Account) extras.getParcelable(KEY_ACCOUNT); + phase = extras.getInt(KEY_PHASE); + } + + StringBuilder report = new StringBuilder(); + + try { + report.append( + "SYSTEM INFORMATION\n" + + "Android version: " + Build.VERSION.RELEASE + " (" + Build.DISPLAY + ")\n" + + "Device: " + Build.MANUFACTURER + " / " + Build.MODEL + " (" + Build.DEVICE + ")\n\n" + ); + } catch (Exception ex) { + Constants.log.error("Couldn't get system details", ex); + } + + try { + PackageManager pm = getPackageManager(); + String installedFrom = pm.getInstallerPackageName("at.bitfire.davdroid"); + if (TextUtils.isEmpty(installedFrom)) + installedFrom = "APK (directly)"; + else { + PackageInfo installer = pm.getPackageInfo(installedFrom, PackageManager.GET_META_DATA); + if (installer != null) + installedFrom = pm.getApplicationLabel(installer.applicationInfo).toString(); + } + report.append( + "SOFTWARE INFORMATION\n" + + "DAVdroid version: " + BuildConfig.VERSION_NAME + " (" + BuildConfig.VERSION_CODE + ") " + BuildConfig.buildTime.toString() + "\n" + + "Installed from: " + installedFrom + "\n\n" + ); + } catch(Exception ex) { + Constants.log.error("Couldn't get software information", ex); + } + + report.append( + "CONFIGURATION\n" + + "System-wide synchronization: " + (ContentResolver.getMasterSyncAutomatically() ? "automatically" : "manually") + " (overrides account settings)\n" + ); + if (account != null) + report.append( + "Account name: " + account.name + "\n" + + "Address book synchronization: " + syncStatus(account, ContactsContract.AUTHORITY) + "\n" + + "Calendar synchronization: " + syncStatus(account, CalendarContract.AUTHORITY) + "\n" + + "OpenTasks synchronization: " + syncStatus(account, "org.dmfs.tasks") + "\n\n" + ); + + if (phase != null) { + report.append("SYNCHRONIZATION INFO\nSychronization phase: " + phase + "\n\n"); + } + + if (exception instanceof HttpException) { + HttpException http = (HttpException)exception; + if (http.request != null) + report.append("HTTP REQUEST:\n" + http.request + "\n\n"); + if (http.response != null) + report.append("HTTP RESPONSE:\n" + http.response + "\n\n"); + } + + if (exception != null) { + report.append("STACK TRACE\n"); + StringWriter writer = new StringWriter(); + @Cleanup PrintWriter printWriter = new PrintWriter(writer); + exception.printStackTrace(printWriter); + report.append(writer.toString()); + } + + return report.toString(); + } + + protected static String syncStatus(Account account, String authority) { + return ContentResolver.getIsSyncable(account, authority) > 0 ? + (ContentResolver.getSyncAutomatically(account, ContactsContract.AUTHORITY) ? "automatically" : "manually") : + "—"; + } + +} diff --git a/app/src/main/java/at/bitfire/davdroid/ui/MainActivity.java b/app/src/main/java/at/bitfire/davdroid/ui/MainActivity.java index 76c73c1e..35a520c0 100644 --- a/app/src/main/java/at/bitfire/davdroid/ui/MainActivity.java +++ b/app/src/main/java/at/bitfire/davdroid/ui/MainActivity.java @@ -11,6 +11,7 @@ import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import android.os.Debug; import android.provider.Settings; import android.text.Html; import android.text.method.LinkMovementMethod; @@ -58,6 +59,10 @@ public class MainActivity extends Activity { startActivity(new Intent(this, AddAccountActivity.class)); } + public void showDebugInfo(MenuItem item) { + startActivity(new Intent(this, DebugInfoActivity.class)); + } + public void showSettings(MenuItem item) { startActivity(new Intent(this, SettingsActivity.class)); } diff --git a/app/src/main/res/layout/debug_info_activity.xml b/app/src/main/res/layout/debug_info_activity.xml new file mode 100644 index 00000000..386cdacd --- /dev/null +++ b/app/src/main/res/layout/debug_info_activity.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/exception_details_activity.xml b/app/src/main/res/menu/exception_details_activity.xml new file mode 100644 index 00000000..68ba0600 --- /dev/null +++ b/app/src/main/res/menu/exception_details_activity.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/main_activity.xml b/app/src/main/res/menu/main_activity.xml index d722114f..8430cf77 100644 --- a/app/src/main/res/menu/main_activity.xml +++ b/app/src/main/res/menu/main_activity.xml @@ -11,5 +11,6 @@ + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 49c578c7..6b0bd7e1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -177,6 +177,18 @@ "Verwenden Sie Ihre Email-Adresse als Kontoname, da Android den Kontonamen als ORGANIZER-Feld in Terminen benutzt. Sie können keine zwei Konten mit dem gleichen Namen anlegen. schreibgeschützt - Synchronisierung fehlgeschlagen + Synchronisierung von %s fehlgeschlagen + HTTP-Fehler beim %1$s + + Abfragen der Server-Fähigkeiten + Verarbeiten lokal gelöschter Einträge + Vorbereiten neuer lokaler Einträge + Hochladen neuer/geänderter lokaler Einträge + Abfragen des Sync.-Zustands + Auflisten lokaler Einträge + Auflisten der Server-Einträge + Herunterladen von Server-Einträgen + Speichern des Sync.-Zustands + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1d856eb..4e124ad2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,9 +11,10 @@ DAVdroid - Next - Skip Help + Next + Share + Skip Untrusted certificate in certificate path. See FAQ for more info. HTTP error: %s @@ -23,6 +24,7 @@ Manage sync accounts + Show debug info Thank you for buying DAVdroid via Google Play and thus supporting the project. Unfortunately, there are two issues with Google Play:

@@ -188,6 +190,20 @@ "Use your email address as account name because Android will use the account name as ORGANIZER field for events you create. You can't have two accounts with the same name. read-only - Synchronization failed + + Debug info + Synchronization of %s failed + HTTP error while %1$s + + querying capabilities + processing locally deleted entries + preparing locally created entries + uploading created/modified entries + checking sync state + listing local entries + listing remote entries + downloading remote entries + saving sync state + diff --git a/dav4android b/dav4android index 8258787d..e6c3ee6d 160000 --- a/dav4android +++ b/dav4android @@ -1 +1 @@ -Subproject commit 8258787df4c29697e76c683d1b9e4caea42205ec +Subproject commit e6c3ee6da90a94d3c77675b8fdd9be7e2d5f83e3