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.vcard; 17 18 import com.android.contacts.R; 19 20 import android.app.Service; 21 import android.content.Intent; 22 import android.content.res.Resources; 23 import android.media.MediaScannerConnection; 24 import android.media.MediaScannerConnection.MediaScannerConnectionClient; 25 import android.net.Uri; 26 import android.os.Binder; 27 import android.os.Handler; 28 import android.os.IBinder; 29 import android.os.Message; 30 import android.os.Messenger; 31 import android.os.RemoteException; 32 import android.text.TextUtils; 33 import android.util.Log; 34 35 import java.io.File; 36 import java.util.ArrayList; 37 import java.util.HashMap; 38 import java.util.HashSet; 39 import java.util.List; 40 import java.util.Map; 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 Map<Integer, ProcessorBase> mRunningJobMap = 112 new HashMap<Integer, ProcessorBase>(); 113 // Stores ScannerConnectionClient objects until they finish scanning requested files. 114 // Uses List class for simplicity. It's not costly as we won't have multiple objects in 115 // almost all cases. 116 private final List<CustomMediaScannerConnectionClient> mRemainingScannerConnections = 117 new ArrayList<CustomMediaScannerConnectionClient>(); 118 119 /* ** vCard exporter params ** */ 120 // If true, VCardExporter is able to emits files longer than 8.3 format. 121 private static final boolean ALLOW_LONG_FILE_NAME = false; 122 123 private String mTargetDirectory; 124 private String mFileNamePrefix; 125 private String mFileNameSuffix; 126 private int mFileIndexMinimum; 127 private int mFileIndexMaximum; 128 private String mFileNameExtension; 129 private Set<String> mExtensionsToConsider; 130 private String mErrorReason; 131 private MyBinder mBinder; 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 { 138 public VCardService getService() { 139 return VCardService.this; 140 } 141 } 142 143 @Override 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 151 private void initExporterParams() { 152 mTargetDirectory = getString(R.string.config_export_dir); 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 177 public int onStartCommand(Intent intent, int flags, int id) { 178 return START_STICKY; 179 } 180 181 @Override 182 public IBinder onBind(Intent intent) { 183 return mBinder; 184 } 185 186 @Override 187 public void onDestroy() { 188 if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed."); 189 cancelAllRequestsAndShutdown(); 190 clearCache(); 191 super.onDestroy(); 192 } 193 194 public synchronized void handleImportRequest(List<ImportRequest> requests, 195 VCardImportExportListener listener) { 196 if (DEBUG) { 197 final ArrayList<String> uris = new ArrayList<String>(); 198 final ArrayList<String> displayNames = new ArrayList<String>(); 199 for (ImportRequest request : requests) { 200 uris.add(request.uri.toString()); 201 displayNames.add(request.displayName); 202 } 203 Log.d(LOG_TAG, 204 String.format("received multiple import request (uri: %s, displayName: %s)", 205 uris.toString(), displayNames.toString())); 206 } 207 final int size = requests.size(); 208 for (int i = 0; i < size; i++) { 209 ImportRequest request = requests.get(i); 210 211 if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) { 212 if (listener != null) { 213 listener.onImportProcessed(request, mCurrentJobId, i); 214 } 215 mCurrentJobId++; 216 } else { 217 if (listener != null) { 218 listener.onImportFailed(request); 219 } 220 // A rejection means executor doesn't run any more. Exit. 221 break; 222 } 223 } 224 } 225 226 public synchronized void handleExportRequest(ExportRequest request, 227 VCardImportExportListener listener) { 228 if (tryExecute(new ExportProcessor(this, request, mCurrentJobId))) { 229 final String path = request.destUri.getEncodedPath(); 230 if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path); 231 if (!mReservedDestination.add(path)) { 232 Log.w(LOG_TAG, 233 String.format("The path %s is already reserved. Reject export request", 234 path)); 235 if (listener != null) { 236 listener.onExportFailed(request); 237 } 238 return; 239 } 240 241 if (listener != null) { 242 listener.onExportProcessed(request, mCurrentJobId); 243 } 244 mCurrentJobId++; 245 } else { 246 if (listener != null) { 247 listener.onExportFailed(request); 248 } 249 } 250 } 251 252 /** 253 * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor. 254 * @return true when successful. 255 */ 256 private synchronized boolean tryExecute(ProcessorBase processor) { 257 try { 258 if (DEBUG) { 259 Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown() 260 + ", terminated: " + mExecutorService.isTerminated()); 261 } 262 mExecutorService.execute(processor); 263 mRunningJobMap.put(mCurrentJobId, processor); 264 return true; 265 } catch (RejectedExecutionException e) { 266 Log.w(LOG_TAG, "Failed to excetute a job.", e); 267 return false; 268 } 269 } 270 271 public synchronized void handleCancelRequest(CancelRequest request, 272 VCardImportExportListener listener) { 273 final int jobId = request.jobId; 274 if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId)); 275 final ProcessorBase processor = mRunningJobMap.remove(jobId); 276 277 if (processor != null) { 278 processor.cancel(true); 279 final int type = processor.getType(); 280 if (listener != null) { 281 listener.onCancelRequest(request, type); 282 } 283 if (type == TYPE_EXPORT) { 284 final String path = 285 ((ExportProcessor)processor).getRequest().destUri.getEncodedPath(); 286 Log.i(LOG_TAG, 287 String.format("Cancel reservation for the path %s if appropriate", path)); 288 if (!mReservedDestination.remove(path)) { 289 Log.w(LOG_TAG, "Not reserved."); 290 } 291 } 292 } else { 293 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 294 } 295 stopServiceIfAppropriate(); 296 } 297 298 public synchronized void handleRequestAvailableExportDestination(final Messenger messenger) { 299 if (DEBUG) Log.d(LOG_TAG, "Received available export destination request."); 300 final String path = getAppropriateDestination(mTargetDirectory); 301 final Message message; 302 if (path != null) { 303 message = Message.obtain(null, 304 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 0, 0, path); 305 } else { 306 message = Message.obtain(null, 307 VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 308 R.id.dialog_fail_to_export_with_reason, 0, mErrorReason); 309 } 310 try { 311 messenger.send(message); 312 } catch (RemoteException e) { 313 Log.w(LOG_TAG, "Failed to send reply for available export destination request.", e); 314 } 315 } 316 317 /** 318 * Checks job list and call {@link #stopSelf()} when there's no job and no scanner connection 319 * is remaining. 320 * A new job (import/export) cannot be submitted any more after this call. 321 */ 322 private synchronized void stopServiceIfAppropriate() { 323 if (mRunningJobMap.size() > 0) { 324 for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) { 325 final int jobId = entry.getKey(); 326 final ProcessorBase processor = entry.getValue(); 327 if (processor.isDone()) { 328 mRunningJobMap.remove(jobId); 329 } else { 330 Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId)); 331 return; 332 } 333 } 334 } 335 336 if (!mRemainingScannerConnections.isEmpty()) { 337 Log.i(LOG_TAG, "MediaScanner update is in progress."); 338 return; 339 } 340 341 Log.i(LOG_TAG, "No unfinished job. Stop this service."); 342 mExecutorService.shutdown(); 343 stopSelf(); 344 } 345 346 /* package */ synchronized void updateMediaScanner(String path) { 347 if (DEBUG) { 348 Log.d(LOG_TAG, "MediaScanner is being updated: " + path); 349 } 350 351 if (mExecutorService.isShutdown()) { 352 Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " + 353 "Ignoring the update request"); 354 return; 355 } 356 final CustomMediaScannerConnectionClient client = 357 new CustomMediaScannerConnectionClient(path); 358 mRemainingScannerConnections.add(client); 359 client.start(); 360 } 361 362 private synchronized void removeConnectionClient( 363 CustomMediaScannerConnectionClient client) { 364 if (DEBUG) { 365 Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient."); 366 } 367 mRemainingScannerConnections.remove(client); 368 stopServiceIfAppropriate(); 369 } 370 371 /* package */ synchronized void handleFinishImportNotification( 372 int jobId, boolean successful) { 373 if (DEBUG) { 374 Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). " 375 + "Result: %b", jobId, (successful ? "success" : "failure"))); 376 } 377 if (mRunningJobMap.remove(jobId) == null) { 378 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 379 } 380 stopServiceIfAppropriate(); 381 } 382 383 /* package */ synchronized void handleFinishExportNotification( 384 int jobId, boolean successful) { 385 if (DEBUG) { 386 Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). " 387 + "Result: %b", jobId, (successful ? "success" : "failure"))); 388 } 389 final ProcessorBase job = mRunningJobMap.remove(jobId); 390 if (job == null) { 391 Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId)); 392 } else if (!(job instanceof ExportProcessor)) { 393 Log.w(LOG_TAG, 394 String.format("Removed job (id: %s) isn't ExportProcessor", jobId)); 395 } else { 396 final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath(); 397 if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path); 398 mReservedDestination.remove(path); 399 } 400 401 stopServiceIfAppropriate(); 402 } 403 404 /** 405 * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which 406 * means this Service becomes no longer ready for import/export requests. 407 * 408 * Mainly called from onDestroy(). 409 */ 410 private synchronized void cancelAllRequestsAndShutdown() { 411 for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) { 412 entry.getValue().cancel(true); 413 } 414 mRunningJobMap.clear(); 415 mExecutorService.shutdown(); 416 } 417 418 /** 419 * Removes import caches stored locally. 420 */ 421 private void clearCache() { 422 for (final String fileName : fileList()) { 423 if (fileName.startsWith(CACHE_FILE_PREFIX)) { 424 // We don't want to keep all the caches so we remove cache files old enough. 425 Log.i(LOG_TAG, "Remove a temporary file: " + fileName); 426 deleteFile(fileName); 427 } 428 } 429 } 430 431 /** 432 * Returns an appropriate file name for vCard export. Returns null when impossible. 433 * 434 * @return destination path for a vCard file to be exported. null on error and mErrorReason 435 * is correctly set. 436 */ 437 private String getAppropriateDestination(final String destDirectory) { 438 /* 439 * Here, file names have 5 parts: directory, prefix, index, suffix, and extension. 440 * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf" 441 * (In default, prefix and suffix is empty, so usually the destination would be 442 * /mnt/sdcard/00001.vcf.) 443 * 444 * This method increments "index" part from 1 to maximum, and checks whether any file name 445 * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the 446 * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is 447 * returned. 448 * 449 * There may not be any appropriate file name. If there are 99999 vCard files in the 450 * storage, for example, there's no appropriate name, so this method returns 451 * null. 452 */ 453 454 // Count the number of digits of mFileIndexMaximum 455 // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the 456 int fileIndexDigit = 0; 457 { 458 // Calling Math.Log10() is costly. 459 int tmp; 460 for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0; 461 fileIndexDigit++, tmp /= 10) { 462 } 463 } 464 465 // %s05d%s (e.g. "p00001s") 466 final String bodyFormat = "%s%0" + fileIndexDigit + "d%s"; 467 468 if (!ALLOW_LONG_FILE_NAME) { 469 final String possibleBody = 470 String.format(bodyFormat, mFileNamePrefix, 1, mFileNameSuffix); 471 if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) { 472 Log.e(LOG_TAG, "This code does not allow any long file name."); 473 mErrorReason = getString(R.string.fail_reason_too_long_filename, 474 String.format("%s.%s", possibleBody, mFileNameExtension)); 475 Log.w(LOG_TAG, "File name becomes too long."); 476 return null; 477 } 478 } 479 480 for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) { 481 boolean numberIsAvailable = true; 482 String body = null; 483 for (String possibleExtension : mExtensionsToConsider) { 484 body = String.format(bodyFormat, mFileNamePrefix, i, mFileNameSuffix); 485 final String path = 486 String.format("%s/%s.%s", destDirectory, body, possibleExtension); 487 synchronized (this) { 488 if (mReservedDestination.contains(path)) { 489 if (DEBUG) { 490 Log.d(LOG_TAG, String.format("The path %s is reserved.", path)); 491 } 492 numberIsAvailable = false; 493 break; 494 } 495 } 496 final File file = new File(path); 497 if (file.exists()) { 498 numberIsAvailable = false; 499 break; 500 } 501 } 502 if (numberIsAvailable) { 503 return String.format("%s/%s.%s", destDirectory, body, mFileNameExtension); 504 } 505 } 506 507 Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage"); 508 mErrorReason = getString(R.string.fail_reason_too_many_vcard); 509 return null; 510 } 511 } 512