1
0
mirror of https://github.com/etesync/android synced 2024-12-23 15:18:14 +00:00

Add import from vCard (vcf)/iCal (ics)

This commit adds a basic UI for importing contacts and calendars from a
file.
This commit is contained in:
Tom Hacohen 2017-03-23 13:07:12 +00:00
parent 24b170a170
commit f984b76ec6
9 changed files with 603 additions and 6 deletions

View File

@ -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
-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18"/>
<!-- Used for external log and vcf import. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<!-- other permissions -->
<!-- android.permission-group.CONTACTS -->
<uses-permission android:name="android.permission.READ_CONTACTS"/>
@ -207,8 +209,6 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/log_paths" />
</provider>
</application>
</manifest>

View File

@ -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;

View File

@ -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";
}

View File

@ -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();
}
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:alpha="0.54" >
<path
android:fillColor="#FF000000"
android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"/>
</vector>

View File

@ -14,4 +14,8 @@
android:onClick="onEditCollection"
app:showAsAction="always"/>
<item android:title="@string/view_collection_import"
android:onClick="onImport"
app:showAsAction="never"/>
</menu>

View File

@ -130,6 +130,15 @@
<string name="login_encryption_setup_title">Setting up encryption</string>
<string name="login_encryption_setup">Please wait, setting up encryption…</string>
<!-- ImportFragment -->
<string name="import_dialog_title">Import</string>
<string name="import_dialog_failed_title">Import Failed</string>
<string name="import_dialog_loading_file">Loading file (may take a while)...</string>
<string name="import_dialog_adding_entries">Adding entries...</string>
<string name="import_dialog_success">Processed %1$d entries.\nAdded: %2$d\nChanged: %3$d\nSkipped (failed): %4$d</string>
<string name="import_permission_required">Reading storage permission is required for import.</string>
<string name="choose_file">Choose file</string>
<!-- AccountSettingsActivity -->
<string name="settings_title">Settings: %s</string>
<string name="settings_authentication">Authentication</string>
@ -188,6 +197,7 @@
<string name="create_collection_display_name_required">Title is required</string>
<string name="create_collection_description">Description (optional):</string>
<string name="view_collection_edit">Edit</string>
<string name="view_collection_import">Import</string>
<string name="create_collection_create">Save</string>
<string name="delete_collection">Delete</string>
<string name="delete_collection_confirm_title">Are you sure?</string>