Home | History | Annotate | Download | only in vcard
      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