diff options
author | Chiao Cheng <chiaocheng@google.com> | 2012-12-03 17:15:58 -0800 |
---|---|---|
committer | Chiao Cheng <chiaocheng@google.com> | 2012-12-03 17:15:58 -0800 |
commit | 7903d2473e1120e32fa5380a7d7532d0a21e2180 (patch) | |
tree | e38ffca11c65aeb7b12aeac85035548f1f7f4a2f /src/com/android/contacts/common/vcard | |
parent | f0fb1f31683808c3ebfce234dc0a882e6e6cac1c (diff) | |
download | packages_apps_ContactsCommon-7903d2473e1120e32fa5380a7d7532d0a21e2180.tar.gz packages_apps_ContactsCommon-7903d2473e1120e32fa5380a7d7532d0a21e2180.tar.bz2 packages_apps_ContactsCommon-7903d2473e1120e32fa5380a7d7532d0a21e2180.zip |
Moving vcard UI to ContactsCommon.
Moving all class in vcard directory in preparation to move
ImportExportDialogFragment.
Bug: 6993891
Change-Id: I4391c6e63d20ebe91e240001885a6ce18388e51f
Diffstat (limited to 'src/com/android/contacts/common/vcard')
15 files changed, 3585 insertions, 0 deletions
diff --git a/src/com/android/contacts/common/vcard/CancelActivity.java b/src/com/android/contacts/common/vcard/CancelActivity.java new file mode 100644 index 00000000..20869f74 --- /dev/null +++ b/src/com/android/contacts/common/vcard/CancelActivity.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +import com.android.contacts.common.R; + +/** + * The Activity for canceling vCard import/export. + */ +public class CancelActivity extends Activity implements ServiceConnection { + private final String LOG_TAG = "VCardCancel"; + + /* package */ final static String JOB_ID = "job_id"; + /* package */ final static String DISPLAY_NAME = "display_name"; + + /** + * Type of the process to be canceled. Only used for choosing appropriate title/message. + * Must be {@link VCardService#TYPE_IMPORT} or {@link VCardService#TYPE_EXPORT}. + */ + /* package */ final static String TYPE = "type"; + + private class RequestCancelListener implements DialogInterface.OnClickListener { + @Override + public void onClick(DialogInterface dialog, int which) { + bindService(new Intent(CancelActivity.this, + VCardService.class), CancelActivity.this, Context.BIND_AUTO_CREATE); + } + } + + private class CancelListener + implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener { + @Override + public void onClick(DialogInterface dialog, int which) { + finish(); + } + @Override + public void onCancel(DialogInterface dialog) { + finish(); + } + } + + private final CancelListener mCancelListener = new CancelListener(); + private int mJobId; + private String mDisplayName; + private int mType; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final Uri uri = getIntent().getData(); + mJobId = Integer.parseInt(uri.getQueryParameter(JOB_ID)); + mDisplayName = uri.getQueryParameter(DISPLAY_NAME); + mType = Integer.parseInt(uri.getQueryParameter(TYPE)); + showDialog(R.id.dialog_cancel_confirmation); + } + + @Override + protected Dialog onCreateDialog(int id, Bundle bundle) { + switch (id) { + case R.id.dialog_cancel_confirmation: { + final String message; + if (mType == VCardService.TYPE_IMPORT) { + message = getString(R.string.cancel_import_confirmation_message, mDisplayName); + } else { + message = getString(R.string.cancel_export_confirmation_message, mDisplayName); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setMessage(message) + .setPositiveButton(android.R.string.ok, new RequestCancelListener()) + .setOnCancelListener(mCancelListener) + .setNegativeButton(android.R.string.cancel, mCancelListener); + return builder.create(); + } + case R.id.dialog_cancel_failed: + final AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(R.string.cancel_vcard_import_or_export_failed) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(getString(R.string.fail_reason_unknown)) + .setOnCancelListener(mCancelListener) + .setPositiveButton(android.R.string.ok, mCancelListener); + return builder.create(); + default: + Log.w(LOG_TAG, "Unknown dialog id: " + id); + break; + } + return super.onCreateDialog(id, bundle); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + VCardService service = ((VCardService.MyBinder) binder).getService(); + + try { + final CancelRequest request = new CancelRequest(mJobId, mDisplayName); + service.handleCancelRequest(request, null); + } finally { + unbindService(this); + } + + finish(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // do nothing + } +} diff --git a/src/com/android/contacts/common/vcard/CancelRequest.java b/src/com/android/contacts/common/vcard/CancelRequest.java new file mode 100644 index 00000000..a5eb4aa3 --- /dev/null +++ b/src/com/android/contacts/common/vcard/CancelRequest.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +/** + * Class representing one request for canceling vCard import/export. + */ +public class CancelRequest { + public final int jobId; + /** + * Name used for showing users some useful info. Typically a file name. + * Must not be used to do some actual operations. + */ + public final String displayName; + public CancelRequest(int jobId, String displayName) { + this.jobId = jobId; + this.displayName = displayName; + } +} diff --git a/src/com/android/contacts/common/vcard/ExportProcessor.java b/src/com/android/contacts/common/vcard/ExportProcessor.java new file mode 100644 index 00000000..0d5c2f35 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ExportProcessor.java @@ -0,0 +1,292 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.app.Notification; +import android.app.NotificationManager; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.RawContactsEntity; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.R; +import com.android.vcard.VCardComposer; +import com.android.vcard.VCardConfig; + +import java.io.BufferedWriter; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; + +/** + * Class for processing one export request from a user. Dropped after exporting requested Uri(s). + * {@link VCardService} will create another object when there is another export request. + */ +public class ExportProcessor extends ProcessorBase { + private static final String LOG_TAG = "VCardExport"; + private static final boolean DEBUG = VCardService.DEBUG; + + private final VCardService mService; + private final ContentResolver mResolver; + private final NotificationManager mNotificationManager; + private final ExportRequest mExportRequest; + private final int mJobId; + private final String mCallingActivity; + + + private volatile boolean mCanceled; + private volatile boolean mDone; + + public ExportProcessor(VCardService service, ExportRequest exportRequest, int jobId, + String callingActivity) { + mService = service; + mResolver = service.getContentResolver(); + mNotificationManager = + (NotificationManager)mService.getSystemService(Context.NOTIFICATION_SERVICE); + mExportRequest = exportRequest; + mJobId = jobId; + mCallingActivity = callingActivity; + } + + @Override + public final int getType() { + return VCardService.TYPE_EXPORT; + } + + @Override + public void run() { + // ExecutorService ignores RuntimeException, so we need to show it here. + try { + runInternal(); + + if (isCancelled()) { + doCancelNotification(); + } + } catch (OutOfMemoryError e) { + Log.e(LOG_TAG, "OutOfMemoryError thrown during import", e); + throw e; + } catch (RuntimeException e) { + Log.e(LOG_TAG, "RuntimeException thrown during export", e); + throw e; + } finally { + synchronized (this) { + mDone = true; + } + } + } + + private void runInternal() { + if (DEBUG) Log.d(LOG_TAG, String.format("vCard export (id: %d) has started.", mJobId)); + final ExportRequest request = mExportRequest; + VCardComposer composer = null; + Writer writer = null; + boolean successful = false; + try { + if (isCancelled()) { + Log.i(LOG_TAG, "Export request is cancelled before handling the request"); + return; + } + final Uri uri = request.destUri; + final OutputStream outputStream; + try { + outputStream = mResolver.openOutputStream(uri); + } catch (FileNotFoundException e) { + Log.w(LOG_TAG, "FileNotFoundException thrown", e); + // Need concise title. + + final String errorReason = + mService.getString(R.string.fail_reason_could_not_open_file, + uri, e.getMessage()); + doFinishNotification(errorReason, null); + return; + } + + final String exportType = request.exportType; + final int vcardType; + if (TextUtils.isEmpty(exportType)) { + vcardType = VCardConfig.getVCardTypeFromString( + mService.getString(R.string.config_export_vcard_type)); + } else { + vcardType = VCardConfig.getVCardTypeFromString(exportType); + } + + composer = new VCardComposer(mService, vcardType, true); + + // for test + // int vcardType = (VCardConfig.VCARD_TYPE_V21_GENERIC | + // VCardConfig.FLAG_USE_QP_TO_PRIMARY_PROPERTIES); + // composer = new VCardComposer(ExportVCardActivity.this, vcardType, true); + + writer = new BufferedWriter(new OutputStreamWriter(outputStream)); + final Uri contentUriForRawContactsEntity = RawContactsEntity.CONTENT_URI.buildUpon() + .appendQueryParameter(RawContactsEntity.FOR_EXPORT_ONLY, "1") + .build(); + // TODO: should provide better selection. + if (!composer.init(Contacts.CONTENT_URI, new String[] {Contacts._ID}, + null, null, + null, contentUriForRawContactsEntity)) { + final String errorReason = composer.getErrorReason(); + Log.e(LOG_TAG, "initialization of vCard composer failed: " + errorReason); + final String translatedErrorReason = + translateComposerError(errorReason); + final String title = + mService.getString(R.string.fail_reason_could_not_initialize_exporter, + translatedErrorReason); + doFinishNotification(title, null); + return; + } + + final int total = composer.getCount(); + if (total == 0) { + final String title = + mService.getString(R.string.fail_reason_no_exportable_contact); + doFinishNotification(title, null); + return; + } + + int current = 1; // 1-origin + while (!composer.isAfterLast()) { + if (isCancelled()) { + Log.i(LOG_TAG, "Export request is cancelled during composing vCard"); + return; + } + try { + writer.write(composer.createOneEntry()); + } catch (IOException e) { + final String errorReason = composer.getErrorReason(); + Log.e(LOG_TAG, "Failed to read a contact: " + errorReason); + final String translatedErrorReason = + translateComposerError(errorReason); + final String title = + mService.getString(R.string.fail_reason_error_occurred_during_export, + translatedErrorReason); + doFinishNotification(title, null); + return; + } + + // vCard export is quite fast (compared to import), and frequent notifications + // bother notification bar too much. + if (current % 100 == 1) { + doProgressNotification(uri, total, current); + } + current++; + } + Log.i(LOG_TAG, "Successfully finished exporting vCard " + request.destUri); + + if (DEBUG) { + Log.d(LOG_TAG, "Ask MediaScanner to scan the file: " + request.destUri.getPath()); + } + mService.updateMediaScanner(request.destUri.getPath()); + + successful = true; + final String filename = uri.getLastPathSegment(); + final String title = mService.getString(R.string.exporting_vcard_finished_title, + filename); + doFinishNotification(title, null); + } finally { + if (composer != null) { + composer.terminate(); + } + if (writer != null) { + try { + writer.close(); + } catch (IOException e) { + Log.w(LOG_TAG, "IOException is thrown during close(). Ignored. " + e); + } + } + mService.handleFinishExportNotification(mJobId, successful); + } + } + + private String translateComposerError(String errorMessage) { + final Resources resources = mService.getResources(); + if (VCardComposer.FAILURE_REASON_FAILED_TO_GET_DATABASE_INFO.equals(errorMessage)) { + return resources.getString(R.string.composer_failed_to_get_database_infomation); + } else if (VCardComposer.FAILURE_REASON_NO_ENTRY.equals(errorMessage)) { + return resources.getString(R.string.composer_has_no_exportable_contact); + } else if (VCardComposer.FAILURE_REASON_NOT_INITIALIZED.equals(errorMessage)) { + return resources.getString(R.string.composer_not_initialized); + } else { + return errorMessage; + } + } + + private void doProgressNotification(Uri uri, int totalCount, int currentCount) { + final String displayName = uri.getLastPathSegment(); + final String description = + mService.getString(R.string.exporting_contact_list_message, displayName); + final String tickerText = + mService.getString(R.string.exporting_contact_list_title); + final Notification notification = + NotificationImportExportListener.constructProgressNotification(mService, + VCardService.TYPE_EXPORT, description, tickerText, mJobId, displayName, + totalCount, currentCount); + mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG, + mJobId, notification); + } + + private void doCancelNotification() { + if (DEBUG) Log.d(LOG_TAG, "send cancel notification"); + final String description = mService.getString(R.string.exporting_vcard_canceled_title, + mExportRequest.destUri.getLastPathSegment()); + final Notification notification = + NotificationImportExportListener.constructCancelNotification(mService, description); + mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG, + mJobId, notification); + } + + private void doFinishNotification(final String title, final String description) { + if (DEBUG) Log.d(LOG_TAG, "send finish notification: " + title + ", " + description); + final Intent intent = new Intent(); + intent.setClassName(mService, mCallingActivity); + final Notification notification = + NotificationImportExportListener.constructFinishNotification(mService, title, + description, intent); + mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG, + mJobId, notification); + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (DEBUG) Log.d(LOG_TAG, "received cancel request"); + if (mDone || mCanceled) { + return false; + } + mCanceled = true; + return true; + } + + @Override + public synchronized boolean isCancelled() { + return mCanceled; + } + + @Override + public synchronized boolean isDone() { + return mDone; + } + + public ExportRequest getRequest() { + return mExportRequest; + } +} diff --git a/src/com/android/contacts/common/vcard/ExportRequest.java b/src/com/android/contacts/common/vcard/ExportRequest.java new file mode 100644 index 00000000..e05a32c5 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ExportRequest.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.net.Uri; + +public class ExportRequest { + public final Uri destUri; + /** + * Can be null. + */ + public final String exportType; + + public ExportRequest(Uri destUri) { + this(destUri, null); + } + + public ExportRequest(Uri destUri, String exportType) { + this.destUri = destUri; + this.exportType = exportType; + } +} diff --git a/src/com/android/contacts/common/vcard/ExportVCardActivity.java b/src/com/android/contacts/common/vcard/ExportVCardActivity.java new file mode 100644 index 00000000..3d6f6022 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ExportVCardActivity.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.text.TextUtils; +import android.util.Log; + +import com.android.contacts.common.R; + +import java.io.File; + +/** + * Shows a dialog confirming the export and asks actual vCard export to {@link VCardService} + * + * This Activity first connects to VCardService and ask an available file name and shows it to + * a user. After the user's confirmation, it send export request with the file name, assuming the + * file name is not reserved yet. + */ +public class ExportVCardActivity extends Activity implements ServiceConnection, + DialogInterface.OnClickListener, DialogInterface.OnCancelListener { + private static final String LOG_TAG = "VCardExport"; + private static final boolean DEBUG = VCardService.DEBUG; + + /** + * Handler used when some Message has come from {@link VCardService}. + */ + private class IncomingHandler extends Handler { + @Override + public void handleMessage(Message msg) { + if (DEBUG) Log.d(LOG_TAG, "IncomingHandler received message."); + + if (msg.arg1 != 0) { + Log.i(LOG_TAG, "Message returned from vCard server contains error code."); + if (msg.obj != null) { + mErrorReason = (String)msg.obj; + } + showDialog(msg.arg1); + return; + } + + switch (msg.what) { + case VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION: + if (msg.obj == null) { + Log.w(LOG_TAG, "Message returned from vCard server doesn't contain valid path"); + mErrorReason = getString(R.string.fail_reason_unknown); + showDialog(R.id.dialog_fail_to_export_with_reason); + } else { + mTargetFileName = (String)msg.obj; + if (TextUtils.isEmpty(mTargetFileName)) { + Log.w(LOG_TAG, "Destination file name coming from vCard service is empty."); + mErrorReason = getString(R.string.fail_reason_unknown); + showDialog(R.id.dialog_fail_to_export_with_reason); + } else { + if (DEBUG) { + Log.d(LOG_TAG, + String.format("Target file name is set (%s). " + + "Show confirmation dialog", mTargetFileName)); + } + showDialog(R.id.dialog_export_confirmation); + } + } + break; + default: + Log.w(LOG_TAG, "Unknown message type: " + msg.what); + super.handleMessage(msg); + } + } + } + + /** + * True when this Activity is connected to {@link VCardService}. + * + * Should be touched inside synchronized block. + */ + private boolean mConnected; + + /** + * True when users need to do something and this Activity should not disconnect from + * VCardService. False when all necessary procedures are done (including sending export request) + * or there's some error occured. + */ + private volatile boolean mProcessOngoing = true; + + private VCardService mService; + private final Messenger mIncomingMessenger = new Messenger(new IncomingHandler()); + + // Used temporarily when asking users to confirm the file name + private String mTargetFileName; + + // String for storing error reason temporarily. + private String mErrorReason; + + private class ExportConfirmationListener implements DialogInterface.OnClickListener { + private final Uri mDestinationUri; + + public ExportConfirmationListener(String path) { + this(Uri.parse("file://" + path)); + } + + public ExportConfirmationListener(Uri uri) { + mDestinationUri = uri; + } + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + if (DEBUG) { + Log.d(LOG_TAG, + String.format("Try sending export request (uri: %s)", mDestinationUri)); + } + final ExportRequest request = new ExportRequest(mDestinationUri); + // The connection object will call finish(). + mService.handleExportRequest(request, new NotificationImportExportListener( + ExportVCardActivity.this)); + } + unbindAndFinish(); + } + } + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + + // Check directory is available. + if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { + Log.w(LOG_TAG, "External storage is in state " + Environment.getExternalStorageState() + + ". Cancelling export"); + showDialog(R.id.dialog_sdcard_not_found); + return; + } + + final File targetDirectory = Environment.getExternalStorageDirectory(); + if (!(targetDirectory.exists() && + targetDirectory.isDirectory() && + targetDirectory.canRead()) && + !targetDirectory.mkdirs()) { + showDialog(R.id.dialog_sdcard_not_found); + return; + } + + final String callingActivity = getIntent().getExtras() + .getString(VCardCommonArguments.ARG_CALLING_ACTIVITY); + Intent intent = new Intent(this, VCardService.class); + intent.putExtra(VCardCommonArguments.ARG_CALLING_ACTIVITY, callingActivity); + + if (startService(intent) == null) { + Log.e(LOG_TAG, "Failed to start vCard service"); + mErrorReason = getString(R.string.fail_reason_unknown); + showDialog(R.id.dialog_fail_to_export_with_reason); + return; + } + + if (!bindService(intent, this, Context.BIND_AUTO_CREATE)) { + Log.e(LOG_TAG, "Failed to connect to vCard service."); + mErrorReason = getString(R.string.fail_reason_unknown); + showDialog(R.id.dialog_fail_to_export_with_reason); + } + // Continued to onServiceConnected() + } + + @Override + public synchronized void onServiceConnected(ComponentName name, IBinder binder) { + if (DEBUG) Log.d(LOG_TAG, "connected to service, requesting a destination file name"); + mConnected = true; + mService = ((VCardService.MyBinder) binder).getService(); + mService.handleRequestAvailableExportDestination(mIncomingMessenger); + // Wait until MSG_SET_AVAILABLE_EXPORT_DESTINATION message is available. + } + + // Use synchronized since we don't want to call unbindAndFinish() just after this call. + @Override + public synchronized void onServiceDisconnected(ComponentName name) { + if (DEBUG) Log.d(LOG_TAG, "onServiceDisconnected()"); + mService = null; + mConnected = false; + if (mProcessOngoing) { + // Unexpected disconnect event. + Log.w(LOG_TAG, "Disconnected from service during the process ongoing."); + mErrorReason = getString(R.string.fail_reason_unknown); + showDialog(R.id.dialog_fail_to_export_with_reason); + } + } + + @Override + protected Dialog onCreateDialog(int id, Bundle bundle) { + switch (id) { + case R.id.dialog_export_confirmation: { + return new AlertDialog.Builder(this) + .setTitle(R.string.confirm_export_title) + .setMessage(getString(R.string.confirm_export_message, mTargetFileName)) + .setPositiveButton(android.R.string.ok, + new ExportConfirmationListener(mTargetFileName)) + .setNegativeButton(android.R.string.cancel, this) + .setOnCancelListener(this) + .create(); + } + case R.string.fail_reason_too_many_vcard: { + mProcessOngoing = false; + return new AlertDialog.Builder(this) + .setTitle(R.string.exporting_contact_failed_title) + .setMessage(getString(R.string.exporting_contact_failed_message, + getString(R.string.fail_reason_too_many_vcard))) + .setPositiveButton(android.R.string.ok, this) + .create(); + } + case R.id.dialog_fail_to_export_with_reason: { + mProcessOngoing = false; + return new AlertDialog.Builder(this) + .setTitle(R.string.exporting_contact_failed_title) + .setMessage(getString(R.string.exporting_contact_failed_message, + mErrorReason != null ? mErrorReason : + getString(R.string.fail_reason_unknown))) + .setPositiveButton(android.R.string.ok, this) + .setOnCancelListener(this) + .create(); + } + case R.id.dialog_sdcard_not_found: { + mProcessOngoing = false; + return new AlertDialog.Builder(this) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(R.string.no_sdcard_message) + .setPositiveButton(android.R.string.ok, this).create(); + } + } + return super.onCreateDialog(id, bundle); + } + + @Override + protected void onPrepareDialog(int id, Dialog dialog, Bundle args) { + if (id == R.id.dialog_fail_to_export_with_reason) { + ((AlertDialog)dialog).setMessage(mErrorReason); + } else if (id == R.id.dialog_export_confirmation) { + ((AlertDialog)dialog).setMessage( + getString(R.string.confirm_export_message, mTargetFileName)); + } else { + super.onPrepareDialog(id, dialog, args); + } + } + + @Override + protected void onStop() { + super.onStop(); + + if (!isFinishing()) { + unbindAndFinish(); + } + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (DEBUG) Log.d(LOG_TAG, "ExportVCardActivity#onClick() is called"); + unbindAndFinish(); + } + + @Override + public void onCancel(DialogInterface dialog) { + if (DEBUG) Log.d(LOG_TAG, "ExportVCardActivity#onCancel() is called"); + mProcessOngoing = false; + unbindAndFinish(); + } + + @Override + public void unbindService(ServiceConnection conn) { + mProcessOngoing = false; + super.unbindService(conn); + } + + private synchronized void unbindAndFinish() { + if (mConnected) { + unbindService(this); + mConnected = false; + } + finish(); + } +} diff --git a/src/com/android/contacts/common/vcard/ImportProcessor.java b/src/com/android/contacts/common/vcard/ImportProcessor.java new file mode 100644 index 00000000..37128755 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ImportProcessor.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.accounts.Account; +import android.content.ContentResolver; +import android.net.Uri; +import android.util.Log; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCommitter; +import com.android.vcard.VCardEntryConstructor; +import com.android.vcard.VCardEntryHandler; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardNotSupportedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Class for processing one import request from a user. Dropped after importing requested Uri(s). + * {@link VCardService} will create another object when there is another import request. + */ +public class ImportProcessor extends ProcessorBase implements VCardEntryHandler { + private static final String LOG_TAG = "VCardImport"; + private static final boolean DEBUG = VCardService.DEBUG; + + private final VCardService mService; + private final ContentResolver mResolver; + private final ImportRequest mImportRequest; + private final int mJobId; + private final VCardImportExportListener mListener; + + // TODO: remove and show appropriate message instead. + private final List<Uri> mFailedUris = new ArrayList<Uri>(); + + private VCardParser mVCardParser; + + private volatile boolean mCanceled; + private volatile boolean mDone; + + private int mCurrentCount = 0; + private int mTotalCount = 0; + + public ImportProcessor(final VCardService service, final VCardImportExportListener listener, + final ImportRequest request, final int jobId) { + mService = service; + mResolver = mService.getContentResolver(); + mListener = listener; + + mImportRequest = request; + mJobId = jobId; + } + + @Override + public void onStart() { + // do nothing + } + + @Override + public void onEnd() { + // do nothing + } + + @Override + public void onEntryCreated(VCardEntry entry) { + mCurrentCount++; + if (mListener != null) { + mListener.onImportParsed(mImportRequest, mJobId, entry, mCurrentCount, mTotalCount); + } + } + + @Override + public final int getType() { + return VCardService.TYPE_IMPORT; + } + + @Override + public void run() { + // ExecutorService ignores RuntimeException, so we need to show it here. + try { + runInternal(); + + if (isCancelled() && mListener != null) { + mListener.onImportCanceled(mImportRequest, mJobId); + } + } catch (OutOfMemoryError e) { + Log.e(LOG_TAG, "OutOfMemoryError thrown during import", e); + throw e; + } catch (RuntimeException e) { + Log.e(LOG_TAG, "RuntimeException thrown during import", e); + throw e; + } finally { + synchronized (this) { + mDone = true; + } + } + } + + private void runInternal() { + Log.i(LOG_TAG, String.format("vCard import (id: %d) has started.", mJobId)); + final ImportRequest request = mImportRequest; + if (isCancelled()) { + Log.i(LOG_TAG, "Canceled before actually handling parameter (" + request.uri + ")"); + return; + } + final int[] possibleVCardVersions; + if (request.vcardVersion == ImportVCardActivity.VCARD_VERSION_AUTO_DETECT) { + /** + * Note: this code assumes that a given Uri is able to be opened more than once, + * which may not be true in certain conditions. + */ + possibleVCardVersions = new int[] { + ImportVCardActivity.VCARD_VERSION_V21, + ImportVCardActivity.VCARD_VERSION_V30 + }; + } else { + possibleVCardVersions = new int[] { + request.vcardVersion + }; + } + + final Uri uri = request.uri; + final Account account = request.account; + final int estimatedVCardType = request.estimatedVCardType; + final String estimatedCharset = request.estimatedCharset; + final int entryCount = request.entryCount; + mTotalCount += entryCount; + + final VCardEntryConstructor constructor = + new VCardEntryConstructor(estimatedVCardType, account, estimatedCharset); + final VCardEntryCommitter committer = new VCardEntryCommitter(mResolver); + constructor.addEntryHandler(committer); + constructor.addEntryHandler(this); + + InputStream is = null; + boolean successful = false; + try { + if (uri != null) { + Log.i(LOG_TAG, "start importing one vCard (Uri: " + uri + ")"); + is = mResolver.openInputStream(uri); + } else if (request.data != null){ + Log.i(LOG_TAG, "start importing one vCard (byte[])"); + is = new ByteArrayInputStream(request.data); + } + + if (is != null) { + successful = readOneVCard(is, estimatedVCardType, estimatedCharset, constructor, + possibleVCardVersions); + } + } catch (IOException e) { + successful = false; + } finally { + if (is != null) { + try { + is.close(); + } catch (Exception e) { + // ignore + } + } + } + + mService.handleFinishImportNotification(mJobId, successful); + + if (successful) { + // TODO: successful becomes true even when cancelled. Should return more appropriate + // value + if (isCancelled()) { + Log.i(LOG_TAG, "vCard import has been canceled (uri: " + uri + ")"); + // Cancel notification will be done outside this method. + } else { + Log.i(LOG_TAG, "Successfully finished importing one vCard file: " + uri); + List<Uri> uris = committer.getCreatedUris(); + if (mListener != null) { + if (uris != null && uris.size() > 0) { + // TODO: construct intent showing a list of imported contact list. + mListener.onImportFinished(mImportRequest, mJobId, uris.get(0)); + } else { + // Not critical, but suspicious. + Log.w(LOG_TAG, + "Created Uris is null or 0 length " + + "though the creation itself is successful."); + mListener.onImportFinished(mImportRequest, mJobId, null); + } + } + } + } else { + Log.w(LOG_TAG, "Failed to read one vCard file: " + uri); + mFailedUris.add(uri); + } + } + + private boolean readOneVCard(InputStream is, int vcardType, String charset, + final VCardInterpreter interpreter, + final int[] possibleVCardVersions) { + boolean successful = false; + final int length = possibleVCardVersions.length; + for (int i = 0; i < length; i++) { + final int vcardVersion = possibleVCardVersions[i]; + try { + if (i > 0 && (interpreter instanceof VCardEntryConstructor)) { + // Let the object clean up internal temporary objects, + ((VCardEntryConstructor) interpreter).clear(); + } + + // We need synchronized block here, + // since we need to handle mCanceled and mVCardParser at once. + // In the worst case, a user may call cancel() just before creating + // mVCardParser. + synchronized (this) { + mVCardParser = (vcardVersion == ImportVCardActivity.VCARD_VERSION_V30 ? + new VCardParser_V30(vcardType) : + new VCardParser_V21(vcardType)); + if (isCancelled()) { + Log.i(LOG_TAG, "ImportProcessor already recieves cancel request, so " + + "send cancel request to vCard parser too."); + mVCardParser.cancel(); + } + } + mVCardParser.parse(is, interpreter); + + successful = true; + break; + } catch (IOException e) { + Log.e(LOG_TAG, "IOException was emitted: " + e.getMessage()); + } catch (VCardNestedException e) { + // This exception should not be thrown here. We should instead handle it + // in the preprocessing session in ImportVCardActivity, as we don't try + // to detect the type of given vCard here. + // + // TODO: Handle this case appropriately, which should mean we have to have + // code trying to auto-detect the type of given vCard twice (both in + // ImportVCardActivity and ImportVCardService). + Log.e(LOG_TAG, "Nested Exception is found."); + } catch (VCardNotSupportedException e) { + Log.e(LOG_TAG, e.toString()); + } catch (VCardVersionException e) { + if (i == length - 1) { + Log.e(LOG_TAG, "Appropriate version for this vCard is not found."); + } else { + // We'll try the other (v30) version. + } + } catch (VCardException e) { + Log.e(LOG_TAG, e.toString()); + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + } + + return successful; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (DEBUG) Log.d(LOG_TAG, "ImportProcessor received cancel request"); + if (mDone || mCanceled) { + return false; + } + mCanceled = true; + synchronized (this) { + if (mVCardParser != null) { + mVCardParser.cancel(); + } + } + return true; + } + + @Override + public synchronized boolean isCancelled() { + return mCanceled; + } + + + @Override + public synchronized boolean isDone() { + return mDone; + } +} diff --git a/src/com/android/contacts/common/vcard/ImportRequest.java b/src/com/android/contacts/common/vcard/ImportRequest.java new file mode 100644 index 00000000..0fab8f51 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ImportRequest.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.accounts.Account; +import android.net.Uri; + +import com.android.vcard.VCardSourceDetector; + +/** + * Class representing one request for importing vCard (given as a Uri). + * + * Mainly used when {@link ImportVCardActivity} requests {@link VCardService} + * to import some specific Uri. + * + * Note: This object's accepting only One Uri does NOT mean that + * there's only one vCard entry inside the instance, as one Uri often has multiple + * vCard entries inside it. + */ +public class ImportRequest { + /** + * Can be null (typically when there's no Account available in the system). + */ + public final Account account; + + /** + * Uri to be imported. May have different content than originally given from users, so + * when displaying user-friendly information (e.g. "importing xxx.vcf"), use + * {@link #displayName} instead. + * + * If this is null {@link #data} contains the byte stream of the vcard. + */ + public final Uri uri; + + /** + * Holds the byte stream of the vcard, if {@link #uri} is null. + */ + public final byte[] data; + + /** + * String to be displayed to the user to indicate the source of the VCARD. + */ + public final String displayName; + + /** + * Can be {@link VCardSourceDetector#PARSE_TYPE_UNKNOWN}. + */ + public final int estimatedVCardType; + + /** + * Can be null, meaning no preferable charset is available. + */ + public final String estimatedCharset; + + /** + * Assumes that one Uri contains only one version, while there's a (tiny) possibility + * we may have two types in one vCard. + * + * e.g. + * BEGIN:VCARD + * VERSION:2.1 + * ... + * END:VCARD + * BEGIN:VCARD + * VERSION:3.0 + * ... + * END:VCARD + * + * We've never seen this kind of a file, but we may have to cope with it in the future. + */ + public final int vcardVersion; + + /** + * The count of vCard entries in {@link #uri}. A receiver of this object can use it + * when showing the progress of import. Thus a receiver must be able to torelate this + * variable being invalid because of vCard's limitation. + * + * vCard does not let us know this count without looking over a whole file content, + * which means we have to open and scan over {@link #uri} to know this value, while + * it may not be opened more than once (Uri does not require it to be opened multiple times + * and may become invalid after its close() request). + */ + public final int entryCount; + + public ImportRequest(Account account, + byte[] data, Uri uri, String displayName, int estimatedType, String estimatedCharset, + int vcardVersion, int entryCount) { + this.account = account; + this.data = data; + this.uri = uri; + this.displayName = displayName; + this.estimatedVCardType = estimatedType; + this.estimatedCharset = estimatedCharset; + this.vcardVersion = vcardVersion; + this.entryCount = entryCount; + } +} diff --git a/src/com/android/contacts/common/vcard/ImportVCardActivity.java b/src/com/android/contacts/common/vcard/ImportVCardActivity.java new file mode 100644 index 00000000..70c98215 --- /dev/null +++ b/src/com/android/contacts/common/vcard/ImportVCardActivity.java @@ -0,0 +1,1032 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.ProgressDialog; +import android.content.ComponentName; +import android.content.ContentResolver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.content.ServiceConnection; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.PowerManager; +import android.provider.OpenableColumns; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.RelativeSizeSpan; +import android.util.Log; +import android.widget.Toast; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.util.AccountSelectionUtil; +import com.android.vcard.VCardEntryCounter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.VCardSourceDetector; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import java.nio.channels.WritableByteChannel; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Vector; + +/** + * The class letting users to import vCard. This includes the UI part for letting them select + * an Account and posssibly a file if there's no Uri is given from its caller Activity. + * + * Note that this Activity assumes that the instance is a "one-shot Activity", which will be + * finished (with the method {@link Activity#finish()}) after the import and never reuse + * any Dialog in the instance. So this code is careless about the management around managed + * dialogs stuffs (like how onCreateDialog() is used). + */ +public class ImportVCardActivity extends Activity { + private static final String LOG_TAG = "VCardImport"; + + private static final int SELECT_ACCOUNT = 0; + + /* package */ static final String VCARD_URI_ARRAY = "vcard_uri"; + /* package */ static final String ESTIMATED_VCARD_TYPE_ARRAY = "estimated_vcard_type"; + /* package */ static final String ESTIMATED_CHARSET_ARRAY = "estimated_charset"; + /* package */ static final String VCARD_VERSION_ARRAY = "vcard_version"; + /* package */ static final String ENTRY_COUNT_ARRAY = "entry_count"; + + /* package */ final static int VCARD_VERSION_AUTO_DETECT = 0; + /* package */ final static int VCARD_VERSION_V21 = 1; + /* package */ final static int VCARD_VERSION_V30 = 2; + + private static final String SECURE_DIRECTORY_NAME = ".android_secure"; + + /** + * Notification id used when error happened before sending an import request to VCardServer. + */ + private static final int FAILURE_NOTIFICATION_ID = 1; + + final static String CACHED_URIS = "cached_uris"; + + private AccountSelectionUtil.AccountSelectedListener mAccountSelectionListener; + + private AccountWithDataSet mAccount; + + private ProgressDialog mProgressDialogForScanVCard; + private ProgressDialog mProgressDialogForCachingVCard; + + private List<VCardFile> mAllVCardFileList; + private VCardScanThread mVCardScanThread; + + private VCardCacheThread mVCardCacheThread; + private ImportRequestConnection mConnection; + /* package */ VCardImportExportListener mListener; + + private String mErrorMessage; + + private Handler mHandler = new Handler(); + + private static class VCardFile { + private final String mName; + private final String mCanonicalPath; + private final long mLastModified; + + public VCardFile(String name, String canonicalPath, long lastModified) { + mName = name; + mCanonicalPath = canonicalPath; + mLastModified = lastModified; + } + + public String getName() { + return mName; + } + + public String getCanonicalPath() { + return mCanonicalPath; + } + + public long getLastModified() { + return mLastModified; + } + } + + // Runs on the UI thread. + private class DialogDisplayer implements Runnable { + private final int mResId; + public DialogDisplayer(int resId) { + mResId = resId; + } + public DialogDisplayer(String errorMessage) { + mResId = R.id.dialog_error_with_message; + mErrorMessage = errorMessage; + } + @Override + public void run() { + if (!isFinishing()) { + showDialog(mResId); + } + } + } + + private class CancelListener + implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener { + @Override + public void onClick(DialogInterface dialog, int which) { + finish(); + } + @Override + public void onCancel(DialogInterface dialog) { + finish(); + } + } + + private CancelListener mCancelListener = new CancelListener(); + + private class ImportRequestConnection implements ServiceConnection { + private VCardService mService; + + public void sendImportRequest(final List<ImportRequest> requests) { + Log.i(LOG_TAG, "Send an import request"); + mService.handleImportRequest(requests, mListener); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + mService = ((VCardService.MyBinder) binder).getService(); + Log.i(LOG_TAG, + String.format("Connected to VCardService. Kick a vCard cache thread (uri: %s)", + Arrays.toString(mVCardCacheThread.getSourceUris()))); + mVCardCacheThread.start(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.i(LOG_TAG, "Disconnected from VCardService"); + } + } + + /** + * Caches given vCard files into a local directory, and sends actual import request to + * {@link VCardService}. + * + * We need to cache given files into local storage. One of reasons is that some data (as Uri) + * may have special permissions. Callers may allow only this Activity to access that content, + * not what this Activity launched (like {@link VCardService}). + */ + private class VCardCacheThread extends Thread + implements DialogInterface.OnCancelListener { + private boolean mCanceled; + private PowerManager.WakeLock mWakeLock; + private VCardParser mVCardParser; + private final Uri[] mSourceUris; // Given from a caller. + private final byte[] mSource; + private final String mDisplayName; + + public VCardCacheThread(final Uri[] sourceUris) { + mSourceUris = sourceUris; + mSource = null; + final Context context = ImportVCardActivity.this; + final PowerManager powerManager = + (PowerManager)context.getSystemService(Context.POWER_SERVICE); + mWakeLock = powerManager.newWakeLock( + PowerManager.SCREEN_DIM_WAKE_LOCK | + PowerManager.ON_AFTER_RELEASE, LOG_TAG); + mDisplayName = null; + } + + @Override + public void finalize() { + if (mWakeLock != null && mWakeLock.isHeld()) { + Log.w(LOG_TAG, "WakeLock is being held."); + mWakeLock.release(); + } + } + + @Override + public void run() { + Log.i(LOG_TAG, "vCard cache thread starts running."); + if (mConnection == null) { + throw new NullPointerException("vCard cache thread must be launched " + + "after a service connection is established"); + } + + mWakeLock.acquire(); + try { + if (mCanceled == true) { + Log.i(LOG_TAG, "vCard cache operation is canceled."); + return; + } + + final Context context = ImportVCardActivity.this; + // Uris given from caller applications may not be opened twice: consider when + // it is not from local storage (e.g. "file:///...") but from some special + // provider (e.g. "content://..."). + // Thus we have to once copy the content of Uri into local storage, and read + // it after it. + // + // We may be able to read content of each vCard file during copying them + // to local storage, but currently vCard code does not allow us to do so. + int cache_index = 0; + ArrayList<ImportRequest> requests = new ArrayList<ImportRequest>(); + if (mSource != null) { + try { + requests.add(constructImportRequest(mSource, null, mDisplayName)); + } catch (VCardException e) { + Log.e(LOG_TAG, "Maybe the file is in wrong format", e); + showFailureNotification(R.string.fail_reason_not_supported); + return; + } + } else { + final ContentResolver resolver = + ImportVCardActivity.this.getContentResolver(); + for (Uri sourceUri : mSourceUris) { + String filename = null; + // Note: caches are removed by VCardService. + while (true) { + filename = VCardService.CACHE_FILE_PREFIX + cache_index + ".vcf"; + final File file = context.getFileStreamPath(filename); + if (!file.exists()) { + break; + } else { + if (cache_index == Integer.MAX_VALUE) { + throw new RuntimeException("Exceeded cache limit"); + } + cache_index++; + } + } + final Uri localDataUri = copyTo(sourceUri, filename); + if (mCanceled) { + Log.i(LOG_TAG, "vCard cache operation is canceled."); + break; + } + if (localDataUri == null) { + Log.w(LOG_TAG, "destUri is null"); + break; + } + + String displayName = null; + Cursor cursor = null; + // Try to get a display name from the given Uri. If it fails, we just + // pick up the last part of the Uri. + try { + cursor = resolver.query(sourceUri, + new String[] { OpenableColumns.DISPLAY_NAME }, + null, null, null); + if (cursor != null && cursor.getCount() > 0 && cursor.moveToFirst()) { + if (cursor.getCount() > 1) { + Log.w(LOG_TAG, "Unexpected multiple rows: " + + cursor.getCount()); + } + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index >= 0) { + displayName = cursor.getString(index); + } + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + if (TextUtils.isEmpty(displayName)){ + displayName = sourceUri.getLastPathSegment(); + } + + final ImportRequest request; + try { + request = constructImportRequest(null, localDataUri, displayName); + } catch (VCardException e) { + Log.e(LOG_TAG, "Maybe the file is in wrong format", e); + showFailureNotification(R.string.fail_reason_not_supported); + return; + } catch (IOException e) { + Log.e(LOG_TAG, "Unexpected IOException", e); + showFailureNotification(R.string.fail_reason_io_error); + return; + } + if (mCanceled) { + Log.i(LOG_TAG, "vCard cache operation is canceled."); + return; + } + requests.add(request); + } + } + if (!requests.isEmpty()) { + mConnection.sendImportRequest(requests); + } else { + Log.w(LOG_TAG, "Empty import requests. Ignore it."); + } + } catch (OutOfMemoryError e) { + Log.e(LOG_TAG, "OutOfMemoryError occured during caching vCard"); + System.gc(); + runOnUiThread(new DialogDisplayer( + getString(R.string.fail_reason_low_memory_during_import))); + } catch (IOException e) { + Log.e(LOG_TAG, "IOException during caching vCard", e); + runOnUiThread(new DialogDisplayer( + getString(R.string.fail_reason_io_error))); + } finally { + Log.i(LOG_TAG, "Finished caching vCard."); + mWakeLock.release(); + unbindService(mConnection); + mProgressDialogForCachingVCard.dismiss(); + mProgressDialogForCachingVCard = null; + finish(); + } + } + + /** + * Copy the content of sourceUri to the destination. + */ + private Uri copyTo(final Uri sourceUri, String filename) throws IOException { + Log.i(LOG_TAG, String.format("Copy a Uri to app local storage (%s -> %s)", + sourceUri, filename)); + final Context context = ImportVCardActivity.this; + final ContentResolver resolver = context.getContentResolver(); + ReadableByteChannel inputChannel = null; + WritableByteChannel outputChannel = null; + Uri destUri = null; + try { + inputChannel = Channels.newChannel(resolver.openInputStream(sourceUri)); + destUri = Uri.parse(context.getFileStreamPath(filename).toURI().toString()); + outputChannel = context.openFileOutput(filename, Context.MODE_PRIVATE).getChannel(); + final ByteBuffer buffer = ByteBuffer.allocateDirect(8192); + while (inputChannel.read(buffer) != -1) { + if (mCanceled) { + Log.d(LOG_TAG, "Canceled during caching " + sourceUri); + return null; + } + buffer.flip(); + outputChannel.write(buffer); + buffer.compact(); + } + buffer.flip(); + while (buffer.hasRemaining()) { + outputChannel.write(buffer); + } + } finally { + if (inputChannel != null) { + try { + inputChannel.close(); + } catch (IOException e) { + Log.w(LOG_TAG, "Failed to close inputChannel."); + } + } + if (outputChannel != null) { + try { + outputChannel.close(); + } catch(IOException e) { + Log.w(LOG_TAG, "Failed to close outputChannel"); + } + } + } + return destUri; + } + + /** + * Reads localDataUri (possibly multiple times) and constructs {@link ImportRequest} from + * its content. + * + * @arg localDataUri Uri actually used for the import. Should be stored in + * app local storage, as we cannot guarantee other types of Uris can be read + * multiple times. This variable populates {@link ImportRequest#uri}. + * @arg displayName Used for displaying information to the user. This variable populates + * {@link ImportRequest#displayName}. + */ + private ImportRequest constructImportRequest(final byte[] data, + final Uri localDataUri, final String displayName) + throws IOException, VCardException { + final ContentResolver resolver = ImportVCardActivity.this.getContentResolver(); + VCardEntryCounter counter = null; + VCardSourceDetector detector = null; + int vcardVersion = VCARD_VERSION_V21; + try { + boolean shouldUseV30 = false; + InputStream is; + if (data != null) { + is = new ByteArrayInputStream(data); + } else { + is = resolver.openInputStream(localDataUri); + } + mVCardParser = new VCardParser_V21(); + try { + counter = new VCardEntryCounter(); + detector = new VCardSourceDetector(); + mVCardParser.addInterpreter(counter); + mVCardParser.addInterpreter(detector); + mVCardParser.parse(is); + } catch (VCardVersionException e1) { + try { + is.close(); + } catch (IOException e) { + } + + shouldUseV30 = true; + if (data != null) { + is = new ByteArrayInputStream(data); + } else { + is = resolver.openInputStream(localDataUri); + } + mVCardParser = new VCardParser_V30(); + try { + counter = new VCardEntryCounter(); + detector = new VCardSourceDetector(); + mVCardParser.addInterpreter(counter); + mVCardParser.addInterpreter(detector); + mVCardParser.parse(is); + } catch (VCardVersionException e2) { + throw new VCardException("vCard with unspported version."); + } + } finally { + if (is != null) { + try { + is.close(); + } catch (IOException e) { + } + } + } + + vcardVersion = shouldUseV30 ? VCARD_VERSION_V30 : VCARD_VERSION_V21; + } catch (VCardNestedException e) { + Log.w(LOG_TAG, "Nested Exception is found (it may be false-positive)."); + // Go through without throwing the Exception, as we may be able to detect the + // version before it + } + return new ImportRequest(mAccount, + data, localDataUri, displayName, + detector.getEstimatedType(), + detector.getEstimatedCharset(), + vcardVersion, counter.getCount()); + } + + public Uri[] getSourceUris() { + return mSourceUris; + } + + public void cancel() { + mCanceled = true; + if (mVCardParser != null) { + mVCardParser.cancel(); + } + } + + @Override + public void onCancel(DialogInterface dialog) { + Log.i(LOG_TAG, "Cancel request has come. Abort caching vCard."); + cancel(); + } + } + + private class ImportTypeSelectedListener implements + DialogInterface.OnClickListener { + public static final int IMPORT_ONE = 0; + public static final int IMPORT_MULTIPLE = 1; + public static final int IMPORT_ALL = 2; + public static final int IMPORT_TYPE_SIZE = 3; + + private int mCurrentIndex; + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + switch (mCurrentIndex) { + case IMPORT_ALL: + importVCardFromSDCard(mAllVCardFileList); + break; + case IMPORT_MULTIPLE: + showDialog(R.id.dialog_select_multiple_vcard); + break; + default: + showDialog(R.id.dialog_select_one_vcard); + break; + } + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + finish(); + } else { + mCurrentIndex = which; + } + } + } + + private class VCardSelectedListener implements + DialogInterface.OnClickListener, DialogInterface.OnMultiChoiceClickListener { + private int mCurrentIndex; + private Set<Integer> mSelectedIndexSet; + + public VCardSelectedListener(boolean multipleSelect) { + mCurrentIndex = 0; + if (multipleSelect) { + mSelectedIndexSet = new HashSet<Integer>(); + } + } + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + if (mSelectedIndexSet != null) { + List<VCardFile> selectedVCardFileList = new ArrayList<VCardFile>(); + final int size = mAllVCardFileList.size(); + // We'd like to sort the files by its index, so we do not use Set iterator. + for (int i = 0; i < size; i++) { + if (mSelectedIndexSet.contains(i)) { + selectedVCardFileList.add(mAllVCardFileList.get(i)); + } + } + importVCardFromSDCard(selectedVCardFileList); + } else { + importVCardFromSDCard(mAllVCardFileList.get(mCurrentIndex)); + } + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + finish(); + } else { + // Some file is selected. + mCurrentIndex = which; + if (mSelectedIndexSet != null) { + if (mSelectedIndexSet.contains(which)) { + mSelectedIndexSet.remove(which); + } else { + mSelectedIndexSet.add(which); + } + } + } + } + + public void onClick(DialogInterface dialog, int which, boolean isChecked) { + if (mSelectedIndexSet == null || (mSelectedIndexSet.contains(which) == isChecked)) { + Log.e(LOG_TAG, String.format("Inconsist state in index %d (%s)", which, + mAllVCardFileList.get(which).getCanonicalPath())); + } else { + onClick(dialog, which); + } + } + } + + /** + * Thread scanning VCard from SDCard. After scanning, the dialog which lets a user select + * a vCard file is shown. After the choice, VCardReadThread starts running. + */ + private class VCardScanThread extends Thread implements OnCancelListener, OnClickListener { + private boolean mCanceled; + private boolean mGotIOException; + private File mRootDirectory; + + // To avoid recursive link. + private Set<String> mCheckedPaths; + private PowerManager.WakeLock mWakeLock; + + private class CanceledException extends Exception { + } + + public VCardScanThread(File sdcardDirectory) { + mCanceled = false; + mGotIOException = false; + mRootDirectory = sdcardDirectory; + mCheckedPaths = new HashSet<String>(); + PowerManager powerManager = (PowerManager)ImportVCardActivity.this.getSystemService( + Context.POWER_SERVICE); + mWakeLock = powerManager.newWakeLock( + PowerManager.SCREEN_DIM_WAKE_LOCK | + PowerManager.ON_AFTER_RELEASE, LOG_TAG); + } + + @Override + public void run() { + mAllVCardFileList = new Vector<VCardFile>(); + try { + mWakeLock.acquire(); + getVCardFileRecursively(mRootDirectory); + } catch (CanceledException e) { + mCanceled = true; + } catch (IOException e) { + mGotIOException = true; + } finally { + mWakeLock.release(); + } + + if (mCanceled) { + mAllVCardFileList = null; + } + + mProgressDialogForScanVCard.dismiss(); + mProgressDialogForScanVCard = null; + + if (mGotIOException) { + runOnUiThread(new DialogDisplayer(R.id.dialog_io_exception)); + } else if (mCanceled) { + finish(); + } else { + int size = mAllVCardFileList.size(); + final Context context = ImportVCardActivity.this; + if (size == 0) { + runOnUiThread(new DialogDisplayer(R.id.dialog_vcard_not_found)); + } else { + startVCardSelectAndImport(); + } + } + } + + private void getVCardFileRecursively(File directory) + throws CanceledException, IOException { + if (mCanceled) { + throw new CanceledException(); + } + + // e.g. secured directory may return null toward listFiles(). + final File[] files = directory.listFiles(); + if (files == null) { + final String currentDirectoryPath = directory.getCanonicalPath(); + final String secureDirectoryPath = + mRootDirectory.getCanonicalPath().concat(SECURE_DIRECTORY_NAME); + if (!TextUtils.equals(currentDirectoryPath, secureDirectoryPath)) { + Log.w(LOG_TAG, "listFiles() returned null (directory: " + directory + ")"); + } + return; + } + for (File file : directory.listFiles()) { + if (mCanceled) { + throw new CanceledException(); + } + String canonicalPath = file.getCanonicalPath(); + if (mCheckedPaths.contains(canonicalPath)) { + continue; + } + + mCheckedPaths.add(canonicalPath); + + if (file.isDirectory()) { + getVCardFileRecursively(file); + } else if (canonicalPath.toLowerCase().endsWith(".vcf") && + file.canRead()){ + String fileName = file.getName(); + VCardFile vcardFile = new VCardFile( + fileName, canonicalPath, file.lastModified()); + mAllVCardFileList.add(vcardFile); + } + } + } + + public void onCancel(DialogInterface dialog) { + mCanceled = true; + } + + public void onClick(DialogInterface dialog, int which) { + if (which == DialogInterface.BUTTON_NEGATIVE) { + mCanceled = true; + } + } + } + + private void startVCardSelectAndImport() { + int size = mAllVCardFileList.size(); + if (getResources().getBoolean(R.bool.config_import_all_vcard_from_sdcard_automatically) || + size == 1) { + importVCardFromSDCard(mAllVCardFileList); + } else if (getResources().getBoolean(R.bool.config_allow_users_select_all_vcard_import)) { + runOnUiThread(new DialogDisplayer(R.id.dialog_select_import_type)); + } else { + runOnUiThread(new DialogDisplayer(R.id.dialog_select_one_vcard)); + } + } + + private void importVCardFromSDCard(final List<VCardFile> selectedVCardFileList) { + final int size = selectedVCardFileList.size(); + String[] uriStrings = new String[size]; + int i = 0; + for (VCardFile vcardFile : selectedVCardFileList) { + uriStrings[i] = "file://" + vcardFile.getCanonicalPath(); + i++; + } + importVCard(uriStrings); + } + + private void importVCardFromSDCard(final VCardFile vcardFile) { + importVCard(new Uri[] {Uri.parse("file://" + vcardFile.getCanonicalPath())}); + } + + private void importVCard(final Uri uri) { + importVCard(new Uri[] {uri}); + } + + private void importVCard(final String[] uriStrings) { + final int length = uriStrings.length; + final Uri[] uris = new Uri[length]; + for (int i = 0; i < length; i++) { + uris[i] = Uri.parse(uriStrings[i]); + } + importVCard(uris); + } + + private void importVCard(final Uri[] uris) { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!isFinishing()) { + mVCardCacheThread = new VCardCacheThread(uris); + mListener = new NotificationImportExportListener(ImportVCardActivity.this); + showDialog(R.id.dialog_cache_vcard); + } + } + }); + } + + private Dialog getSelectImportTypeDialog() { + final DialogInterface.OnClickListener listener = new ImportTypeSelectedListener(); + final AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(R.string.select_vcard_title) + .setPositiveButton(android.R.string.ok, listener) + .setOnCancelListener(mCancelListener) + .setNegativeButton(android.R.string.cancel, mCancelListener); + + final String[] items = new String[ImportTypeSelectedListener.IMPORT_TYPE_SIZE]; + items[ImportTypeSelectedListener.IMPORT_ONE] = + getString(R.string.import_one_vcard_string); + items[ImportTypeSelectedListener.IMPORT_MULTIPLE] = + getString(R.string.import_multiple_vcard_string); + items[ImportTypeSelectedListener.IMPORT_ALL] = + getString(R.string.import_all_vcard_string); + builder.setSingleChoiceItems(items, ImportTypeSelectedListener.IMPORT_ONE, listener); + return builder.create(); + } + + private Dialog getVCardFileSelectDialog(boolean multipleSelect) { + final int size = mAllVCardFileList.size(); + final VCardSelectedListener listener = new VCardSelectedListener(multipleSelect); + final AlertDialog.Builder builder = + new AlertDialog.Builder(this) + .setTitle(R.string.select_vcard_title) + .setPositiveButton(android.R.string.ok, listener) + .setOnCancelListener(mCancelListener) + .setNegativeButton(android.R.string.cancel, mCancelListener); + + CharSequence[] items = new CharSequence[size]; + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + for (int i = 0; i < size; i++) { + VCardFile vcardFile = mAllVCardFileList.get(i); + SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); + stringBuilder.append(vcardFile.getName()); + stringBuilder.append('\n'); + int indexToBeSpanned = stringBuilder.length(); + // Smaller date text looks better, since each file name becomes easier to read. + // The value set to RelativeSizeSpan is arbitrary. You can change it to any other + // value (but the value bigger than 1.0f would not make nice appearance :) + stringBuilder.append( + "(" + dateFormat.format(new Date(vcardFile.getLastModified())) + ")"); + stringBuilder.setSpan( + new RelativeSizeSpan(0.7f), indexToBeSpanned, stringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + items[i] = stringBuilder; + } + if (multipleSelect) { + builder.setMultiChoiceItems(items, (boolean[])null, listener); + } else { + builder.setSingleChoiceItems(items, 0, listener); + } + return builder.create(); + } + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + + String accountName = null; + String accountType = null; + String dataSet = null; + final Intent intent = getIntent(); + if (intent != null) { + accountName = intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME); + accountType = intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE); + dataSet = intent.getStringExtra(SelectAccountActivity.DATA_SET); + } else { + Log.e(LOG_TAG, "intent does not exist"); + } + + if (!TextUtils.isEmpty(accountName) && !TextUtils.isEmpty(accountType)) { + mAccount = new AccountWithDataSet(accountName, accountType, dataSet); + } else { + final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this); + final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true); + if (accountList.size() == 0) { + mAccount = null; + } else if (accountList.size() == 1) { + mAccount = accountList.get(0); + } else { + startActivityForResult(new Intent(this, SelectAccountActivity.class), + SELECT_ACCOUNT); + return; + } + } + + startImport(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + if (requestCode == SELECT_ACCOUNT) { + if (resultCode == Activity.RESULT_OK) { + mAccount = new AccountWithDataSet( + intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME), + intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE), + intent.getStringExtra(SelectAccountActivity.DATA_SET)); + startImport(); + } else { + if (resultCode != Activity.RESULT_CANCELED) { + Log.w(LOG_TAG, "Result code was not OK nor CANCELED: " + resultCode); + } + finish(); + } + } + } + + private void startImport() { + Intent intent = getIntent(); + // Handle inbound files + Uri uri = intent.getData(); + if (uri != null) { + Log.i(LOG_TAG, "Starting vCard import using Uri " + uri); + importVCard(uri); + } else { + Log.i(LOG_TAG, "Start vCard without Uri. The user will select vCard manually."); + doScanExternalStorageAndImportVCard(); + } + } + + @Override + protected Dialog onCreateDialog(int resId, Bundle bundle) { + switch (resId) { + case R.string.import_from_sdcard: { + if (mAccountSelectionListener == null) { + throw new NullPointerException( + "mAccountSelectionListener must not be null."); + } + return AccountSelectionUtil.getSelectAccountDialog(this, resId, + mAccountSelectionListener, mCancelListener); + } + case R.id.dialog_searching_vcard: { + if (mProgressDialogForScanVCard == null) { + String message = getString(R.string.searching_vcard_message); + mProgressDialogForScanVCard = + ProgressDialog.show(this, "", message, true, false); + mProgressDialogForScanVCard.setOnCancelListener(mVCardScanThread); + mVCardScanThread.start(); + } + return mProgressDialogForScanVCard; + } + case R.id.dialog_sdcard_not_found: { + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(R.string.no_sdcard_message) + .setOnCancelListener(mCancelListener) + .setPositiveButton(android.R.string.ok, mCancelListener); + return builder.create(); + } + case R.id.dialog_vcard_not_found: { + final String message = getString(R.string.import_failure_no_vcard_file); + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setMessage(message) + .setOnCancelListener(mCancelListener) + .setPositiveButton(android.R.string.ok, mCancelListener); + return builder.create(); + } + case R.id.dialog_select_import_type: { + return getSelectImportTypeDialog(); + } + case R.id.dialog_select_multiple_vcard: { + return getVCardFileSelectDialog(true); + } + case R.id.dialog_select_one_vcard: { + return getVCardFileSelectDialog(false); + } + case R.id.dialog_cache_vcard: { + if (mProgressDialogForCachingVCard == null) { + final String title = getString(R.string.caching_vcard_title); + final String message = getString(R.string.caching_vcard_message); + mProgressDialogForCachingVCard = new ProgressDialog(this); + mProgressDialogForCachingVCard.setTitle(title); + mProgressDialogForCachingVCard.setMessage(message); + mProgressDialogForCachingVCard.setProgressStyle(ProgressDialog.STYLE_SPINNER); + mProgressDialogForCachingVCard.setOnCancelListener(mVCardCacheThread); + startVCardService(); + } + return mProgressDialogForCachingVCard; + } + case R.id.dialog_io_exception: { + String message = (getString(R.string.scanning_sdcard_failed_message, + getString(R.string.fail_reason_io_error))); + AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(message) + .setOnCancelListener(mCancelListener) + .setPositiveButton(android.R.string.ok, mCancelListener); + return builder.create(); + } + case R.id.dialog_error_with_message: { + String message = mErrorMessage; + if (TextUtils.isEmpty(message)) { + Log.e(LOG_TAG, "Error message is null while it must not."); + message = getString(R.string.fail_reason_unknown); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(this) + .setTitle(getString(R.string.reading_vcard_failed_title)) + .setIconAttribute(android.R.attr.alertDialogIcon) + .setMessage(message) + .setOnCancelListener(mCancelListener) + .setPositiveButton(android.R.string.ok, mCancelListener); + return builder.create(); + } + } + + return super.onCreateDialog(resId, bundle); + } + + /* package */ void startVCardService() { + mConnection = new ImportRequestConnection(); + + Log.i(LOG_TAG, "Bind to VCardService."); + // We don't want the service finishes itself just after this connection. + Intent intent = new Intent(this, VCardService.class); + startService(intent); + bindService(new Intent(this, VCardService.class), + mConnection, Context.BIND_AUTO_CREATE); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (mProgressDialogForCachingVCard != null) { + Log.i(LOG_TAG, "Cache thread is still running. Show progress dialog again."); + showDialog(R.id.dialog_cache_vcard); + } + } + + /** + * Scans vCard in external storage (typically SDCard) and tries to import it. + * - When there's no SDCard available, an error dialog is shown. + * - When multiple vCard files are available, asks a user to select one. + */ + private void doScanExternalStorageAndImportVCard() { + // TODO: should use getExternalStorageState(). + final File file = Environment.getExternalStorageDirectory(); + if (!file.exists() || !file.isDirectory() || !file.canRead()) { + showDialog(R.id.dialog_sdcard_not_found); + } else { + mVCardScanThread = new VCardScanThread(file); + showDialog(R.id.dialog_searching_vcard); + } + } + + /* package */ void showFailureNotification(int reasonId) { + final NotificationManager notificationManager = + (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); + final Notification notification = + NotificationImportExportListener.constructImportFailureNotification( + ImportVCardActivity.this, + getString(reasonId)); + notificationManager.notify(NotificationImportExportListener.FAILURE_NOTIFICATION_TAG, + FAILURE_NOTIFICATION_ID, notification); + mHandler.post(new Runnable() { + @Override + public void run() { + Toast.makeText(ImportVCardActivity.this, + getString(R.string.vcard_import_failed), Toast.LENGTH_LONG).show(); + } + }); + } +} diff --git a/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java b/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java new file mode 100644 index 00000000..96f224a1 --- /dev/null +++ b/src/com/android/contacts/common/vcard/NfcImportVCardActivity.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; +import android.nfc.NdefMessage; +import android.nfc.NdefRecord; +import android.nfc.NfcAdapter; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.ContactsContract.RawContacts; +import android.util.Log; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCounter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.VCardSourceDetector; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class NfcImportVCardActivity extends Activity implements ServiceConnection, + VCardImportExportListener { + private static final String TAG = "NfcImportVCardActivity"; + + private static final int SELECT_ACCOUNT = 1; + + private NdefRecord mRecord; + private AccountWithDataSet mAccount; + + /* package */ class ImportTask extends AsyncTask<VCardService, Void, ImportRequest> { + @Override + public ImportRequest doInBackground(VCardService... services) { + ImportRequest request = createImportRequest(); + if (request == null) { + return null; + } + + ArrayList<ImportRequest> requests = new ArrayList<ImportRequest>(); + requests.add(request); + services[0].handleImportRequest(requests, NfcImportVCardActivity.this); + return request; + } + + @Override + public void onCancelled() { + unbindService(NfcImportVCardActivity.this); + } + + @Override + public void onPostExecute(ImportRequest request) { + unbindService(NfcImportVCardActivity.this); + } + } + + /* package */ ImportRequest createImportRequest() { + VCardParser parser; + VCardEntryCounter counter = null; + VCardSourceDetector detector = null; + int vcardVersion = ImportVCardActivity.VCARD_VERSION_V21; + try { + ByteArrayInputStream is = new ByteArrayInputStream(mRecord.getPayload()); + is.mark(0); + parser = new VCardParser_V21(); + try { + counter = new VCardEntryCounter(); + detector = new VCardSourceDetector(); + parser.addInterpreter(counter); + parser.addInterpreter(detector); + parser.parse(is); + } catch (VCardVersionException e1) { + is.reset(); + vcardVersion = ImportVCardActivity.VCARD_VERSION_V30; + parser = new VCardParser_V30(); + try { + counter = new VCardEntryCounter(); + detector = new VCardSourceDetector(); + parser.addInterpreter(counter); + parser.addInterpreter(detector); + parser.parse(is); + } catch (VCardVersionException e2) { + return null; + } + } finally { + try { + if (is != null) is.close(); + } catch (IOException e) { + } + } + } catch (IOException e) { + Log.e(TAG, "Failed reading vcard data", e); + return null; + } catch (VCardNestedException e) { + Log.w(TAG, "Nested Exception is found (it may be false-positive)."); + // Go through without throwing the Exception, as we may be able to detect the + // version before it + } catch (VCardException e) { + Log.e(TAG, "Error parsing vcard", e); + return null; + } + + return new ImportRequest(mAccount, mRecord.getPayload(), null, + getString(R.string.nfc_vcard_file_name), detector.getEstimatedType(), + detector.getEstimatedCharset(), vcardVersion, counter.getCount()); + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + VCardService service = ((VCardService.MyBinder) binder).getService(); + new ImportTask().execute(service); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // Do nothing + } + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + + Intent intent = getIntent(); + if (!NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction())) { + Log.w(TAG, "Unknowon intent " + intent); + finish(); + return; + } + + String type = intent.getType(); + if (type == null || + (!"text/x-vcard".equals(type) && !"text/vcard".equals(type))) { + Log.w(TAG, "Not a vcard"); + //setStatus(getString(R.string.fail_reason_not_supported)); + finish(); + return; + } + NdefMessage msg = (NdefMessage) intent.getParcelableArrayExtra( + NfcAdapter.EXTRA_NDEF_MESSAGES)[0]; + mRecord = msg.getRecords()[0]; + + final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this); + final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true); + if (accountList.size() == 0) { + mAccount = null; + } else if (accountList.size() == 1) { + mAccount = accountList.get(0); + } else { + startActivityForResult(new Intent(this, SelectAccountActivity.class), SELECT_ACCOUNT); + return; + } + + startImport(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + if (requestCode == SELECT_ACCOUNT) { + if (resultCode == RESULT_OK) { + mAccount = new AccountWithDataSet( + intent.getStringExtra(SelectAccountActivity.ACCOUNT_NAME), + intent.getStringExtra(SelectAccountActivity.ACCOUNT_TYPE), + intent.getStringExtra(SelectAccountActivity.DATA_SET)); + startImport(); + } else { + finish(); + } + } + } + + private void startImport() { + // We don't want the service finishes itself just after this connection. + Intent intent = new Intent(this, VCardService.class); + startService(intent); + bindService(intent, this, Context.BIND_AUTO_CREATE); + } + + @Override + public void onImportProcessed(ImportRequest request, int jobId, int sequence) { + // do nothing + } + + @Override + public void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount, + int totalCount) { + // do nothing + } + + @Override + public void onImportFinished(ImportRequest request, int jobId, Uri uri) { + if (isFinishing()) { + Log.i(TAG, "Late import -- ignoring"); + return; + } + + if (uri != null) { + Uri contactUri = RawContacts.getContactLookupUri(getContentResolver(), uri); + Intent intent = new Intent(Intent.ACTION_VIEW, contactUri); + startActivity(intent); + finish(); + } + } + + @Override + public void onImportFailed(ImportRequest request) { + if (isFinishing()) { + Log.i(TAG, "Late import failure -- ignoring"); + return; + } + // TODO: report failure + } + + @Override + public void onImportCanceled(ImportRequest request, int jobId) { + // do nothing + } + + @Override + public void onExportProcessed(ExportRequest request, int jobId) { + // do nothing + } + + @Override + public void onExportFailed(ExportRequest request) { + // do nothing + } + + @Override + public void onCancelRequest(CancelRequest request, int type) { + // do nothing + } + + @Override + public void onComplete() { + // do nothing + } +} diff --git a/src/com/android/contacts/common/vcard/NotificationImportExportListener.java b/src/com/android/contacts/common/vcard/NotificationImportExportListener.java new file mode 100644 index 00000000..f873e316 --- /dev/null +++ b/src/com/android/contacts/common/vcard/NotificationImportExportListener.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.provider.ContactsContract.RawContacts; +import android.widget.Toast; + +import com.android.contacts.common.R; +import com.android.vcard.VCardEntry; + +public class NotificationImportExportListener implements VCardImportExportListener, + Handler.Callback { + /** The tag used by vCard-related notifications. */ + /* package */ static final String DEFAULT_NOTIFICATION_TAG = "VCardServiceProgress"; + /** + * The tag used by vCard-related failure notifications. + * <p> + * Use a different tag from {@link #DEFAULT_NOTIFICATION_TAG} so that failures do not get + * replaced by other notifications and vice-versa. + */ + /* package */ static final String FAILURE_NOTIFICATION_TAG = "VCardServiceFailure"; + + private final NotificationManager mNotificationManager; + private final Activity mContext; + private final Handler mHandler; + + public NotificationImportExportListener(Activity activity) { + mContext = activity; + mNotificationManager = (NotificationManager) activity.getSystemService( + Context.NOTIFICATION_SERVICE); + mHandler = new Handler(this); + } + + @Override + public boolean handleMessage(Message msg) { + String text = (String) msg.obj; + Toast.makeText(mContext, text, Toast.LENGTH_LONG).show(); + return true; + } + + @Override + public void onImportProcessed(ImportRequest request, int jobId, int sequence) { + // Show a notification about the status + final String displayName; + final String message; + if (request.displayName != null) { + displayName = request.displayName; + message = mContext.getString(R.string.vcard_import_will_start_message, displayName); + } else { + displayName = mContext.getString(R.string.vcard_unknown_filename); + message = mContext.getString( + R.string.vcard_import_will_start_message_with_default_name); + } + + // We just want to show notification for the first vCard. + if (sequence == 0) { + // TODO: Ideally we should detect the current status of import/export and + // show "started" when we can import right now and show "will start" when + // we cannot. + mHandler.obtainMessage(0, message).sendToTarget(); + } + + final Notification notification = constructProgressNotification(mContext, + VCardService.TYPE_IMPORT, message, message, jobId, displayName, -1, 0); + mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification); + } + + @Override + public void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount, + int totalCount) { + if (entry.isIgnorable()) { + return; + } + + final String totalCountString = String.valueOf(totalCount); + final String tickerText = + mContext.getString(R.string.progress_notifier_message, + String.valueOf(currentCount), + totalCountString, + entry.getDisplayName()); + final String description = mContext.getString(R.string.importing_vcard_description, + entry.getDisplayName()); + + final Notification notification = constructProgressNotification( + mContext.getApplicationContext(), VCardService.TYPE_IMPORT, description, tickerText, + jobId, request.displayName, totalCount, currentCount); + mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification); + } + + @Override + public void onImportFinished(ImportRequest request, int jobId, Uri createdUri) { + final String description = mContext.getString(R.string.importing_vcard_finished_title, + request.displayName); + final Intent intent; + if (createdUri != null) { + final long rawContactId = ContentUris.parseId(createdUri); + final Uri contactUri = RawContacts.getContactLookupUri( + mContext.getContentResolver(), ContentUris.withAppendedId( + RawContacts.CONTENT_URI, rawContactId)); + intent = new Intent(Intent.ACTION_VIEW, contactUri); + } else { + intent = null; + } + final Notification notification = + NotificationImportExportListener.constructFinishNotification(mContext, + description, null, intent); + mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG, + jobId, notification); + } + + @Override + public void onImportFailed(ImportRequest request) { + // TODO: a little unkind to show Toast in this case, which is shown just a moment. + // Ideally we should show some persistent something users can notice more easily. + mHandler.obtainMessage(0, + mContext.getString(R.string.vcard_import_request_rejected_message)).sendToTarget(); + } + + @Override + public void onImportCanceled(ImportRequest request, int jobId) { + final String description = mContext.getString(R.string.importing_vcard_canceled_title, + request.displayName); + final Notification notification = + NotificationImportExportListener.constructCancelNotification(mContext, description); + mNotificationManager.notify(NotificationImportExportListener.DEFAULT_NOTIFICATION_TAG, + jobId, notification); + } + + @Override + public void onExportProcessed(ExportRequest request, int jobId) { + final String displayName = request.destUri.getLastPathSegment(); + final String message = mContext.getString(R.string.vcard_export_will_start_message, + displayName); + + mHandler.obtainMessage(0, message).sendToTarget(); + final Notification notification = + NotificationImportExportListener.constructProgressNotification(mContext, + VCardService.TYPE_EXPORT, message, message, jobId, displayName, -1, 0); + mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, jobId, notification); + } + + @Override + public void onExportFailed(ExportRequest request) { + mHandler.obtainMessage(0, + mContext.getString(R.string.vcard_export_request_rejected_message)).sendToTarget(); + } + + @Override + public void onCancelRequest(CancelRequest request, int type) { + final String description = type == VCardService.TYPE_IMPORT ? + mContext.getString(R.string.importing_vcard_canceled_title, request.displayName) : + mContext.getString(R.string.exporting_vcard_canceled_title, request.displayName); + final Notification notification = constructCancelNotification(mContext, description); + mNotificationManager.notify(DEFAULT_NOTIFICATION_TAG, request.jobId, notification); + } + + /** + * Constructs a {@link Notification} showing the current status of import/export. + * Users can cancel the process with the Notification. + * + * @param context + * @param type import/export + * @param description Content of the Notification. + * @param tickerText + * @param jobId + * @param displayName Name to be shown to the Notification (e.g. "finished importing XXXX"). + * Typycally a file name. + * @param totalCount The number of vCard entries to be imported. Used to show progress bar. + * -1 lets the system show the progress bar with "indeterminate" state. + * @param currentCount The index of current vCard. Used to show progress bar. + */ + /* package */ static Notification constructProgressNotification( + Context context, int type, String description, String tickerText, + int jobId, String displayName, int totalCount, int currentCount) { + // Note: We cannot use extra values here (like setIntExtra()), as PendingIntent doesn't + // preserve them across multiple Notifications. PendingIntent preserves the first extras + // (when flag is not set), or update them when PendingIntent#getActivity() is called + // (See PendingIntent#FLAG_UPDATE_CURRENT). In either case, we cannot preserve extras as we + // expect (for each vCard import/export request). + // + // We use query parameter in Uri instead. + // Scheme and Authority is arbitorary, assuming CancelActivity never refers them. + final Intent intent = new Intent(context, CancelActivity.class); + final Uri uri = (new Uri.Builder()) + .scheme("invalidscheme") + .authority("invalidauthority") + .appendQueryParameter(CancelActivity.JOB_ID, String.valueOf(jobId)) + .appendQueryParameter(CancelActivity.DISPLAY_NAME, displayName) + .appendQueryParameter(CancelActivity.TYPE, String.valueOf(type)).build(); + intent.setData(uri); + + final Notification.Builder builder = new Notification.Builder(context); + builder.setOngoing(true) + .setProgress(totalCount, currentCount, totalCount == - 1) + .setTicker(tickerText) + .setContentTitle(description) + .setSmallIcon(type == VCardService.TYPE_IMPORT + ? android.R.drawable.stat_sys_download + : android.R.drawable.stat_sys_upload) + .setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)); + if (totalCount > 0) { + builder.setContentText(context.getString(R.string.percentage, + String.valueOf(currentCount * 100 / totalCount))); + } + return builder.getNotification(); + } + + /** + * Constructs a Notification telling users the process is canceled. + * + * @param context + * @param description Content of the Notification + */ + /* package */ static Notification constructCancelNotification( + Context context, String description) { + return new Notification.Builder(context) + .setAutoCancel(true) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle(description) + .setContentText(description) + .setContentIntent(PendingIntent.getActivity(context, 0, new Intent(), 0)) + .getNotification(); + } + + /** + * Constructs a Notification telling users the process is finished. + * + * @param context + * @param description Content of the Notification + * @param intent Intent to be launched when the Notification is clicked. Can be null. + */ + /* package */ static Notification constructFinishNotification( + Context context, String title, String description, Intent intent) { + return new Notification.Builder(context) + .setAutoCancel(true) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(title) + .setContentText(description) + .setContentIntent(PendingIntent.getActivity(context, 0, + (intent != null ? intent : new Intent()), 0)) + .getNotification(); + } + + /** + * Constructs a Notification telling the vCard import has failed. + * + * @param context + * @param reason The reason why the import has failed. Shown in description field. + */ + /* package */ static Notification constructImportFailureNotification( + Context context, String reason) { + return new Notification.Builder(context) + .setAutoCancel(true) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentTitle(context.getString(R.string.vcard_import_failed)) + .setContentText(reason) + .setContentIntent(PendingIntent.getActivity(context, 0, new Intent(), 0)) + .getNotification(); + } + + @Override + public void onComplete() { + mContext.finish(); + } +} diff --git a/src/com/android/contacts/common/vcard/ProcessorBase.java b/src/com/android/contacts/common/vcard/ProcessorBase.java new file mode 100644 index 00000000..abc859dd --- /dev/null +++ b/src/com/android/contacts/common/vcard/ProcessorBase.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.TimeUnit; + +/** + * A base processor class. One instance processes vCard one import/export request (imports a given + * vCard or exports a vCard). Expected to be used with {@link ExecutorService}. + * + * This instance starts itself with {@link #run()} method, and can be cancelled with + * {@link #cancel(boolean)}. Users can check the processor's status using {@link #isCancelled()} + * and {@link #isDone()} asynchronously. + * + * {@link #get()} and {@link #get(long, TimeUnit)}, which are form {@link Future}, aren't + * supported and {@link UnsupportedOperationException} will be just thrown when they are called. + */ +public abstract class ProcessorBase implements RunnableFuture<Object> { + + /** + * @return the type of the processor. Must be {@link VCardService#TYPE_IMPORT} or + * {@link VCardService#TYPE_EXPORT}. + */ + public abstract int getType(); + + @Override + public abstract void run(); + + /** + * Cancels this operation. + * + * @param mayInterruptIfRunning ignored. When this method is called, the instance + * stops processing and finish itself even if the thread is running. + * + * @see Future#cancel(boolean) + */ + @Override + public abstract boolean cancel(boolean mayInterruptIfRunning); + @Override + public abstract boolean isCancelled(); + @Override + public abstract boolean isDone(); + + /** + * Just throws {@link UnsupportedOperationException}. + */ + @Override + public final Object get() { + throw new UnsupportedOperationException(); + } + + /** + * Just throws {@link UnsupportedOperationException}. + */ + @Override + public final Object get(long timeout, TimeUnit unit) { + throw new UnsupportedOperationException(); + } +} diff --git a/src/com/android/contacts/common/vcard/SelectAccountActivity.java b/src/com/android/contacts/common/vcard/SelectAccountActivity.java new file mode 100644 index 00000000..d05810db --- /dev/null +++ b/src/com/android/contacts/common/vcard/SelectAccountActivity.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +import com.android.contacts.common.R; +import com.android.contacts.common.model.AccountTypeManager; +import com.android.contacts.common.model.account.AccountWithDataSet; +import com.android.contacts.common.util.AccountSelectionUtil; + +import java.util.List; + +public class SelectAccountActivity extends Activity { + private static final String LOG_TAG = "SelectAccountActivity"; + + public static final String ACCOUNT_NAME = "account_name"; + public static final String ACCOUNT_TYPE = "account_type"; + public static final String DATA_SET = "data_set"; + + private class CancelListener + implements DialogInterface.OnClickListener, DialogInterface.OnCancelListener { + public void onClick(DialogInterface dialog, int which) { + finish(); + } + public void onCancel(DialogInterface dialog) { + finish(); + } + } + + private AccountSelectionUtil.AccountSelectedListener mAccountSelectionListener; + + @Override + protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + + // There's three possibilities: + // - more than one accounts -> ask the user + // - just one account -> use the account without asking the user + // - no account -> use phone-local storage without asking the user + final int resId = R.string.import_from_sdcard; + final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this); + final List<AccountWithDataSet> accountList = accountTypes.getAccounts(true); + if (accountList.size() == 0) { + Log.w(LOG_TAG, "Account does not exist"); + finish(); + return; + } else if (accountList.size() == 1) { + final AccountWithDataSet account = accountList.get(0); + final Intent intent = new Intent(); + intent.putExtra(ACCOUNT_NAME, account.name); + intent.putExtra(ACCOUNT_TYPE, account.type); + intent.putExtra(DATA_SET, account.dataSet); + setResult(RESULT_OK, intent); + finish(); + return; + } + + Log.i(LOG_TAG, "The number of available accounts: " + accountList.size()); + + // Multiple accounts. Let users to select one. + mAccountSelectionListener = + new AccountSelectionUtil.AccountSelectedListener( + this, accountList, resId) { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + final AccountWithDataSet account = mAccountList.get(which); + final Intent intent = new Intent(); + intent.putExtra(ACCOUNT_NAME, account.name); + intent.putExtra(ACCOUNT_TYPE, account.type); + intent.putExtra(DATA_SET, account.dataSet); + setResult(RESULT_OK, intent); + finish(); + } + }; + showDialog(resId); + return; + } + + @Override + protected Dialog onCreateDialog(int resId, Bundle bundle) { + switch (resId) { + case R.string.import_from_sdcard: { + if (mAccountSelectionListener == null) { + throw new NullPointerException( + "mAccountSelectionListener must not be null."); + } + return AccountSelectionUtil.getSelectAccountDialog(this, resId, + mAccountSelectionListener, + new CancelListener()); + } + } + return super.onCreateDialog(resId, bundle); + } +} diff --git a/src/com/android/contacts/common/vcard/VCardCommonArguments.java b/src/com/android/contacts/common/vcard/VCardCommonArguments.java new file mode 100644 index 00000000..c423ca3a --- /dev/null +++ b/src/com/android/contacts/common/vcard/VCardCommonArguments.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.contacts.common.vcard; + +/** + * Argument constants used by many activities and services. + */ +public class VCardCommonArguments { + + // Argument used to pass calling activities to the target activity or service. + // The value should be a string class name (e.g. com.android.contacts.vcard.VCardCommonArgs) + public static final String ARG_CALLING_ACTIVITY = "CALLING_ACTIVITY"; +} diff --git a/src/com/android/contacts/common/vcard/VCardImportExportListener.java b/src/com/android/contacts/common/vcard/VCardImportExportListener.java new file mode 100644 index 00000000..e4e4893a --- /dev/null +++ b/src/com/android/contacts/common/vcard/VCardImportExportListener.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.contacts.common.vcard; + +import android.net.Uri; + +import com.android.vcard.VCardEntry; + +interface VCardImportExportListener { + void onImportProcessed(ImportRequest request, int jobId, int sequence); + void onImportParsed(ImportRequest request, int jobId, VCardEntry entry, int currentCount, + int totalCount); + void onImportFinished(ImportRequest request, int jobId, Uri uri); + void onImportFailed(ImportRequest request); + void onImportCanceled(ImportRequest request, int jobId); + + void onExportProcessed(ExportRequest request, int jobId); + void onExportFailed(ExportRequest request); + + void onCancelRequest(CancelRequest request, int type); + void onComplete(); +} diff --git a/src/com/android/contacts/common/vcard/VCardService.java b/src/com/android/contacts/common/vcard/VCardService.java new file mode 100644 index 00000000..07c2a3c1 --- /dev/null +++ b/src/com/android/contacts/common/vcard/VCardService.java @@ -0,0 +1,537 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.contacts.common.vcard; + +import android.app.Service; +import android.content.Intent; +import android.content.res.Resources; +import android.media.MediaScannerConnection; +import android.media.MediaScannerConnection.MediaScannerConnectionClient; +import android.net.Uri; +import android.os.Binder; +import android.os.Environment; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; + +import com.android.contacts.common.R; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +/** + * The class responsible for handling vCard import/export requests. + * + * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push + * it to {@link ExecutorService} with single thread executor. The executor handles each request + * one by one, and notifies users when needed. + */ +// TODO: Using IntentService looks simpler than using Service + ServiceConnection though this +// works fine enough. Investigate the feasibility. +public class VCardService extends Service { + private final static String LOG_TAG = "VCardService"; + + /* package */ final static boolean DEBUG = false; + + /* package */ static final int MSG_IMPORT_REQUEST = 1; + /* package */ static final int MSG_EXPORT_REQUEST = 2; + /* package */ static final int MSG_CANCEL_REQUEST = 3; + /* package */ static final int MSG_REQUEST_AVAILABLE_EXPORT_DESTINATION = 4; + /* package */ static final int MSG_SET_AVAILABLE_EXPORT_DESTINATION = 5; + + /** + * Specifies the type of operation. Used when constructing a notification, canceling + * some operation, etc. + */ + /* package */ static final int TYPE_IMPORT = 1; + /* package */ static final int TYPE_EXPORT = 2; + + /* package */ static final String CACHE_FILE_PREFIX = "import_tmp_"; + + + private class CustomMediaScannerConnectionClient implements MediaScannerConnectionClient { + final MediaScannerConnection mConnection; + final String mPath; + + public CustomMediaScannerConnectionClient(String path) { + mConnection = new MediaScannerConnection(VCardService.this, this); + mPath = path; + } + + public void start() { + mConnection.connect(); + } + + @Override + public void onMediaScannerConnected() { + if (DEBUG) { Log.d(LOG_TAG, "Connected to MediaScanner. Start scanning."); } + mConnection.scanFile(mPath, null); + } + + @Override + public void onScanCompleted(String path, Uri uri) { + if (DEBUG) { Log.d(LOG_TAG, "scan completed: " + path); } + mConnection.disconnect(); + removeConnectionClient(this); + } + } + + // Should be single thread, as we don't want to simultaneously handle import and export + // requests. + private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); + + private int mCurrentJobId; + + // Stores all unfinished import/export jobs which will be executed by mExecutorService. + // Key is jobId. + private final SparseArray<ProcessorBase> mRunningJobMap = new SparseArray<ProcessorBase>(); + // Stores ScannerConnectionClient objects until they finish scanning requested files. + // Uses List class for simplicity. It's not costly as we won't have multiple objects in + // almost all cases. + private final List<CustomMediaScannerConnectionClient> mRemainingScannerConnections = + new ArrayList<CustomMediaScannerConnectionClient>(); + + /* ** vCard exporter params ** */ + // If true, VCardExporter is able to emits files longer than 8.3 format. + private static final boolean ALLOW_LONG_FILE_NAME = false; + + private File mTargetDirectory; + private String mFileNamePrefix; + private String mFileNameSuffix; + private int mFileIndexMinimum; + private int mFileIndexMaximum; + private String mFileNameExtension; + private Set<String> mExtensionsToConsider; + private String mErrorReason; + private MyBinder mBinder; + + private String mCallingActivity; + + // File names currently reserved by some export job. + private final Set<String> mReservedDestination = new HashSet<String>(); + /* ** end of vCard exporter params ** */ + + public class MyBinder extends Binder { + public VCardService getService() { + return VCardService.this; + } + } + + @Override + public void onCreate() { + super.onCreate(); + mBinder = new MyBinder(); + if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created."); + initExporterParams(); + } + + private void initExporterParams() { + mTargetDirectory = Environment.getExternalStorageDirectory(); + mFileNamePrefix = getString(R.string.config_export_file_prefix); + mFileNameSuffix = getString(R.string.config_export_file_suffix); + mFileNameExtension = getString(R.string.config_export_file_extension); + + mExtensionsToConsider = new HashSet<String>(); + mExtensionsToConsider.add(mFileNameExtension); + + final String additionalExtensions = + getString(R.string.config_export_extensions_to_consider); + if (!TextUtils.isEmpty(additionalExtensions)) { + for (String extension : additionalExtensions.split(",")) { + String trimed = extension.trim(); + if (trimed.length() > 0) { + mExtensionsToConsider.add(trimed); + } + } + } + + final Resources resources = getResources(); + mFileIndexMinimum = resources.getInteger(R.integer.config_export_file_min_index); + mFileIndexMaximum = resources.getInteger(R.integer.config_export_file_max_index); + } + + @Override + public int onStartCommand(Intent intent, int flags, int id) { + mCallingActivity = intent.getExtras().getString( + VCardCommonArguments.ARG_CALLING_ACTIVITY); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + @Override + public void onDestroy() { + if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed."); + cancelAllRequestsAndShutdown(); + clearCache(); + super.onDestroy(); + } + + public synchronized void handleImportRequest(List<ImportRequest> requests, + VCardImportExportListener listener) { + if (DEBUG) { + final ArrayList<String> uris = new ArrayList<String>(); + final ArrayList<String> displayNames = new ArrayList<String>(); + for (ImportRequest request : requests) { + uris.add(request.uri.toString()); + displayNames.add(request.displayName); + } + Log.d(LOG_TAG, + String.format("received multiple import request (uri: %s, displayName: %s)", + uris.toString(), displayNames.toString())); + } + final int size = requests.size(); + for (int i = 0; i < size; i++) { + ImportRequest request = requests.get(i); + + if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) { + if (listener != null) { + listener.onImportProcessed(request, mCurrentJobId, i); + } + mCurrentJobId++; + } else { + if (listener != null) { + listener.onImportFailed(request); + } + // A rejection means executor doesn't run any more. Exit. + break; + } + } + } + + public synchronized void handleExportRequest(ExportRequest request, + VCardImportExportListener listener) { + if (tryExecute(new ExportProcessor(this, request, mCurrentJobId, mCallingActivity))) { + final String path = request.destUri.getEncodedPath(); + if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path); + if (!mReservedDestination.add(path)) { + Log.w(LOG_TAG, + String.format("The path %s is already reserved. Reject export request", + path)); + if (listener != null) { + listener.onExportFailed(request); + } + return; + } + + if (listener != null) { + listener.onExportProcessed(request, mCurrentJobId); + } + mCurrentJobId++; + } else { + if (listener != null) { + listener.onExportFailed(request); + } + } + } + + /** + * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor. + * @return true when successful. + */ + private synchronized boolean tryExecute(ProcessorBase processor) { + try { + if (DEBUG) { + Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown() + + ", terminated: " + mExecutorService.isTerminated()); + } + mExecutorService.execute(processor); + mRunningJobMap.put(mCurrentJobId, processor); + return true; + } catch (RejectedExecutionException e) { + Log.w(LOG_TAG, "Failed to excetute a job.", e); + return false; + } + } + + public synchronized void handleCancelRequest(CancelRequest request, + VCardImportExportListener listener) { + final int jobId = request.jobId; + if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId)); + + final ProcessorBase processor = mRunningJobMap.get(jobId); + mRunningJobMap.remove(jobId); + + if (processor != null) { + processor.cancel(true); + final int type = processor.getType(); + if (listener != null) { + listener.onCancelRequest(request, type); + } + if (type == TYPE_EXPORT) { + final String path = + ((ExportProcessor)processor).getRequest().destUri.getEncodedPath(); + Log.i(LOG_TAG, + String.format("Cancel reservation for the path %s if appropriate", path)); + if (!mReservedDestination.remove(path)) { + Log.w(LOG_TAG, "Not reserved."); + } + } + } else { + Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); + } + stopServiceIfAppropriate(); + } + + public synchronized void handleRequestAvailableExportDestination(final Messenger messenger) { + if (DEBUG) Log.d(LOG_TAG, "Received available export destination request."); + final String path = getAppropriateDestination(mTargetDirectory); + final Message message; + if (path != null) { + message = Message.obtain(null, + VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 0, 0, path); + } else { + message = Message.obtain(null, + VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, + R.id.dialog_fail_to_export_with_reason, 0, mErrorReason); + } + try { + messenger.send(message); + } catch (RemoteException e) { + Log.w(LOG_TAG, "Failed to send reply for available export destination request.", e); + } + } + + /** + * Checks job list and call {@link #stopSelf()} when there's no job and no scanner connection + * is remaining. + * A new job (import/export) cannot be submitted any more after this call. + */ + private synchronized void stopServiceIfAppropriate() { + if (mRunningJobMap.size() > 0) { + final int size = mRunningJobMap.size(); + + // Check if there are processors which aren't finished yet. If we still have ones to + // process, we cannot stop the service yet. Also clean up already finished processors + // here. + + // Job-ids to be removed. At first all elements in the array are invalid and will + // be filled with real job-ids from the array's top. When we find a not-yet-finished + // processor, then we start removing those finished jobs. In that case latter half of + // this array will be invalid. + final int[] toBeRemoved = new int[size]; + for (int i = 0; i < size; i++) { + final int jobId = mRunningJobMap.keyAt(i); + final ProcessorBase processor = mRunningJobMap.valueAt(i); + if (!processor.isDone()) { + Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId)); + + // Remove processors which are already "done", all of which should be before + // processors which aren't done yet. + for (int j = 0; j < i; j++) { + mRunningJobMap.remove(toBeRemoved[j]); + } + return; + } + + // Remember the finished processor. + toBeRemoved[i] = jobId; + } + + // We're sure we can remove all. Instead of removing one by one, just call clear(). + mRunningJobMap.clear(); + } + + if (!mRemainingScannerConnections.isEmpty()) { + Log.i(LOG_TAG, "MediaScanner update is in progress."); + return; + } + + Log.i(LOG_TAG, "No unfinished job. Stop this service."); + mExecutorService.shutdown(); + stopSelf(); + } + + /* package */ synchronized void updateMediaScanner(String path) { + if (DEBUG) { + Log.d(LOG_TAG, "MediaScanner is being updated: " + path); + } + + if (mExecutorService.isShutdown()) { + Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " + + "Ignoring the update request"); + return; + } + final CustomMediaScannerConnectionClient client = + new CustomMediaScannerConnectionClient(path); + mRemainingScannerConnections.add(client); + client.start(); + } + + private synchronized void removeConnectionClient( + CustomMediaScannerConnectionClient client) { + if (DEBUG) { + Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient."); + } + mRemainingScannerConnections.remove(client); + stopServiceIfAppropriate(); + } + + /* package */ synchronized void handleFinishImportNotification( + int jobId, boolean successful) { + if (DEBUG) { + Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). " + + "Result: %b", jobId, (successful ? "success" : "failure"))); + } + mRunningJobMap.remove(jobId); + stopServiceIfAppropriate(); + } + + /* package */ synchronized void handleFinishExportNotification( + int jobId, boolean successful) { + if (DEBUG) { + Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). " + + "Result: %b", jobId, (successful ? "success" : "failure"))); + } + final ProcessorBase job = mRunningJobMap.get(jobId); + mRunningJobMap.remove(jobId); + if (job == null) { + Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); + } else if (!(job instanceof ExportProcessor)) { + Log.w(LOG_TAG, + String.format("Removed job (id: %s) isn't ExportProcessor", jobId)); + } else { + final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath(); + if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path); + mReservedDestination.remove(path); + } + + stopServiceIfAppropriate(); + } + + /** + * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which + * means this Service becomes no longer ready for import/export requests. + * + * Mainly called from onDestroy(). + */ + private synchronized void cancelAllRequestsAndShutdown() { + for (int i = 0; i < mRunningJobMap.size(); i++) { + mRunningJobMap.valueAt(i).cancel(true); + } + mRunningJobMap.clear(); + mExecutorService.shutdown(); + } + + /** + * Removes import caches stored locally. + */ + private void clearCache() { + for (final String fileName : fileList()) { + if (fileName.startsWith(CACHE_FILE_PREFIX)) { + // We don't want to keep all the caches so we remove cache files old enough. + Log.i(LOG_TAG, "Remove a temporary file: " + fileName); + deleteFile(fileName); + } + } + } + + /** + * Returns an appropriate file name for vCard export. Returns null when impossible. + * + * @return destination path for a vCard file to be exported. null on error and mErrorReason + * is correctly set. + */ + private String getAppropriateDestination(final File destDirectory) { + /* + * Here, file names have 5 parts: directory, prefix, index, suffix, and extension. + * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf" + * (In default, prefix and suffix is empty, so usually the destination would be + * /mnt/sdcard/00001.vcf.) + * + * This method increments "index" part from 1 to maximum, and checks whether any file name + * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the + * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is + * returned. + * + * There may not be any appropriate file name. If there are 99999 vCard files in the + * storage, for example, there's no appropriate name, so this method returns + * null. + */ + + // Count the number of digits of mFileIndexMaximum + // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the + int fileIndexDigit = 0; + { + // Calling Math.Log10() is costly. + int tmp; + for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0; + fileIndexDigit++, tmp /= 10) { + } + } + + // %s05d%s (e.g. "p00001s") + final String bodyFormat = "%s%0" + fileIndexDigit + "d%s"; + + if (!ALLOW_LONG_FILE_NAME) { + final String possibleBody = + String.format(bodyFormat, mFileNamePrefix, 1, mFileNameSuffix); + if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) { + Log.e(LOG_TAG, "This code does not allow any long file name."); + mErrorReason = getString(R.string.fail_reason_too_long_filename, + String.format("%s.%s", possibleBody, mFileNameExtension)); + Log.w(LOG_TAG, "File name becomes too long."); + return null; + } + } + + for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) { + boolean numberIsAvailable = true; + final String body = String.format(bodyFormat, mFileNamePrefix, i, mFileNameSuffix); + // Make sure that none of the extensions of mExtensionsToConsider matches. If this + // number is free, we'll go ahead with mFileNameExtension (which is included in + // mExtensionsToConsider) + for (String possibleExtension : mExtensionsToConsider) { + final File file = new File(destDirectory, body + "." + possibleExtension); + final String path = file.getAbsolutePath(); + synchronized (this) { + // Is this being exported right now? Skip this number + if (mReservedDestination.contains(path)) { + if (DEBUG) { + Log.d(LOG_TAG, String.format("%s is already being exported.", path)); + } + numberIsAvailable = false; + break; + } + } + if (file.exists()) { + numberIsAvailable = false; + break; + } + } + if (numberIsAvailable) { + return new File(destDirectory, body + "." + mFileNameExtension).getAbsolutePath(); + } + } + + Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage"); + mErrorReason = getString(R.string.fail_reason_too_many_vcard); + return null; + } +} |