diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3a3ddfdb..28ca6a2e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,9 +29,11 @@
for writing external log files; permission only required for SDK <= 18 because since then,
writing to app-private directory doesn't require extra permissions
-->
-
+
+
+
@@ -207,8 +209,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_paths" />
-
-
diff --git a/app/src/main/java/com/etesync/syncadapter/App.java b/app/src/main/java/com/etesync/syncadapter/App.java
index 1efee55d..3d9adf39 100644
--- a/app/src/main/java/com/etesync/syncadapter/App.java
+++ b/app/src/main/java/com/etesync/syncadapter/App.java
@@ -8,8 +8,6 @@
package com.etesync.syncadapter;
-import android.accounts.Account;
-import android.accounts.AccountManager;
import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Application;
diff --git a/app/src/main/java/com/etesync/syncadapter/Constants.java b/app/src/main/java/com/etesync/syncadapter/Constants.java
index 81f4300a..7b1c790b 100644
--- a/app/src/main/java/com/etesync/syncadapter/Constants.java
+++ b/app/src/main/java/com/etesync/syncadapter/Constants.java
@@ -40,5 +40,6 @@ public class Constants {
public static final int DEFAULT_SYNC_INTERVAL = 4 * 3600; // 4 hours
public static final long DEFAULT_RETRY_DELAY = 30 * 60; // 30 minutes
- public final static String KEY_ACCOUNT = "account";
+ public final static String KEY_ACCOUNT = "account",
+ KEY_COLLECTION_INFO = "collectionInfo";
}
diff --git a/app/src/main/java/com/etesync/syncadapter/ui/ImportFragment.java b/app/src/main/java/com/etesync/syncadapter/ui/ImportFragment.java
new file mode 100644
index 00000000..6ef32c09
--- /dev/null
+++ b/app/src/main/java/com/etesync/syncadapter/ui/ImportFragment.java
@@ -0,0 +1,410 @@
+package com.etesync.syncadapter.ui;
+
+import android.Manifest;
+import android.accounts.Account;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.Dialog;
+import android.app.ProgressDialog;
+import android.content.ActivityNotFoundException;
+import android.content.ContentProviderClient;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.CalendarContract;
+import android.provider.ContactsContract;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.v4.app.DialogFragment;
+import android.support.v7.app.AlertDialog;
+import android.widget.Toast;
+
+import com.etesync.syncadapter.App;
+import com.etesync.syncadapter.R;
+import com.etesync.syncadapter.model.CollectionInfo;
+import com.etesync.syncadapter.resource.LocalAddressBook;
+import com.etesync.syncadapter.resource.LocalCalendar;
+import com.etesync.syncadapter.resource.LocalContact;
+import com.etesync.syncadapter.resource.LocalEvent;
+
+import org.apache.commons.codec.Charsets;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.Serializable;
+
+import at.bitfire.ical4android.CalendarStorageException;
+import at.bitfire.ical4android.Event;
+import at.bitfire.ical4android.InvalidCalendarException;
+import at.bitfire.vcard4android.Contact;
+import at.bitfire.vcard4android.ContactsStorageException;
+import lombok.Cleanup;
+import lombok.ToString;
+
+import static com.etesync.syncadapter.Constants.KEY_ACCOUNT;
+import static com.etesync.syncadapter.Constants.KEY_COLLECTION_INFO;
+
+public class ImportFragment extends DialogFragment {
+ private static final int REQUEST_CODE = 6384; // onActivityResult request
+
+ private static final String TAG_PROGRESS_MAX = "progressMax";
+
+ private Account account;
+ private CollectionInfo info;
+ private File importFile;
+
+ public static ImportFragment newInstance(Account account, CollectionInfo info) {
+ ImportFragment frag = new ImportFragment();
+ Bundle args = new Bundle(1);
+ args.putParcelable(KEY_ACCOUNT, account);
+ args.putSerializable(KEY_COLLECTION_INFO, info);
+ frag.setArguments(args);
+ return frag;
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setCancelable(false);
+ setRetainInstance(true);
+
+ account = getArguments().getParcelable(KEY_ACCOUNT);
+ info = (CollectionInfo) getArguments().getSerializable(KEY_COLLECTION_INFO);
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ chooseFile();
+ } else {
+ ImportResult data = new ImportResult();
+ data.e = new Exception(getString(R.string.import_permission_required));
+ getFragmentManager().beginTransaction()
+ .add(ResultFragment.newInstance(data), null)
+ .commitAllowingStateLoss();
+
+ dismissAllowingStateLoss();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private void requestPermissions() {
+ requestPermissions(new String[]{
+ Manifest.permission.READ_EXTERNAL_STORAGE,
+ }, 0);
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ super.onCreateDialog(savedInstanceState);
+ ProgressDialog progress = new ProgressDialog(getActivity());
+ progress.setTitle(R.string.import_dialog_title);
+ progress.setMessage(getString(R.string.import_dialog_loading_file));
+ progress.setCanceledOnTouchOutside(false);
+ progress.setIndeterminate(false);
+ progress.setIcon(R.drawable.ic_import_export_black);
+ progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+
+ if (savedInstanceState == null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ requestPermissions();
+ } else {
+ chooseFile();
+ }
+ } else {
+ setDialogAddEntries(progress, savedInstanceState.getInt(TAG_PROGRESS_MAX));
+ }
+
+ return progress;
+ }
+
+ private void setDialogAddEntries(ProgressDialog dialog, int length) {
+ dialog.setMax(length);
+ dialog.setMessage(getString(R.string.import_dialog_adding_entries));
+ }
+
+ @Override
+ public void onSaveInstanceState(Bundle outState) {
+ super.onSaveInstanceState(outState);
+ final ProgressDialog dialog = (ProgressDialog) getDialog();
+
+ outState.putInt(TAG_PROGRESS_MAX, dialog.getMax());
+ }
+
+ @Override
+ public void onDestroyView() {
+ Dialog dialog = getDialog();
+ // handles https://code.google.com/p/android/issues/detail?id=17423
+ if (dialog != null && getRetainInstance()) {
+ dialog.setDismissMessage(null);
+ }
+ super.onDestroyView();
+ }
+
+ public void chooseFile() {
+ Intent intent = new Intent();
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setAction(Intent.ACTION_GET_CONTENT);
+
+ if (info.type.equals(CollectionInfo.Type.CALENDAR)) {
+ intent.setType("text/calendar");
+ } else if (info.type.equals(CollectionInfo.Type.ADDRESS_BOOK)) {
+ intent.setType("text/x-vcard");
+ }
+
+ Intent chooser = Intent.createChooser(
+ intent, getString(R.string.choose_file));
+ try {
+ startActivityForResult(chooser, REQUEST_CODE);
+ } catch (ActivityNotFoundException e) {
+ ImportResult data = new ImportResult();
+ data.e = new Exception("Failed to open file chooser.\nPlease install one.");
+ getFragmentManager().beginTransaction()
+ .add(ResultFragment.newInstance(data), null)
+ .commitAllowingStateLoss();
+
+ dismissAllowingStateLoss();
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ switch (requestCode) {
+ case REQUEST_CODE:
+ if (resultCode == Activity.RESULT_OK) {
+ if (data != null) {
+ // Get the URI of the selected file
+ final Uri uri = data.getData();
+ App.log.info("Importing uri = " + uri.toString());
+ try {
+ importFile = new File(com.etesync.syncadapter.utils.FileUtils.getPath(getContext(), uri));
+
+ new Thread(new ImportCalendarsLoader()).start();
+ } catch (Exception e) {
+ App.log.severe("File select error: " + e.getLocalizedMessage());
+ }
+ }
+ } else {
+ dismissAllowingStateLoss();
+ }
+ break;
+ }
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+
+ public void loadFinished(ImportResult data) {
+ getFragmentManager().beginTransaction()
+ .add(ResultFragment.newInstance(data), null)
+ .commitAllowingStateLoss();
+
+ dismissAllowingStateLoss();
+ }
+
+ private class ImportCalendarsLoader implements Runnable {
+ private void finishParsingFile(final int length) {
+ if (getActivity() == null) {
+ return;
+ }
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ setDialogAddEntries((ProgressDialog) getDialog(), length);
+ }
+ });
+ }
+
+ private void entryProcessed() {
+ if (getActivity() == null) {
+ return;
+ }
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final ProgressDialog dialog = (ProgressDialog) getDialog();
+
+ dialog.incrementProgressBy(1);
+ }
+ });
+ }
+
+ @Override
+ public void run() {
+ final ImportResult result = loadInBackground();
+
+ getActivity().runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ loadFinished(result);
+ }
+ });
+ }
+
+ public ImportResult loadInBackground() {
+ ImportResult result = new ImportResult();
+
+ try {
+ @Cleanup FileInputStream importStream = new FileInputStream(importFile);
+
+ if (info.type.equals(CollectionInfo.Type.CALENDAR)) {
+ final Event[] events = Event.fromStream(importStream, Charsets.UTF_8);
+
+ if (events.length == 0) {
+ App.log.warning("Empty/invalid file.");
+ result.e = new Exception("Empty/invalid file.");
+ return result;
+ }
+
+ result.total = events.length;
+
+ finishParsingFile(events.length);
+
+ ContentProviderClient provider = getContext().getContentResolver().acquireContentProviderClient(CalendarContract.CONTENT_URI);
+ LocalCalendar localCalendar;
+ try {
+ localCalendar = LocalCalendar.findByName(account, provider, LocalCalendar.Factory.INSTANCE, info.url);
+ if (localCalendar == null) {
+ throw new FileNotFoundException("Failed to load local resource.");
+ }
+ } catch (CalendarStorageException | FileNotFoundException e) {
+ App.log.info("Fail" + e.getLocalizedMessage());
+ result.e = e;
+ return result;
+ }
+
+ for (Event event : events) {
+ try {
+ LocalEvent localEvent = localCalendar.getByUid(event.uid);
+ if (localEvent != null) {
+ localEvent.updateAsDirty(event);
+ result.updated++;
+ } else {
+ localEvent = new LocalEvent(localCalendar, event, event.uid, null);
+ localEvent.addAsDirty();
+ result.added++;
+ }
+ } catch (CalendarStorageException e) {
+ e.printStackTrace();
+ }
+
+ entryProcessed();
+ }
+ } else if (info.type.equals(CollectionInfo.Type.ADDRESS_BOOK)) {
+ // FIXME: Handle groups and download icon?
+ final Contact[] contacts = Contact.fromStream(importStream, Charsets.UTF_8, null);
+
+ if (contacts.length == 0) {
+ App.log.warning("Empty/invalid file.");
+ result.e = new Exception("Empty/invalid file.");
+ return result;
+ }
+
+ result.total = contacts.length;
+
+ finishParsingFile(contacts.length);
+
+ ContentProviderClient provider = getContext().getContentResolver().acquireContentProviderClient(ContactsContract.RawContacts.CONTENT_URI);
+ LocalAddressBook localAddressBook = new LocalAddressBook(account, provider);
+
+ for (Contact contact : contacts) {
+ try {
+ LocalContact localContact = (LocalContact) localAddressBook.getByUid(contact.uid);
+ if (localContact != null) {
+ localContact.updateAsDirty(contact);
+ result.updated++;
+ } else {
+ localContact = new LocalContact(localAddressBook, contact, contact.uid, null);
+ localContact.createAsDirty();
+ result.added++;
+ }
+ } catch (ContactsStorageException e) {
+ e.printStackTrace();
+ }
+
+ entryProcessed();
+ }
+ }
+
+ return result;
+ } catch (FileNotFoundException e) {
+ result.e = e;
+ return result;
+ } catch (InvalidCalendarException | IOException e) {
+ result.e = e;
+ return result;
+ }
+ }
+ }
+
+ @ToString
+ static class ImportResult implements Serializable {
+ long total;
+ long added;
+ long updated;
+ Exception e;
+
+ boolean isFailed() {
+ return (e != null);
+ }
+
+ long getSkipped() {
+ return total - (added + updated);
+ }
+ }
+
+ public static class ResultFragment extends DialogFragment {
+ private static final String KEY_RESULT = "result";
+ private ImportResult result;
+
+ private static ResultFragment newInstance(ImportResult result) {
+ Bundle args = new Bundle();
+ args.putSerializable(KEY_RESULT, result);
+ ResultFragment fragment = new ResultFragment();
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ result = (ImportResult) getArguments().getSerializable(KEY_RESULT);
+ }
+
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ int icon;
+ int title;
+ String msg;
+ if (result.isFailed()) {
+ icon = R.drawable.ic_error_dark;
+ title = R.string.import_dialog_failed_title;
+ msg = result.e.getLocalizedMessage();
+ } else {
+ icon = R.drawable.ic_import_export_black;
+ title = R.string.import_dialog_title;
+ msg = getString(R.string.import_dialog_success, result.total, result.added, result.updated, result.getSkipped());
+ }
+ return new AlertDialog.Builder(getActivity())
+ .setTitle(title)
+ .setIcon(icon)
+ .setMessage(msg)
+ .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ // dismiss
+ }
+ })
+ .create();
+ }
+ }
+}
diff --git a/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java b/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java
index 0cab5b10..da43127a 100644
--- a/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java
+++ b/app/src/main/java/com/etesync/syncadapter/ui/ViewCollectionActivity.java
@@ -159,4 +159,10 @@ public class ViewCollectionActivity extends AppCompatActivity {
// FIXME: Handle it more gracefully
finish();
}
+
+ public void onImport(MenuItem item) {
+ getSupportFragmentManager().beginTransaction()
+ .add(ImportFragment.newInstance(account, info), null)
+ .commit();
+ }
}
diff --git a/app/src/main/java/com/etesync/syncadapter/utils/FileUtils.java b/app/src/main/java/com/etesync/syncadapter/utils/FileUtils.java
new file mode 100644
index 00000000..da38b8cb
--- /dev/null
+++ b/app/src/main/java/com/etesync/syncadapter/utils/FileUtils.java
@@ -0,0 +1,158 @@
+package com.etesync.syncadapter.utils;
+
+import android.annotation.SuppressLint;
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.provider.MediaStore;
+
+import java.io.File;
+
+public class FileUtils {
+
+ /**
+ * Get a file path from a Uri. This will get the the path for Storage Access
+ * Framework Documents, as well as the _data field for the MediaStore and
+ * other file-based ContentProviders.
+ *
+ * @param context The context.
+ * @param uri The Uri to query.
+ * @author paulburke
+ */
+ @SuppressLint("NewApi")
+ public static String getPath(final Context context, final Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+
+ final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
+
+ // DocumentProvider
+ if (isKitKat && DocumentsContract.isDocumentUri(context, uri)) {
+ // ExternalStorageProvider
+ if (isExternalStorageDocument(uri)) {
+ final String docId = DocumentsContract.getDocumentId(uri);
+ final String[] split = docId.split(":");
+ final String type = split[0];
+
+ if ("primary".equalsIgnoreCase(type)) {
+ return Environment.getExternalStorageDirectory() + "/" + split[1];
+ }
+
+ // TODO handle non-primary volumes
+ }
+ // DownloadsProvider
+ else if (isDownloadsDocument(uri)) {
+
+ final String id = DocumentsContract.getDocumentId(uri);
+ final Uri contentUri = ContentUris.withAppendedId(
+ Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
+
+ return getDataColumn(context, contentUri, null, null);
+ }
+ // MediaProvider
+ else if (isMediaDocument(uri)) {
+ final String docId = DocumentsContract.getDocumentId(uri);
+ final String[] split = docId.split(":");
+ final String type = split[0];
+
+ Uri contentUri = null;
+ if ("image".equals(type)) {
+ contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+ } else if ("video".equals(type)) {
+ contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+ } else if ("audio".equals(type)) {
+ contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
+ }
+
+ final String selection = "_id=?";
+ final String[] selectionArgs = new String[]{
+ split[1]
+ };
+
+ return getDataColumn(context, contentUri, selection, selectionArgs);
+ }
+ }
+ // MediaStore (and general)
+ else if ("content".equalsIgnoreCase(uri.getScheme())) {
+ String path = getDataColumn(context, uri, null, null);
+ if (path != null) {
+ File file = new File(path);
+ if (!file.canRead()) {
+ return null;
+ }
+ }
+ return path;
+ }
+ // File
+ else if ("file".equalsIgnoreCase(uri.getScheme())) {
+ return uri.getPath();
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the value of the data column for this Uri. This is useful for
+ * MediaStore Uris, and other file-based ContentProviders.
+ *
+ * @param context The context.
+ * @param uri The Uri to query.
+ * @param selection (Optional) Filter used in the query.
+ * @param selectionArgs (Optional) Selection arguments used in the query.
+ * @return The value of the _data column, which is typically a file path.
+ */
+ public static String getDataColumn(Context context, Uri uri, String selection,
+ String[] selectionArgs) {
+
+ Cursor cursor = null;
+ final String column = "_data";
+ final String[] projection = {
+ column
+ };
+
+ try {
+ cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,null);
+ if (cursor != null && cursor.moveToFirst()) {
+ final int column_index = cursor.getColumnIndexOrThrow(column);
+ return cursor.getString(column_index);
+ }
+ } catch(Exception e) {
+ return null;
+ } finally {
+ if (cursor != null) {
+ cursor.close();
+ }
+ }
+ return null;
+ }
+
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is ExternalStorageProvider.
+ */
+ public static boolean isExternalStorageDocument(Uri uri) {
+ return "com.android.externalstorage.documents".equals(uri.getAuthority());
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is DownloadsProvider.
+ */
+ public static boolean isDownloadsDocument(Uri uri) {
+ return "com.android.providers.downloads.documents".equals(uri.getAuthority());
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is MediaProvider.
+ */
+ public static boolean isMediaDocument(Uri uri) {
+ return "com.android.providers.media.documents".equals(uri.getAuthority());
+ }
+}
diff --git a/app/src/main/res/drawable/ic_import_export_black.xml b/app/src/main/res/drawable/ic_import_export_black.xml
new file mode 100644
index 00000000..60c3daaa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_import_export_black.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/menu/activity_view_collection.xml b/app/src/main/res/menu/activity_view_collection.xml
index 530e8426..bf15fecf 100644
--- a/app/src/main/res/menu/activity_view_collection.xml
+++ b/app/src/main/res/menu/activity_view_collection.xml
@@ -14,4 +14,8 @@
android:onClick="onEditCollection"
app:showAsAction="always"/>
+
+
\ 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 26e26a86..8da760a3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -130,6 +130,15 @@
Setting up encryption
Please wait, setting up encryption…
+
+ Import
+ Import Failed
+ Loading file (may take a while)...
+ Adding entries...
+ Processed %1$d entries.\nAdded: %2$d\nChanged: %3$d\nSkipped (failed): %4$d
+ Reading storage permission is required for import.
+ Choose file
+
Settings: %s
Authentication
@@ -188,6 +197,7 @@
Title is required
Description (optional):
Edit
+ Import
Save
Delete
Are you sure?