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