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.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.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    // File names currently reserved by some export job.
132    private final Set<String> mReservedDestination = new HashSet<String>();
133    /* ** end of vCard exporter params ** */
134
135    public class MyBinder extends Binder {
136        public VCardService getService() {
137            return VCardService.this;
138        }
139    }
140
141   @Override
142    public void onCreate() {
143        super.onCreate();
144        mBinder = new MyBinder();
145        if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created.");
146        initExporterParams();
147    }
148
149    private void initExporterParams() {
150        mTargetDirectory = Environment.getExternalStorageDirectory();
151        mFileNamePrefix = getString(R.string.config_export_file_prefix);
152        mFileNameSuffix = getString(R.string.config_export_file_suffix);
153        mFileNameExtension = getString(R.string.config_export_file_extension);
154
155        mExtensionsToConsider = new HashSet<String>();
156        mExtensionsToConsider.add(mFileNameExtension);
157
158        final String additionalExtensions =
159            getString(R.string.config_export_extensions_to_consider);
160        if (!TextUtils.isEmpty(additionalExtensions)) {
161            for (String extension : additionalExtensions.split(",")) {
162                String trimed = extension.trim();
163                if (trimed.length() > 0) {
164                    mExtensionsToConsider.add(trimed);
165                }
166            }
167        }
168
169        final Resources resources = getResources();
170        mFileIndexMinimum = resources.getInteger(R.integer.config_export_file_min_index);
171        mFileIndexMaximum = resources.getInteger(R.integer.config_export_file_max_index);
172    }
173
174    @Override
175    public int onStartCommand(Intent intent, int flags, int id) {
176        return START_STICKY;
177    }
178
179    @Override
180    public IBinder onBind(Intent intent) {
181        return mBinder;
182    }
183
184    @Override
185    public void onDestroy() {
186        if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
187        cancelAllRequestsAndShutdown();
188        clearCache();
189        super.onDestroy();
190    }
191
192    public synchronized void handleImportRequest(List<ImportRequest> requests,
193            VCardImportExportListener listener) {
194        if (DEBUG) {
195            final ArrayList<String> uris = new ArrayList<String>();
196            final ArrayList<String> displayNames = new ArrayList<String>();
197            for (ImportRequest request : requests) {
198                uris.add(request.uri.toString());
199                displayNames.add(request.displayName);
200            }
201            Log.d(LOG_TAG,
202                    String.format("received multiple import request (uri: %s, displayName: %s)",
203                            uris.toString(), displayNames.toString()));
204        }
205        final int size = requests.size();
206        for (int i = 0; i < size; i++) {
207            ImportRequest request = requests.get(i);
208
209            if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) {
210                if (listener != null) {
211                    listener.onImportProcessed(request, mCurrentJobId, i);
212                }
213                mCurrentJobId++;
214            } else {
215                if (listener != null) {
216                    listener.onImportFailed(request);
217                }
218                // A rejection means executor doesn't run any more. Exit.
219                break;
220            }
221        }
222    }
223
224    public synchronized void handleExportRequest(ExportRequest request,
225            VCardImportExportListener listener) {
226        if (tryExecute(new ExportProcessor(this, request, mCurrentJobId))) {
227            final String path = request.destUri.getEncodedPath();
228            if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path);
229            if (!mReservedDestination.add(path)) {
230                Log.w(LOG_TAG,
231                        String.format("The path %s is already reserved. Reject export request",
232                                path));
233                if (listener != null) {
234                    listener.onExportFailed(request);
235                }
236                return;
237            }
238
239            if (listener != null) {
240                listener.onExportProcessed(request, mCurrentJobId);
241            }
242            mCurrentJobId++;
243        } else {
244            if (listener != null) {
245                listener.onExportFailed(request);
246            }
247        }
248    }
249
250    /**
251     * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor.
252     * @return true when successful.
253     */
254    private synchronized boolean tryExecute(ProcessorBase processor) {
255        try {
256            if (DEBUG) {
257                Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown()
258                        + ", terminated: " + mExecutorService.isTerminated());
259            }
260            mExecutorService.execute(processor);
261            mRunningJobMap.put(mCurrentJobId, processor);
262            return true;
263        } catch (RejectedExecutionException e) {
264            Log.w(LOG_TAG, "Failed to excetute a job.", e);
265            return false;
266        }
267    }
268
269    public synchronized void handleCancelRequest(CancelRequest request,
270            VCardImportExportListener listener) {
271        final int jobId = request.jobId;
272        if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId));
273
274        final ProcessorBase processor = mRunningJobMap.get(jobId);
275        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            final int size = mRunningJobMap.size();
325
326            // Check if there are processors which aren't finished yet. If we still have ones to
327            // process, we cannot stop the service yet. Also clean up already finished processors
328            // here.
329
330            // Job-ids to be removed. At first all elements in the array are invalid and will
331            // be filled with real job-ids from the array's top. When we find a not-yet-finished
332            // processor, then we start removing those finished jobs. In that case latter half of
333            // this array will be invalid.
334            final int[] toBeRemoved = new int[size];
335            for (int i = 0; i < size; i++) {
336                final int jobId = mRunningJobMap.keyAt(i);
337                final ProcessorBase processor = mRunningJobMap.valueAt(i);
338                if (!processor.isDone()) {
339                    Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId));
340
341                    // Remove processors which are already "done", all of which should be before
342                    // processors which aren't done yet.
343                    for (int j = 0; j < i; j++) {
344                        mRunningJobMap.remove(toBeRemoved[j]);
345                    }
346                    return;
347                }
348
349                // Remember the finished processor.
350                toBeRemoved[i] = jobId;
351            }
352
353            // We're sure we can remove all. Instead of removing one by one, just call clear().
354            mRunningJobMap.clear();
355        }
356
357        if (!mRemainingScannerConnections.isEmpty()) {
358            Log.i(LOG_TAG, "MediaScanner update is in progress.");
359            return;
360        }
361
362        Log.i(LOG_TAG, "No unfinished job. Stop this service.");
363        mExecutorService.shutdown();
364        stopSelf();
365    }
366
367    /* package */ synchronized void updateMediaScanner(String path) {
368        if (DEBUG) {
369            Log.d(LOG_TAG, "MediaScanner is being updated: " + path);
370        }
371
372        if (mExecutorService.isShutdown()) {
373            Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " +
374                    "Ignoring the update request");
375            return;
376        }
377        final CustomMediaScannerConnectionClient client =
378                new CustomMediaScannerConnectionClient(path);
379        mRemainingScannerConnections.add(client);
380        client.start();
381    }
382
383    private synchronized void removeConnectionClient(
384            CustomMediaScannerConnectionClient client) {
385        if (DEBUG) {
386            Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient.");
387        }
388        mRemainingScannerConnections.remove(client);
389        stopServiceIfAppropriate();
390    }
391
392    /* package */ synchronized void handleFinishImportNotification(
393            int jobId, boolean successful) {
394        if (DEBUG) {
395            Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). "
396                    + "Result: %b", jobId, (successful ? "success" : "failure")));
397        }
398        mRunningJobMap.remove(jobId);
399        stopServiceIfAppropriate();
400    }
401
402    /* package */ synchronized void handleFinishExportNotification(
403            int jobId, boolean successful) {
404        if (DEBUG) {
405            Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). "
406                    + "Result: %b", jobId, (successful ? "success" : "failure")));
407        }
408        final ProcessorBase job = mRunningJobMap.get(jobId);
409        mRunningJobMap.remove(jobId);
410        if (job == null) {
411            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
412        } else if (!(job instanceof ExportProcessor)) {
413            Log.w(LOG_TAG,
414                    String.format("Removed job (id: %s) isn't ExportProcessor", jobId));
415        } else {
416            final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath();
417            if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path);
418            mReservedDestination.remove(path);
419        }
420
421        stopServiceIfAppropriate();
422    }
423
424    /**
425     * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which
426     * means this Service becomes no longer ready for import/export requests.
427     *
428     * Mainly called from onDestroy().
429     */
430    private synchronized void cancelAllRequestsAndShutdown() {
431        for (int i = 0; i < mRunningJobMap.size(); i++) {
432            mRunningJobMap.valueAt(i).cancel(true);
433        }
434        mRunningJobMap.clear();
435        mExecutorService.shutdown();
436    }
437
438    /**
439     * Removes import caches stored locally.
440     */
441    private void clearCache() {
442        for (final String fileName : fileList()) {
443            if (fileName.startsWith(CACHE_FILE_PREFIX)) {
444                // We don't want to keep all the caches so we remove cache files old enough.
445                Log.i(LOG_TAG, "Remove a temporary file: " + fileName);
446                deleteFile(fileName);
447            }
448        }
449    }
450
451    /**
452     * Returns an appropriate file name for vCard export. Returns null when impossible.
453     *
454     * @return destination path for a vCard file to be exported. null on error and mErrorReason
455     * is correctly set.
456     */
457    private String getAppropriateDestination(final File destDirectory) {
458        /*
459         * Here, file names have 5 parts: directory, prefix, index, suffix, and extension.
460         * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf"
461         *      (In default, prefix and suffix is empty, so usually the destination would be
462         *       /mnt/sdcard/00001.vcf.)
463         *
464         * This method increments "index" part from 1 to maximum, and checks whether any file name
465         * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the
466         * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is
467         * returned.
468         *
469         * There may not be any appropriate file name. If there are 99999 vCard files in the
470         * storage, for example, there's no appropriate name, so this method returns
471         * null.
472         */
473
474        // Count the number of digits of mFileIndexMaximum
475        // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the
476        int fileIndexDigit = 0;
477        {
478            // Calling Math.Log10() is costly.
479            int tmp;
480            for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0;
481                fileIndexDigit++, tmp /= 10) {
482            }
483        }
484
485        // %s05d%s (e.g. "p00001s")
486        final String bodyFormat = "%s%0" + fileIndexDigit + "d%s";
487
488        if (!ALLOW_LONG_FILE_NAME) {
489            final String possibleBody =
490                    String.format(bodyFormat, mFileNamePrefix, 1, mFileNameSuffix);
491            if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) {
492                Log.e(LOG_TAG, "This code does not allow any long file name.");
493                mErrorReason = getString(R.string.fail_reason_too_long_filename,
494                        String.format("%s.%s", possibleBody, mFileNameExtension));
495                Log.w(LOG_TAG, "File name becomes too long.");
496                return null;
497            }
498        }
499
500        for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) {
501            boolean numberIsAvailable = true;
502            final String body = String.format(bodyFormat, mFileNamePrefix, i, mFileNameSuffix);
503            // Make sure that none of the extensions of mExtensionsToConsider matches. If this
504            // number is free, we'll go ahead with mFileNameExtension (which is included in
505            // mExtensionsToConsider)
506            for (String possibleExtension : mExtensionsToConsider) {
507                final File file = new File(destDirectory, body + "." + possibleExtension);
508                final String path = file.getAbsolutePath();
509                synchronized (this) {
510                    // Is this being exported right now? Skip this number
511                    if (mReservedDestination.contains(path)) {
512                        if (DEBUG) {
513                            Log.d(LOG_TAG, String.format("%s is already being exported.", path));
514                        }
515                        numberIsAvailable = false;
516                        break;
517                    }
518                }
519                if (file.exists()) {
520                    numberIsAvailable = false;
521                    break;
522                }
523            }
524            if (numberIsAvailable) {
525                return new File(destDirectory, body + "." + mFileNameExtension).getAbsolutePath();
526            }
527        }
528
529        Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage");
530        mErrorReason = getString(R.string.fail_reason_too_many_vcard);
531        return null;
532    }
533}
534