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 */ 16 package com.android.contacts.common.vcard; 17 18 import android.app.Service; 19 import android.content.Intent; 20 import android.content.res.Resources; 21 import android.media.MediaScannerConnection; 22 import android.media.MediaScannerConnection.MediaScannerConnectionClient; 23 import android.net.Uri; 24 import android.os.Binder; 25 import android.os.Environment; 26 import android.os.IBinder; 27 import android.os.Message; 28 import android.os.Messenger; 29 import android.os.RemoteException; 30 import android.text.TextUtils; 31 import android.util.Log; 32 import android.util.SparseArray; 33 34 import com.android.contacts.common.R; 35 36 import java.io.File; 37 import java.util.ArrayList; 38 import java.util.HashSet; 39 import java.util.List; 40 import java.util.Locale; 41 import java.util.Set; 42 import java.util.concurrent.ExecutorService; 43 import java.util.concurrent.Executors; 44 import java.util.concurrent.RejectedExecutionException; 45 46 /** 47 * The class responsible for handling vCard import/export requests. 48 * 49 * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push 50 * it to {@link ExecutorService} with single thread executor. The executor handles each request 51 * one by one, and notifies users when needed. 52 */ 53 // TODO: Using IntentService looks simpler than using Service + ServiceConnection though this 54 // works fine enough. Investigate the feasibility. 55 public class VCardService extends Service { 56 private final static String LOG_TAG = "VCardService"; 57 58 /* package */ final static boolean DEBUG = false; 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 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 76 private class CustomMediaScannerConnectionClient implements MediaScannerConnectionClient { 77 final MediaScannerConnection mConnection; 78 final String mPath; 79 80 public CustomMediaScannerConnectionClient(String path) { 81 mConnection = new MediaScannerConnection(VCardService.this, this); 82 mPath = path; 83 } 84 85 public void start() { 86 mConnection.connect(); 87 } 88 89 @Override 90 public void onMediaScannerConnected() { 91 if (DEBUG) { Log.d(LOG_TAG, "Connected to MediaScanner. Start scanning."); } 92 mConnection.scanFile(mPath, null); 93 } 94 95 @Override 96 public void onScanCompleted(String path, Uri uri) { 97 if (DEBUG) { Log.d(LOG_TAG, "scan completed: " + path); } 98 mConnection.disconnect(); 99 removeConnectionClient(this); 100 } 101 } 102 103 // Should be single thread, as we don't want to simultaneously handle import and export 104 // requests. 105 private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor(); 106 107 private int mCurrentJobId; 108 109 // Stores all unfinished import/export jobs which will be executed by mExecutorService. 110 // Key is jobId. 111 private final SparseArray<ProcessorBase> mRunningJobMap = new SparseArray<ProcessorBase>(); 112 // Stores ScannerConnectionClient objects until they finish scanning requested files. 113 // Uses List class for simplicity. It's not costly as we won't have multiple objects in 114 // almost all cases. 115 private final List<CustomMediaScannerConnectionClient> mRemainingScannerConnections = 116 new ArrayList<CustomMediaScannerConnectionClient>(); 117 118 /* ** vCard exporter params ** */ 119 // If true, VCardExporter is able to emits files longer than 8.3 format. 120 private static final boolean ALLOW_LONG_FILE_NAME = false; 121 122 private File mTargetDirectory; 123 private String mFileNamePrefix; 124 private String mFileNameSuffix; 125 private int mFileIndexMinimum; 126 private int mFileIndexMaximum; 127 private String mFileNameExtension; 128 private Set<String> mExtensionsToConsider; 129 private String mErrorReason; 130 private MyBinder mBinder; 131 132 private String mCallingActivity; 133 134 // File names currently reserved by some export job. 135 private final Set<String> mReservedDestination = new HashSet<String>(); 136 /* ** end of vCard exporter params ** */ 137 138 public class MyBinder extends Binder { 139 public VCardService getService() { 140 return VCardService.this; 141 } 142 } 143 144 @Override 145 public void onCreate() { 146 super.onCreate(); 147 mBinder = new MyBinder(); 148 if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created."); 149 initExporterParams(); 150 } 151 152 private void initExporterParams() { 153 mTargetDirectory = Environment.getExternalStorageDirectory(); 154 mFileNamePrefix = getString(R.string.config_export_file_prefix); 155 mFileNameSuffix = getString(R.string.config_export_file_suffix); 156 mFileNameExtension = getString(R.string.config_export_file_extension); 157 158 mExtensionsToConsider = new HashSet<String>(); 159 mExtensionsToConsider.add(mFileNameExtension); 160 161 final String additionalExtensions = 162 getString(R.string.config_export_extensions_to_consider); 163 if (!TextUtils.isEmpty(additionalExtensions)) { 164 for (String extension : additionalExtensions.split(",")) { 165 String trimed = extension.trim(); 166 if (trimed.length() > 0) { 167 mExtensionsToConsider.add(trimed); 168 } 169 } 170 } 171 172 final Resources resources = getResources(); 173 mFileIndexMinimum = resources.getInteger(R.integer.config_export_file_min_index); 174 mFileIndexMaximum = resources.getInteger(R.integer.config_export_file_max_index); 175 } 176 177 @Override 178 public int onStartCommand(Intent intent, int flags, int id) { 179 if (intent != null && intent.getExtras() != null) { 180 mCallingActivity = intent.getExtras().getString( 181 VCardCommonArguments.ARG_CALLING_ACTIVITY); 182 } else { 183 mCallingActivity = null; 184 } 185 return START_STICKY; 186 } 187 188 @Override 189 public IBinder onBind(Intent intent) { 190 return mBinder; 191 } 192 193 @Override 194 public void onDestroy() { 195 if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed."); 196 cancelAllRequestsAndShutdown(); 197 clearCache(); 198 super.onDestroy(); 199 } 200 201 public synchronized void handleImportRequest(List<ImportRequest> requests, 202 VCardImportExportListener listener) { 203 if (DEBUG) { 204 final ArrayList<String> uris = new ArrayList<String>(); 205 final ArrayList<String> displayNames = new ArrayList<String>(); 206 for (ImportRequest request : requests) { 207 uris.add(request.uri.toString()); 208 displayNames.add(request.displayName); 209 } 210 Log.d(LOG_TAG, 211 String.format("received multiple import request (uri: %s, displayName: %s)", 212 uris.toString(), displayNames.toString())); 213 } 214 final int size = requests.size(); 215 for (int i = 0; i < size; i++) { 216 ImportRequest request = requests.get(i); 217 218 if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) { 219 if (listener != null) { 220 listener.onImportProcessed(request, mCurrentJobId, i); 221 } 222 mCurrentJobId++; 223 } else { 224 if (listener != null) { 225 listener.onImportFailed(request); 226 } 227 // A rejection means executor doesn't run any more. Exit. 228 break; 229 } 230 } 231 } 232 233 public synchronized void handleExportRequest(ExportRequest request, 234 VCardImportExportListener listener) { 235 if (tryExecute(new ExportProcessor(this, request, mCurrentJobId, mCallingActivity))) { 236 final String path = request.destUri.getEncodedPath(); 237 if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path); 238 if (!mReservedDestination.add(path)) { 239 Log.w(LOG_TAG, 240 String.format("The path %s is already reserved. Reject export request", 241 path)); 242 if (listener != null) { 243 listener.onExportFailed(request); 244 } 245 return; 246 } 247 248 if (listener != null) { 249 listener.onExportProcessed(request, mCurrentJobId); 250 } 251 mCurrentJobId++; 252 } else { 253 if (listener != null) { 254 listener.onExportFailed(request); 255 } 256 } 257 } 258 259 /** 260 * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor. 261 * @return true when successful. 262 */ 263 private synchronized boolean tryExecute(ProcessorBase processor) { 264 try { 265 if (DEBUG) { 266 Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown() 267 + ", terminated: " + mExecutorService.isTerminated()); 268 } 269 mExecutorService.execute(processor); 270 mRunningJobMap.put(mCurrentJobId, processor); 271 return true; 272 } catch (RejectedExecutionException e) { 273 Log.w(LOG_TAG, "Failed to excetute a job.", e); 274 return false; 275 } 276 } 277 278 public synchronized void handleCancelRequest(CancelRequest request, 279 VCardImportExportListener listener) { 280 final int jobId = request.jobId; 281 if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId)); 282 283 final ProcessorBase processor = mRunningJobMap.get(jobId); 284 mRunningJobMap.remove(jobId); 285 286 if (processor != null) { 287 processor.cancel(true); 288 final int type = processor.getType(); 289 if (listener != null) { 290 listener.onCancelRequest(request, type); 291 } 292 if (type == TYPE_EXPORT) { 293 final String path = 294 ((ExportProcessor)processor).getRequest().destUri.getEncodedPath(); 295 Log.i(LOG_TAG, 296 String.format("Cancel reservation for the path %s if appropriate", path)); 297 if (!mReservedDestination.remove(path)) { 298 Log.w(LOG_TAG, "Not reserved."); 299 } 300 } 301 } else { 302 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 303 } 304 stopServiceIfAppropriate(); 305 } 306 307 public synchronized void handleRequestAvailableExportDestination(final Messenger messenger) { 308 if (DEBUG) Log.d(LOG_TAG, "Received available export destination request."); 309 final String path = getAppropriateDestination(mTargetDirectory); 310 final Message message; 311 if (path != null) { 312 message = Message.obtain(null, 313 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 0, 0, path); 314 } else { 315 message = Message.obtain(null, 316 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 317 R.id.dialog_fail_to_export_with_reason, 0, mErrorReason); 318 } 319 try { 320 messenger.send(message); 321 } catch (RemoteException e) { 322 Log.w(LOG_TAG, "Failed to send reply for available export destination request.", e); 323 } 324 } 325 326 /** 327 * Checks job list and call {@link #stopSelf()} when there's no job and no scanner connection 328 * is remaining. 329 * A new job (import/export) cannot be submitted any more after this call. 330 */ 331 private synchronized void stopServiceIfAppropriate() { 332 if (mRunningJobMap.size() > 0) { 333 final int size = mRunningJobMap.size(); 334 335 // Check if there are processors which aren't finished yet. If we still have ones to 336 // process, we cannot stop the service yet. Also clean up already finished processors 337 // here. 338 339 // Job-ids to be removed. At first all elements in the array are invalid and will 340 // be filled with real job-ids from the array's top. When we find a not-yet-finished 341 // processor, then we start removing those finished jobs. In that case latter half of 342 // this array will be invalid. 343 final int[] toBeRemoved = new int[size]; 344 for (int i = 0; i < size; i++) { 345 final int jobId = mRunningJobMap.keyAt(i); 346 final ProcessorBase processor = mRunningJobMap.valueAt(i); 347 if (!processor.isDone()) { 348 Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId)); 349 350 // Remove processors which are already "done", all of which should be before 351 // processors which aren't done yet. 352 for (int j = 0; j < i; j++) { 353 mRunningJobMap.remove(toBeRemoved[j]); 354 } 355 return; 356 } 357 358 // Remember the finished processor. 359 toBeRemoved[i] = jobId; 360 } 361 362 // We're sure we can remove all. Instead of removing one by one, just call clear(). 363 mRunningJobMap.clear(); 364 } 365 366 if (!mRemainingScannerConnections.isEmpty()) { 367 Log.i(LOG_TAG, "MediaScanner update is in progress."); 368 return; 369 } 370 371 Log.i(LOG_TAG, "No unfinished job. Stop this service."); 372 mExecutorService.shutdown(); 373 stopSelf(); 374 } 375 376 /* package */ synchronized void updateMediaScanner(String path) { 377 if (DEBUG) { 378 Log.d(LOG_TAG, "MediaScanner is being updated: " + path); 379 } 380 381 if (mExecutorService.isShutdown()) { 382 Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " + 383 "Ignoring the update request"); 384 return; 385 } 386 final CustomMediaScannerConnectionClient client = 387 new CustomMediaScannerConnectionClient(path); 388 mRemainingScannerConnections.add(client); 389 client.start(); 390 } 391 392 private synchronized void removeConnectionClient( 393 CustomMediaScannerConnectionClient client) { 394 if (DEBUG) { 395 Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient."); 396 } 397 mRemainingScannerConnections.remove(client); 398 stopServiceIfAppropriate(); 399 } 400 401 /* package */ synchronized void handleFinishImportNotification( 402 int jobId, boolean successful) { 403 if (DEBUG) { 404 Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). " 405 + "Result: %b", jobId, (successful ? "success" : "failure"))); 406 } 407 mRunningJobMap.remove(jobId); 408 stopServiceIfAppropriate(); 409 } 410 411 /* package */ synchronized void handleFinishExportNotification( 412 int jobId, boolean successful) { 413 if (DEBUG) { 414 Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). " 415 + "Result: %b", jobId, (successful ? "success" : "failure"))); 416 } 417 final ProcessorBase job = mRunningJobMap.get(jobId); 418 mRunningJobMap.remove(jobId); 419 if (job == null) { 420 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 421 } else if (!(job instanceof ExportProcessor)) { 422 Log.w(LOG_TAG, 423 String.format("Removed job (id: %s) isn't ExportProcessor", jobId)); 424 } else { 425 final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath(); 426 if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path); 427 mReservedDestination.remove(path); 428 } 429 430 stopServiceIfAppropriate(); 431 } 432 433 /** 434 * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which 435 * means this Service becomes no longer ready for import/export requests. 436 * 437 * Mainly called from onDestroy(). 438 */ 439 private synchronized void cancelAllRequestsAndShutdown() { 440 for (int i = 0; i < mRunningJobMap.size(); i++) { 441 mRunningJobMap.valueAt(i).cancel(true); 442 } 443 mRunningJobMap.clear(); 444 mExecutorService.shutdown(); 445 } 446 447 /** 448 * Removes import caches stored locally. 449 */ 450 private void clearCache() { 451 for (final String fileName : fileList()) { 452 if (fileName.startsWith(CACHE_FILE_PREFIX)) { 453 // We don't want to keep all the caches so we remove cache files old enough. 454 Log.i(LOG_TAG, "Remove a temporary file: " + fileName); 455 deleteFile(fileName); 456 } 457 } 458 } 459 460 /** 461 * Returns an appropriate file name for vCard export. Returns null when impossible. 462 * 463 * @return destination path for a vCard file to be exported. null on error and mErrorReason 464 * is correctly set. 465 */ 466 private String getAppropriateDestination(final File destDirectory) { 467 /* 468 * Here, file names have 5 parts: directory, prefix, index, suffix, and extension. 469 * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf" 470 * (In default, prefix and suffix is empty, so usually the destination would be 471 * /mnt/sdcard/00001.vcf.) 472 * 473 * This method increments "index" part from 1 to maximum, and checks whether any file name 474 * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the 475 * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is 476 * returned. We format these numbers in the US locale to ensure we they appear as 477 * english numerals. 478 * 479 * There may not be any appropriate file name. If there are 99999 vCard files in the 480 * storage, for example, there's no appropriate name, so this method returns 481 * null. 482 */ 483 484 // Count the number of digits of mFileIndexMaximum 485 // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the 486 int fileIndexDigit = 0; 487 { 488 // Calling Math.Log10() is costly. 489 int tmp; 490 for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0; 491 fileIndexDigit++, tmp /= 10) { 492 } 493 } 494 495 // %s05d%s (e.g. "p00001s") 496 final String bodyFormat = "%s%0" + fileIndexDigit + "d%s"; 497 498 if (!ALLOW_LONG_FILE_NAME) { 499 final String possibleBody = 500 String.format(Locale.US, bodyFormat, mFileNamePrefix, 1, mFileNameSuffix); 501 if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) { 502 Log.e(LOG_TAG, "This code does not allow any long file name."); 503 mErrorReason = getString(R.string.fail_reason_too_long_filename, 504 String.format("%s.%s", possibleBody, mFileNameExtension)); 505 Log.w(LOG_TAG, "File name becomes too long."); 506 return null; 507 } 508 } 509 510 for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) { 511 boolean numberIsAvailable = true; 512 final String body 513 = String.format(Locale.US, bodyFormat, mFileNamePrefix, i, mFileNameSuffix); 514 // Make sure that none of the extensions of mExtensionsToConsider matches. If this 515 // number is free, we'll go ahead with mFileNameExtension (which is included in 516 // mExtensionsToConsider) 517 for (String possibleExtension : mExtensionsToConsider) { 518 final File file = new File(destDirectory, body + "." + possibleExtension); 519 final String path = file.getAbsolutePath(); 520 synchronized (this) { 521 // Is this being exported right now? Skip this number 522 if (mReservedDestination.contains(path)) { 523 if (DEBUG) { 524 Log.d(LOG_TAG, String.format("%s is already being exported.", path)); 525 } 526 numberIsAvailable = false; 527 break; 528 } 529 } 530 if (file.exists()) { 531 numberIsAvailable = false; 532 break; 533 } 534 } 535 if (numberIsAvailable) { 536 return new File(destDirectory, body + "." + mFileNameExtension).getAbsolutePath(); 537 } 538 } 539 540 Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage"); 541 mErrorReason = getString(R.string.fail_reason_too_many_vcard); 542 return null; 543 } 544 } 545