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 
CustomMediaScannerConnectionClient(String path)80         public CustomMediaScannerConnectionClient(String path) {
81             mConnection = new MediaScannerConnection(VCardService.this, this);
82             mPath = path;
83         }
84 
start()85         public void start() {
86             mConnection.connect();
87         }
88 
89         @Override
onMediaScannerConnected()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
onScanCompleted(String path, Uri uri)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 {
getService()139         public VCardService getService() {
140             return VCardService.this;
141         }
142     }
143 
144    @Override
onCreate()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 
initExporterParams()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
onStartCommand(Intent intent, int flags, int id)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
onBind(Intent intent)189     public IBinder onBind(Intent intent) {
190         return mBinder;
191     }
192 
193     @Override
onDestroy()194     public void onDestroy() {
195         if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
196         cancelAllRequestsAndShutdown();
197         clearCache();
198         super.onDestroy();
199     }
200 
handleImportRequest(List<ImportRequest> requests, VCardImportExportListener listener)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 
handleExportRequest(ExportRequest request, VCardImportExportListener listener)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      */
tryExecute(ProcessorBase processor)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 
handleCancelRequest(CancelRequest request, VCardImportExportListener listener)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 
handleRequestAvailableExportDestination(final Messenger messenger)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      */
stopServiceIfAppropriate()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 
updateMediaScanner(String path)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 
removeConnectionClient( CustomMediaScannerConnectionClient client)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 
handleFinishImportNotification( int jobId, boolean successful)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 
handleFinishExportNotification( int jobId, boolean successful)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      */
cancelAllRequestsAndShutdown()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      */
clearCache()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      */
getAppropriateDestination(final File destDirectory)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