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?