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