VCardService.java revision 783a09a8770f4322a45cee456adefbbc71218ece
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.Notification;
21import android.app.NotificationManager;
22import android.app.PendingIntent;
23import android.app.Service;
24import android.content.Context;
25import android.content.Intent;
26import android.content.res.Resources;
27import android.net.Uri;
28import android.os.Handler;
29import android.os.IBinder;
30import android.os.Message;
31import android.os.Messenger;
32import android.os.RemoteException;
33import android.text.TextUtils;
34import android.util.Log;
35import android.widget.RemoteViews;
36import android.widget.Toast;
37
38import java.io.File;
39import java.util.HashMap;
40import java.util.HashSet;
41import java.util.Map;
42import java.util.Set;
43import java.util.concurrent.ExecutorService;
44import java.util.concurrent.Executors;
45import java.util.concurrent.RejectedExecutionException;
46
47/**
48 * The class responsible for handling vCard import/export requests.
49 *
50 * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push
51 * it to {@link ExecutorService} with single thread executor. The executor handles each request
52 * one by one, and notifies users when needed.
53 */
54// TODO: Using IntentService looks simpler than using Service + ServiceConnection though this
55// works fine enough. Investigate the feasibility.
56public class VCardService extends Service {
57    private final static String LOG_TAG = "VCardService";
58    /* package */ final static boolean DEBUG = true;
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 {@link 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    private final Messenger mMessenger = new Messenger(new Handler() {
76        @Override
77        public void handleMessage(Message msg) {
78            switch (msg.what) {
79                case MSG_IMPORT_REQUEST: {
80                    handleImportRequest((ImportRequest)msg.obj);
81                    break;
82                }
83                case MSG_EXPORT_REQUEST: {
84                    handleExportRequest((ExportRequest)msg.obj);
85                    break;
86                }
87                case MSG_CANCEL_REQUEST: {
88                    handleCancelRequest((CancelRequest)msg.obj);
89                    break;
90                }
91                case MSG_REQUEST_AVAILABLE_EXPORT_DESTINATION: {
92                    handleRequestAvailableExportDestination(msg);
93                    break;
94                }
95                // TODO: add cancel capability for export..
96                default: {
97                    Log.w(LOG_TAG, "Received unknown request, ignoring it.");
98                    super.hasMessages(msg.what);
99                }
100            }
101        }
102    });
103
104    private NotificationManager mNotificationManager;
105
106    // Should be single thread, as we don't want to simultaneously handle import and export
107    // requests.
108    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
109
110    private int mCurrentJobId;
111
112    // Stores all unfinished import/export jobs which will be executed by mExecutorService.
113    // Key is jobId.
114    private final Map<Integer, ProcessorBase> mRunningJobMap =
115            new HashMap<Integer, ProcessorBase>();
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    private String mTargetDirectory;
121    private String mFileNamePrefix;
122    private String mFileNameSuffix;
123    private int mFileIndexMinimum;
124    private int mFileIndexMaximum;
125    private String mFileNameExtension;
126    private Set<String> mExtensionsToConsider;
127    private String mErrorReason;
128
129    // File names currently reserved by some export job.
130    private final Set<String> mReservedDestination = new HashSet<String>();
131    /* ** end of vCard exporter params ** */
132
133    @Override
134    public void onCreate() {
135        super.onCreate();
136        if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created.");
137        mNotificationManager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
138        initExporterParams();
139    }
140
141    private void initExporterParams() {
142        mTargetDirectory = getString(R.string.config_export_dir);
143        mFileNamePrefix = getString(R.string.config_export_file_prefix);
144        mFileNameSuffix = getString(R.string.config_export_file_suffix);
145        mFileNameExtension = getString(R.string.config_export_file_extension);
146
147        mExtensionsToConsider = new HashSet<String>();
148        mExtensionsToConsider.add(mFileNameExtension);
149
150        final String additionalExtensions =
151            getString(R.string.config_export_extensions_to_consider);
152        if (!TextUtils.isEmpty(additionalExtensions)) {
153            for (String extension : additionalExtensions.split(",")) {
154                String trimed = extension.trim();
155                if (trimed.length() > 0) {
156                    mExtensionsToConsider.add(trimed);
157                }
158            }
159        }
160
161        final Resources resources = getResources();
162        mFileIndexMinimum = resources.getInteger(R.integer.config_export_file_min_index);
163        mFileIndexMaximum = resources.getInteger(R.integer.config_export_file_max_index);
164    }
165
166    @Override
167    public int onStartCommand(Intent intent, int flags, int id) {
168        return START_STICKY;
169    }
170
171    @Override
172    public IBinder onBind(Intent intent) {
173        return mMessenger.getBinder();
174    }
175
176    @Override
177    public void onDestroy() {
178        if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
179        cancelAllRequestsAndShutdown();
180        clearCache();
181        super.onDestroy();
182    }
183
184    private synchronized void handleImportRequest(ImportRequest request) {
185        if (DEBUG) {
186            Log.d(LOG_TAG,
187                    String.format("received import request (uri: %s, originalUri: %s)",
188                            request.uri, request.originalUri));
189        }
190        if (tryExecute(new ImportProcessor(this, request, mCurrentJobId))) {
191            final String displayName = request.originalUri.getLastPathSegment();
192            final String message = getString(R.string.vcard_import_will_start_message,
193                    displayName);
194            // TODO: Ideally we should detect the current status of import/export and show
195            // "started" when we can import right now and show "will start" when we cannot.
196            Toast.makeText(this, message, Toast.LENGTH_LONG).show();
197
198            final Notification notification =
199                    constructProgressNotification(
200                            this, TYPE_IMPORT, message, message, mCurrentJobId,
201                            displayName, -1, 0);
202            mNotificationManager.notify(mCurrentJobId, notification);
203            mCurrentJobId++;
204        } else {
205            // TODO: a little unkind to show Toast in this case, which is shown just a moment.
206            // Ideally we should show some persistent something users can notice more easily.
207            Toast.makeText(this, getString(R.string.vcard_import_request_rejected_message),
208                    Toast.LENGTH_LONG).show();
209        }
210    }
211
212    private synchronized void handleExportRequest(ExportRequest request) {
213        if (tryExecute(new ExportProcessor(this, request, mCurrentJobId))) {
214            final String displayName = request.destUri.getLastPathSegment();
215            final String message = getString(R.string.vcard_export_will_start_message,
216                    displayName);
217
218            final String path = request.destUri.getEncodedPath();
219            if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path);
220            if (!mReservedDestination.add(path)) {
221                Log.w(LOG_TAG,
222                        String.format("The path %s is already reserved. Reject export request",
223                                path));
224                Toast.makeText(this, getString(R.string.vcard_export_request_rejected_message),
225                        Toast.LENGTH_LONG).show();
226                return;
227            }
228
229            Toast.makeText(this, message, Toast.LENGTH_LONG).show();
230            final Notification notification =
231                    constructProgressNotification(this, TYPE_EXPORT, message, message,
232                            mCurrentJobId, displayName, -1, 0);
233            mNotificationManager.notify(mCurrentJobId, notification);
234            mCurrentJobId++;
235        } else {
236            Toast.makeText(this, getString(R.string.vcard_export_request_rejected_message),
237                    Toast.LENGTH_LONG).show();
238        }
239    }
240
241    /**
242     * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor.
243     * @return true when successful.
244     */
245    private synchronized boolean tryExecute(ProcessorBase processor) {
246        try {
247            mExecutorService.execute(processor);
248            mRunningJobMap.put(mCurrentJobId, processor);
249            return true;
250        } catch (RejectedExecutionException e) {
251            Log.w(LOG_TAG, "Failed to excetute a job.", e);
252            return false;
253        }
254    }
255
256    private synchronized void handleCancelRequest(CancelRequest request) {
257        final int jobId = request.jobId;
258        if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId));
259        final ProcessorBase processor = mRunningJobMap.remove(jobId);
260
261        if (processor != null) {
262            processor.cancel(true);
263            final String description = processor.getType() == TYPE_IMPORT ?
264                    getString(R.string.importing_vcard_canceled_title, request.displayName) :
265                            getString(R.string.exporting_vcard_canceled_title, request.displayName);
266            final Notification notification = constructCancelNotification(this, description);
267            mNotificationManager.notify(jobId, notification);
268            if (processor.getType() == TYPE_EXPORT) {
269                final String path =
270                        ((ExportProcessor)processor).getRequest().destUri.getEncodedPath();
271                Log.i(LOG_TAG,
272                        String.format("Cancel reservation for the path %s if appropriate", path));
273                if (!mReservedDestination.remove(path)) {
274                    Log.w(LOG_TAG, "Not reserved.");
275                }
276            }
277        } else {
278            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
279        }
280        stopServiceWhenNoJob();
281    }
282
283    private synchronized void handleRequestAvailableExportDestination(Message msg) {
284        if (DEBUG) Log.d(LOG_TAG, "Received available export destination request.");
285        final Messenger messenger = msg.replyTo;
286        final String path = getAppropriateDestination(mTargetDirectory);
287        final Message message;
288        if (path != null) {
289            message = Message.obtain(null,
290                    VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION, 0, 0, path);
291        } else {
292            message = Message.obtain(null,
293                    VCardService.MSG_SET_AVAILABLE_EXPORT_DESTINATION,
294                    R.id.dialog_fail_to_export_with_reason, 0, mErrorReason);
295        }
296        try {
297            messenger.send(message);
298        } catch (RemoteException e) {
299            Log.w(LOG_TAG, "Failed to send reply for available export destination request.", e);
300        }
301    }
302
303    /**
304     * Checks job list and call {@link #stopSelf()} when there's no job now.
305     * A new job cannot be submitted any more after this call.
306     */
307    private synchronized void stopServiceWhenNoJob() {
308        if (mRunningJobMap.size() > 0) {
309            for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) {
310                final int jobId = entry.getKey();
311                final ProcessorBase processor = entry.getValue();
312                if (processor.isDone()) {
313                    mRunningJobMap.remove(jobId);
314                } else {
315                    Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId));
316                    return;
317                }
318            }
319        }
320
321        Log.i(LOG_TAG, "No unfinished job. Stop this service.");
322        mExecutorService.shutdown();
323        stopSelf();
324    }
325
326    /* package */ synchronized void handleFinishImportNotification(
327            int jobId, boolean successful) {
328        if (DEBUG) {
329            Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). "
330                    + "Result: %b", jobId, (successful ? "success" : "failure")));
331        }
332        if (mRunningJobMap.remove(jobId) == null) {
333            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
334        }
335        stopServiceWhenNoJob();
336    }
337
338    /* package */ synchronized void handleFinishExportNotification(
339            int jobId, boolean successful) {
340        if (DEBUG) {
341            Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). "
342                    + "Result: %b", jobId, (successful ? "success" : "failure")));
343        }
344        final ProcessorBase job = mRunningJobMap.remove(jobId);
345        if (job == null) {
346            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
347        } else if (!(job instanceof ExportProcessor)) {
348            Log.w(LOG_TAG,
349                    String.format("Removed job (id: %s) isn't ExportProcessor", jobId));
350        } else {
351            final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath();
352            if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path);
353            mReservedDestination.remove(path);
354        }
355
356        stopServiceWhenNoJob();
357    }
358
359    /**
360     * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which
361     * means this Service becomes no longer ready for import/export requests.
362     *
363     * Mainly called from onDestroy().
364     */
365    private synchronized void cancelAllRequestsAndShutdown() {
366        for (final Map.Entry<Integer, ProcessorBase> entry : mRunningJobMap.entrySet()) {
367            entry.getValue().cancel(true);
368        }
369        mRunningJobMap.clear();
370        mExecutorService.shutdown();
371    }
372
373    /**
374     * Removes import caches stored locally.
375     */
376    private void clearCache() {
377        for (final String fileName : fileList()) {
378            if (fileName.startsWith(CACHE_FILE_PREFIX)) {
379                // We don't want to keep all the caches so we remove cache files old enough.
380                Log.i(LOG_TAG, "Remove a temporary file: " + fileName);
381                deleteFile(fileName);
382            }
383        }
384    }
385
386    /**
387     * Constructs a {@link Notification} showing the current status of import/export.
388     * Users can cancel the process with the Notification.
389     *
390     * @param context
391     * @param type import/export
392     * @param description Content of the Notification.
393     * @param tickerText
394     * @param jobId
395     * @param displayName Name to be shown to the Notification (e.g. "finished importing XXXX").
396     * Typycally a file name.
397     * @param totalCount The number of vCard entries to be imported. Used to show progress bar.
398     * -1 lets the system show the progress bar with "indeterminate" state.
399     * @param currentCount The index of current vCard. Used to show progress bar.
400     */
401    /* package */ static Notification constructProgressNotification(
402            Context context, int type, String description, String tickerText,
403            int jobId, String displayName, int totalCount, int currentCount) {
404        final RemoteViews remoteViews =
405                new RemoteViews(context.getPackageName(),
406                        R.layout.status_bar_ongoing_event_progress_bar);
407        remoteViews.setTextViewText(R.id.status_description, description);
408        remoteViews.setProgressBar(R.id.status_progress_bar, totalCount, currentCount,
409                totalCount == -1);
410        final String percentage;
411        if (totalCount > 0) {
412            percentage = context.getString(R.string.percentage,
413                    String.valueOf(currentCount * 100/totalCount));
414        } else {
415            percentage = "";
416        }
417        remoteViews.setTextViewText(R.id.status_progress_text, percentage);
418        final int icon = (type == TYPE_IMPORT ? android.R.drawable.stat_sys_download :
419                android.R.drawable.stat_sys_upload);
420        remoteViews.setImageViewResource(R.id.status_icon, icon);
421
422        final Notification notification = new Notification();
423        notification.icon = icon;
424        notification.tickerText = tickerText;
425        notification.contentView = remoteViews;
426        notification.flags |= Notification.FLAG_ONGOING_EVENT;
427
428        // Note: We cannot use extra values here (like setIntExtra()), as PendingIntent doesn't
429        // preserve them across multiple Notifications. PendingIntent preserves the first extras
430        // (when flag is not set), or update them when PendingIntent#getActivity() is called
431        // (See PendingIntent#FLAG_UPDATE_CURRENT). In either case, we cannot preserve extras as we
432        // expect (for each vCard import/export request).
433        //
434        // We use query parameter in Uri instead.
435        // Scheme and Authority is arbitorary, assuming CancelActivity never refers them.
436        final Intent intent = new Intent(context, CancelActivity.class);
437        final Uri uri = (new Uri.Builder())
438                .scheme("invalidscheme")
439                .authority("invalidauthority")
440                .appendQueryParameter(CancelActivity.JOB_ID, String.valueOf(jobId))
441                .appendQueryParameter(CancelActivity.DISPLAY_NAME, displayName)
442                .appendQueryParameter(CancelActivity.TYPE, String.valueOf(type)).build();
443        intent.setData(uri);
444
445        notification.contentIntent = PendingIntent.getActivity(context, 0, intent, 0);
446        return notification;
447    }
448
449    /**
450     * Constructs a Notification telling users the process is canceled.
451     *
452     * @param context
453     * @param description Content of the Notification
454     */
455    /* package */ static Notification constructCancelNotification(
456            Context context, String description) {
457        return new Notification.Builder(context)
458                .setAutoCancel(true)
459                .setSmallIcon(android.R.drawable.stat_notify_error)
460                .setContentTitle(description)
461                .setContentText(description)
462                .setContentIntent(PendingIntent.getActivity(context, 0, new Intent(), 0))
463                .getNotification();
464    }
465
466    /**
467     * Constructs a Notification telling users the process is finished.
468     *
469     * @param context
470     * @param description Content of the Notification
471     * @param intent Intent to be launched when the Notification is clicked. Can be null.
472     */
473    /* package */ static Notification constructFinishNotification(
474            Context context, String title, String description, Intent intent) {
475        return new Notification.Builder(context)
476                .setAutoCancel(true)
477                .setSmallIcon(android.R.drawable.stat_sys_download_done)
478                .setContentTitle(title)
479                .setContentText(description)
480                .setContentIntent(PendingIntent.getActivity(context, 0, intent, 0))
481                .getNotification();
482    }
483
484    /**
485     * Returns an appropriate file name for vCard export. Returns null when impossible.
486     *
487     * @return destination path for a vCard file to be exported. null on error and mErrorReason
488     * is correctly set.
489     */
490    private String getAppropriateDestination(final String destDirectory) {
491        /*
492         * Here, file names have 5 parts: directory, prefix, index, suffix, and extension.
493         * e.g. "/mnt/sdcard/prfx00001sfx.vcf" -> "/mnt/sdcard", "prfx", "00001", "sfx", and ".vcf"
494         *      (In default, prefix and suffix is empty, so usually the destination would be
495         *       /mnt/sdcard/00001.vcf.)
496         *
497         * This method increments "index" part from 1 to maximum, and checks whether any file name
498         * following naming rule is available. If there's no file named /mnt/sdcard/00001.vcf, the
499         * name will be returned to a caller. If there are 00001.vcf 00002.vcf, 00003.vcf is
500         * returned.
501         *
502         * There may not be any appropriate file name. If there are 99999 vCard files in the
503         * storage, for example, there's no appropriate name, so this method returns
504         * null.
505         */
506
507        // Count the number of digits of mFileIndexMaximum
508        // e.g. When mFileIndexMaximum is 99999, fileIndexDigit becomes 5, as we will count the
509        int fileIndexDigit = 0;
510        {
511            // Calling Math.Log10() is costly.
512            int tmp;
513            for (fileIndexDigit = 0, tmp = mFileIndexMaximum; tmp > 0;
514                fileIndexDigit++, tmp /= 10) {
515            }
516        }
517
518        // %s05d%s (e.g. "p00001s")
519        final String bodyFormat = "%s%0" + fileIndexDigit + "d%s";
520
521        if (!ALLOW_LONG_FILE_NAME) {
522            final String possibleBody =
523                    String.format(bodyFormat, mFileNamePrefix, 1, mFileNameSuffix);
524            if (possibleBody.length() > 8 || mFileNameExtension.length() > 3) {
525                Log.e(LOG_TAG, "This code does not allow any long file name.");
526                mErrorReason = getString(R.string.fail_reason_too_long_filename,
527                        String.format("%s.%s", possibleBody, mFileNameExtension));
528                Log.w(LOG_TAG, "File name becomes too long.");
529                return null;
530            }
531        }
532
533        for (int i = mFileIndexMinimum; i <= mFileIndexMaximum; i++) {
534            boolean numberIsAvailable = true;
535            String body = null;
536            for (String possibleExtension : mExtensionsToConsider) {
537                body = String.format(bodyFormat, mFileNamePrefix, i, mFileNameSuffix);
538                final String path =
539                        String.format("%s/%s.%s", destDirectory, body, possibleExtension);
540                synchronized (this) {
541                    if (mReservedDestination.contains(path)) {
542                        if (DEBUG) {
543                            Log.d(LOG_TAG, String.format("The path %s is reserved.", path));
544                        }
545                        numberIsAvailable = false;
546                        break;
547                    }
548                }
549                final File file = new File(path);
550                if (file.exists()) {
551                    numberIsAvailable = false;
552                    break;
553                }
554            }
555            if (numberIsAvailable) {
556                return String.format("%s/%s.%s", destDirectory, body, mFileNameExtension);
557            }
558        }
559
560        Log.w(LOG_TAG, "Reached vCard number limit. Maybe there are too many vCard in the storage");
561        mErrorReason = getString(R.string.fail_reason_too_many_vcard);
562        return null;
563    }
564}
565