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 */
16package com.android.contacts.common.vcard;
17
18import android.app.Service;
19import android.content.Intent;
20import android.content.res.Resources;
21import android.media.MediaScannerConnection;
22import android.media.MediaScannerConnection.MediaScannerConnectionClient;
23import android.net.Uri;
24import android.os.Binder;
25import android.os.Environment;
26import android.os.IBinder;
27import android.os.Message;
28import android.os.Messenger;
29import android.os.RemoteException;
30import android.text.TextUtils;
31import android.util.Log;
32import android.util.SparseArray;
33
34import com.android.contacts.common.R;
35
36import java.io.File;
37import java.util.ArrayList;
38import java.util.HashSet;
39import java.util.List;
40import java.util.Set;
41import java.util.concurrent.ExecutorService;
42import java.util.concurrent.Executors;
43import 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.
54public 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
79        public CustomMediaScannerConnectionClient(String path) {
80            mConnection = new MediaScannerConnection(VCardService.this, this);
81            mPath = path;
82        }
83
84        public void start() {
85            mConnection.connect();
86        }
87
88        @Override
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
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 {
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 = 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
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
188    public IBinder onBind(Intent intent) {
189        return mBinder;
190    }
191
192    @Override
193    public void onDestroy() {
194        if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
195        cancelAllRequestsAndShutdown();
196        clearCache();
197        super.onDestroy();
198    }
199
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
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     */
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
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
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     */
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
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
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
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
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     */
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     */
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     */
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