VCardService.java revision 783a09a8770f4322a45cee456adefbbc71218ece
1/* 2 * Copyright (C) 2010 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16package com.android.contacts.vcard; 17 18import com.android.contacts.R; 19 20import android.app.Notification; 21import android.app.NotificationManager; 22import android.app.PendingIntent; 23import android.app.Service; 24import android.content.Context; 25import android.content.Intent; 26import android.content.res.Resources; 27import android.net.Uri; 28import android.os.Handler; 29import android.os.IBinder; 30import android.os.Message; 31import android.os.Messenger; 32import android.os.RemoteException; 33import android.text.TextUtils; 34import android.util.Log; 35import android.widget.RemoteViews; 36import android.widget.Toast; 37 38import java.io.File; 39import java.util.HashMap; 40import java.util.HashSet; 41import java.util.Map; 42import java.util.Set; 43import java.util.concurrent.ExecutorService; 44import java.util.concurrent.Executors; 45import java.util.concurrent.RejectedExecutionException; 46 47/** 48 * The class responsible for handling vCard import/export requests. 49 * 50 * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push 51 * it to {@link ExecutorService} with single thread executor. The executor handles each request 52 * one by one, and notifies users when needed. 53 */ 54// TODO: Using IntentService looks simpler than using Service + ServiceConnection though this 55// works fine enough. Investigate the feasibility. 56public class VCardService extends Service { 57 private final static String LOG_TAG = "VCardService"; 58 /* package */ final static boolean DEBUG = true; 59 60 /* package */ static final int MSG_IMPORT_REQUEST = 1; 61 /* package */ static final int MSG_EXPORT_REQUEST = 2; 62 /* package */ static final int MSG_CANCEL_REQUEST = 3; 63 /* package */ static final int MSG_REQUEST_AVAILABLE_EXPORT_DESTINATION = 4; 64 /* package */ static final int MSG_SET_AVAILABLE_EXPORT_DESTINATION = 5; 65 66 /** 67 * Specifies the type of operation. Used when constructing a {@link Notification}, canceling 68 * some operation, etc. 69 */ 70 /* package */ static final int TYPE_IMPORT = 1; 71 /* package */ static final int TYPE_EXPORT = 2; 72 73 /* package */ static final String CACHE_FILE_PREFIX = "import_tmp_"; 74 75 private final Messenger mMessenger = new Messenger(new Handler() { 76 @Override 77 public void handleMessage(Message msg) { 78 switch (msg.what) { 79 case MSG_IMPORT_REQUEST: { 80 handleImportRequest((ImportRequest)msg.obj); 81 break; 82 } 83 case MSG_EXPORT_REQUEST: { 84 handleExportRequest((ExportRequest)msg.obj); 85 break; 86 } 87 case MSG_CANCEL_REQUEST: { 88 handleCancelRequest((CancelRequest)msg.obj); 89 break; 90 } 91 case MSG_REQUEST_AVAILABLE_EXPORT_DESTINATION: { 92 handleRequestAvailableExportDestination(msg); 93 break; 94 } 95 // TODO: add cancel capability for export.. 96 default: { 97 Log.w(LOG_TAG, "Received unknown request, ignoring it."); 98 super.hasMessages(msg.what); 99 } 100 } 101 } 102 }); 103 104 private NotificationManager mNotificationManager; 105 106 // Should be single thread, as we don't want to simultaneously handle import and export 107 // requests. 108 private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); 109 110 private int mCurrentJobId; 111 112 // Stores all unfinished import/export jobs which will be executed by mExecutorService. 113 // Key is jobId. 114 private final Map<Integer, ProcessorBase> mRunningJobMap = 115 new HashMap<Integer, ProcessorBase>(); 116 117 /* ** vCard exporter params ** */ 118 // If true, VCardExporter is able to emits files longer than 8.3 format. 119 private static final boolean ALLOW_LONG_FILE_NAME = false; 120 private String mTargetDirectory; 121 private String mFileNamePrefix; 122 private String mFileNameSuffix; 123 private int mFileIndexMinimum; 124 private int mFileIndexMaximum; 125 private String mFileNameExtension; 126 private Set<String> mExtensionsToConsider; 127 private String mErrorReason; 128 129 // File names currently reserved by some export job. 130 private final Set<String> mReservedDestination = new HashSet<String>(); 131 /* ** end of vCard exporter params ** */ 132 133 @Override 134 public void onCreate() { 135 super.onCreate(); 136 if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created."); 137 mNotificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE); 138 initExporterParams(); 139 } 140 141 private void initExporterParams() { 142 mTargetDirectory = getString(R.string.config_export_dir); 143 mFileNamePrefix = getString(R.string.config_export_file_prefix); 144 mFileNameSuffix = getString(R.string.config_export_file_suffix); 145 mFileNameExtension = getString(R.string.config_export_file_extension); 146 147 mExtensionsToConsider = new HashSet<String>(); 148 mExtensionsToConsider.add(mFileNameExtension); 149 150 final String additionalExtensions = 151 getString(R.string.config_export_extensions_to_consider); 152 if (!TextUtils.isEmpty(additionalExtensions)) { 153 for (String extension : additionalExtensions.split(",")) { 154 String trimed = extension.trim(); 155 if (trimed.length() > 0) { 156 mExtensionsToConsider.add(trimed); 157 } 158 } 159 } 160 161 final Resources resources = getResources(); 162 mFileIndexMinimum = resources.getInteger(R.integer.config_export_file_min_index); 163 mFileIndexMaximum = resources.getInteger(R.integer.config_export_file_max_index); 164 } 165 166 @Override 167 public int onStartCommand(Intent intent, int flags, int id) { 168 return START_STICKY; 169 } 170 171 @Override 172 public IBinder onBind(Intent intent) { 173 return mMessenger.getBinder(); 174 } 175 176 @Override 177 public void onDestroy() { 178 if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed."); 179 cancelAllRequestsAndShutdown(); 180 clearCache(); 181 super.onDestroy(); 182 } 183 184 private synchronized void handleImportRequest(ImportRequest request) { 185 if (DEBUG) { 186 Log.d(LOG_TAG, 187 String.format("received import request (uri: %s, originalUri: %s)", 188 request.uri, request.originalUri)); 189 } 190 if (tryExecute(new ImportProcessor(this, request, mCurrentJobId))) { 191 final String displayName = request.originalUri.getLastPathSegment(); 192 final String message = getString(R.string.vcard_import_will_start_message, 193 displayName); 194 // TODO: Ideally we should detect the current status of import/export and show 195 // "started" when we can import right now and show "will start" when we cannot. 196 Toast.makeText(this, message, Toast.LENGTH_LONG).show(); 197 198 final Notification notification = 199 constructProgressNotification( 200 this, TYPE_IMPORT, message, message, mCurrentJobId, 201 displayName, -1, 0); 202 mNotificationManager.notify(mCurrentJobId, notification); 203 mCurrentJobId++; 204 } else { 205 // TODO: a little unkind to show Toast in this case, which is shown just a moment. 206 // Ideally we should show some persistent something users can notice more easily. 207 Toast.makeText(this, getString(R.string.vcard_import_request_rejected_message), 208 Toast.LENGTH_LONG).show(); 209 } 210 } 211 212 private synchronized void handleExportRequest(ExportRequest request) { 213 if (tryExecute(new ExportProcessor(this, request, mCurrentJobId))) { 214 final String displayName = request.destUri.getLastPathSegment(); 215 final String message = getString(R.string.vcard_export_will_start_message, 216 displayName); 217 218 final String path = request.destUri.getEncodedPath(); 219 if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path); 220 if (!mReservedDestination.add(path)) { 221 Log.w(LOG_TAG, 222 String.format("The path %s is already reserved. Reject export request", 223 path)); 224 Toast.makeText(this, getString(R.string.vcard_export_request_rejected_message), 225 Toast.LENGTH_LONG).show(); 226 return; 227 } 228 229 Toast.makeText(this, message, Toast.LENGTH_LONG).show(); 230 final Notification notification = 231 constructProgressNotification(this, TYPE_EXPORT, message, message, 232 mCurrentJobId, displayName, -1, 0); 233 mNotificationManager.notify(mCurrentJobId, notification); 234 mCurrentJobId++; 235 } else { 236 Toast.makeText(this, getString(R.string.vcard_export_request_rejected_message), 237 Toast.LENGTH_LONG).show(); 238 } 239 } 240 241 /** 242 * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor. 243 * @return true when successful. 244 */ 245 private synchronized boolean tryExecute(ProcessorBase processor) { 246 try { 247 mExecutorService.execute(processor); 248 mRunningJobMap.put(mCurrentJobId, processor); 249 return true; 250 } catch (RejectedExecutionException e) { 251 Log.w(LOG_TAG, "Failed to excetute a job.", e); 252 return false; 253 } 254 } 255 256 private synchronized void handleCancelRequest(CancelRequest request) { 257 final int jobId = request.jobId; 258 if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId)); 259 final ProcessorBase processor = mRunningJobMap.remove(jobId); 260 261 if (processor != null) { 262 processor.cancel(true); 263 final String description = processor.getType() == TYPE_IMPORT ? 264 getString(R.string.importing_vcard_canceled_title, request.displayName) : 265 getString(R.string.exporting_vcard_canceled_title, request.displayName); 266 final Notification notification = constructCancelNotification(this, description); 267 mNotificationManager.notify(jobId, notification); 268 if (processor.getType() == TYPE_EXPORT) { 269 final String path = 270 ((ExportProcessor)processor).getRequest().destUri.getEncodedPath(); 271 Log.i(LOG_TAG, 272 String.format("Cancel reservation for the path %s if appropriate", path)); 273 if (!mReservedDestination.remove(path)) { 274 Log.w(LOG_TAG, "Not reserved."); 275 } 276 } 277 } else { 278 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 279 } 280 stopServiceWhenNoJob(); 281 } 282 283 private synchronized void handleRequestAvailableExportDestination(Message msg) { 284 if (DEBUG) Log.d(LOG_TAG, "Received available export destination request."); 285 final Messenger messenger = msg.replyTo; 286 final String path = getAppropriateDestination(mTargetDirectory); 287 final Message message; 288 if (path != null) { 289 message = Message.obtain(null, 290 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 0, 0, path); 291 } else { 292 message = Message.obtain(null, 293 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 294 R.id.dialog_fail_to_export_with_reason, 0, mErrorReason); 295 } 296 try { 297 messenger.send(message); 298 } catch (RemoteException e) { 299 Log.w(LOG_TAG, "Failed to send reply for available export destination request.", e); 300 } 301 } 302 303 /** 304 * Checks job list and call {@link #stopSelf()} when there's no job now. 305 * A new job cannot be submitted any more after this call. 306 */ 307 private synchronized void stopServiceWhenNoJob() { 308 if (mRunningJobMap.size() > 0) { 309 for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) { 310 final int jobId = entry.getKey(); 311 final ProcessorBase processor = entry.getValue(); 312 if (processor.isDone()) { 313 mRunningJobMap.remove(jobId); 314 } else { 315 Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId)); 316 return; 317 } 318 } 319 } 320 321 Log.i(LOG_TAG, "No unfinished job. Stop this service."); 322 mExecutorService.shutdown(); 323 stopSelf(); 324 } 325 326 /* package */ synchronized void handleFinishImportNotification( 327 int jobId, boolean successful) { 328 if (DEBUG) { 329 Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). " 330 + "Result: %b", jobId, (successful ? "success" : "failure"))); 331 } 332 if (mRunningJobMap.remove(jobId) == null) { 333 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 334 } 335 stopServiceWhenNoJob(); 336 } 337 338 /* package */ synchronized void handleFinishExportNotification( 339 int jobId, boolean successful) { 340 if (DEBUG) { 341 Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). " 342 + "Result: %b", jobId, (successful ? "success" : "failure"))); 343 } 344 final ProcessorBase job = mRunningJobMap.remove(jobId); 345 if (job == null) { 346 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 347 } else if (!(job instanceof ExportProcessor)) { 348 Log.w(LOG_TAG, 349 String.format("Removed job (id: %s) isn't ExportProcessor", jobId)); 350 } else { 351 final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath(); 352 if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path); 353 mReservedDestination.remove(path); 354 } 355 356 stopServiceWhenNoJob(); 357 } 358 359 /** 360 * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which 361 * means this Service becomes no longer ready for import/export requests. 362 * 363 * Mainly called from onDestroy(). 364 */ 365 private synchronized void cancelAllRequestsAndShutdown() { 366 for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) { 367 entry.getValue().cancel(true); 368 } 369 mRunningJobMap.clear(); 370 mExecutorService.shutdown(); 371 } 372 373 /** 374 * Removes import caches stored locally. 375 */ 376 private void clearCache() { 377 for (final String fileName : fileList()) { 378 if (fileName.startsWith(CACHE_FILE_PREFIX)) { 379 // We don't want to keep all the caches so we remove cache files old enough. 380 Log.i(LOG_TAG, "Remove a temporary file: " + fileName); 381 deleteFile(fileName); 382 } 383 } 384 } 385 386 /** 387 * Constructs a {@link Notification} showing the current status of import/export. 388 * Users can cancel the process with the Notification. 389 * 390 * @param context 391 * @param type import/export 392 * @param description Content of the Notification. 393 * @param tickerText 394 * @param jobId 395 * @param displayName Name to be shown to the Notification (e.g. "finished importing XXXX"). 396 * Typycally a file name. 397 * @param totalCount The number of vCard entries to be imported. Used to show progress bar. 398 * -1 lets the system show the progress bar with "indeterminate" state. 399 * @param currentCount The index of current vCard. Used to show progress bar. 400 */ 401 /* package */ static Notification constructProgressNotification( 402 Context context, int type, String description, String tickerText, 403 int jobId, String displayName, int totalCount, int currentCount) { 404 final RemoteViews remoteViews = 405 new RemoteViews(context.getPackageName(), 406 R.layout.status_bar_ongoing_event_progress_bar); 407 remoteViews.setTextViewText(R.id.status_description, description); 408 remoteViews.setProgressBar(R.id.status_progress_bar, totalCount, currentCount, 409 totalCount == -1); 410 final String percentage; 411 if (totalCount > 0) { 412 percentage = context.getString(R.string.percentage, 413 String.valueOf(currentCount * 100/totalCount)); 414 } else { 415 percentage = ""; 416 } 417 remoteViews.setTextViewText(R.id.status_progress_text, percentage); 418 final int icon = (type == TYPE_IMPORT ? android.R.drawable.stat_sys_download : 419 android.R.drawable.stat_sys_upload); 420 remoteViews.setImageViewResource(R.id.status_icon, icon); 421 422 final Notification notification = new Notification(); 423 notification.icon = icon; 424 notification.tickerText = tickerText; 425 notification.contentView = remoteViews; 426 notification.flags |= Notification.FLAG_ONGOING_EVENT; 427 428 // Note: We cannot use extra values here (like setIntExtra()), as PendingIntent doesn't 429 // preserve them across multiple Notifications. PendingIntent preserves the first extras 430 // (when flag is not set), or update them when PendingIntent#getActivity() is called 431 // (See PendingIntent#FLAG_UPDATE_CURRENT). In either case, we cannot preserve extras as we 432 // expect (for each vCard import/export request). 433 // 434 // We use query parameter in Uri instead. 435 // Scheme and Authority is arbitorary, assuming CancelActivity never refers them. 436 final Intent intent = new Intent(context, CancelActivity.class); 437 final Uri uri = (new Uri.Builder()) 438 .scheme("invalidscheme") 439 .authority("invalidauthority") 440 .appendQueryParameter(CancelActivity.JOB_ID, String.valueOf(jobId)) 441 .appendQueryParameter(CancelActivity.DISPLAY_NAME, displayName) 442 .appendQueryParameter(CancelActivity.TYPE, String.valueOf(type)).build(); 443 intent.setData(uri); 444 445 notification.contentIntent = PendingIntent.getActivity(context, 0, intent, 0); 446 return notification; 447 } 448 449 /** 450 * Constructs a Notification telling users the process is canceled. 451 * 452 * @param context 453 * @param description Content of the Notification 454 */ 455 /* package */ static Notification constructCancelNotification( 456 Context context, String description) { 457 return new Notification.Builder(context) 458 .setAutoCancel(true) 459 .setSmallIcon(android.R.drawable.stat_notify_error) 460 .setContentTitle(description) 461 .setContentText(description) 462 .setContentIntent(PendingIntent.getActivity(context, 0, new Intent(), 0)) 463 .getNotification(); 464 } 465 466 /** 467 * Constructs a Notification telling users the process is finished. 468 * 469 * @param context 470 * @param description Content of the Notification 471 * @param intent Intent to be launched when the Notification is clicked. Can be null. 472 */ 473 /* package */ static Notification constructFinishNotification( 474 Context context, String title, String description, Intent intent) { 475 return new Notification.Builder(context) 476 .setAutoCancel(true) 477 .setSmallIcon(android.R.drawable.stat_sys_download_done) 478 .setContentTitle(title) 479 .setContentText(description) 480 .setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)) 481 .getNotification(); 482 } 483 484 /** 485 * Returns an appropriate file name for vCard export. Returns null when impossible. 486 * 487 * @return destination path for a vCard file to be exported. null on error and mErrorReason 488 * is correctly set. 489 */ 490 private String getAppropriateDestination(final String destDirectory) { 491 /* 492 * Here, file names have 5 parts: directory, prefix, index, suffix, and extension. 493 * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf" 494 * (In default, prefix and suffix is empty, so usually the destination would be 495 * /mnt/sdcard/00001.vcf.) 496 * 497 * This method increments "index" part from 1 to maximum, and checks whether any file name 498 * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the 499 * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is 500 * returned. 501 * 502 * There may not be any appropriate file name. If there are 99999 vCard files in the 503 * storage, for example, there's no appropriate name, so this method returns 504 * null. 505 */ 506 507 // Count the number of digits of mFileIndexMaximum 508 // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the 509 int fileIndexDigit = 0; 510 { 511 // Calling Math.Log10() is costly. 512 int tmp; 513 for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0; 514 fileIndexDigit++, tmp /= 10) { 515 } 516 } 517 518 // %s05d%s (e.g. "p00001s") 519 final String bodyFormat = "%s%0" + fileIndexDigit + "d%s"; 520 521 if (!ALLOW_LONG_FILE_NAME) { 522 final String possibleBody = 523 String.format(bodyFormat, mFileNamePrefix, 1, mFileNameSuffix); 524 if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) { 525 Log.e(LOG_TAG, "This code does not allow any long file name."); 526 mErrorReason = getString(R.string.fail_reason_too_long_filename, 527 String.format("%s.%s", possibleBody, mFileNameExtension)); 528 Log.w(LOG_TAG, "File name becomes too long."); 529 return null; 530 } 531 } 532 533 for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) { 534 boolean numberIsAvailable = true; 535 String body = null; 536 for (String possibleExtension : mExtensionsToConsider) { 537 body = String.format(bodyFormat, mFileNamePrefix, i, mFileNameSuffix); 538 final String path = 539 String.format("%s/%s.%s", destDirectory, body, possibleExtension); 540 synchronized (this) { 541 if (mReservedDestination.contains(path)) { 542 if (DEBUG) { 543 Log.d(LOG_TAG, String.format("The path %s is reserved.", path)); 544 } 545 numberIsAvailable = false; 546 break; 547 } 548 } 549 final File file = new File(path); 550 if (file.exists()) { 551 numberIsAvailable = false; 552 break; 553 } 554 } 555 if (numberIsAvailable) { 556 return String.format("%s/%s.%s", destDirectory, body, mFileNameExtension); 557 } 558 } 559 560 Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage"); 561 mErrorReason = getString(R.string.fail_reason_too_many_vcard); 562 return null; 563 } 564} 565