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.Notification;
19import android.app.Service;
20import android.content.Intent;
21import android.media.MediaScannerConnection;
22import android.media.MediaScannerConnection.MediaScannerConnectionClient;
23import android.net.Uri;
24import android.os.Binder;
25import android.os.IBinder;
26import android.util.Log;
27import android.util.SparseArray;
28
29import java.util.ArrayList;
30import java.util.HashSet;
31import java.util.List;
32import java.util.Set;
33import java.util.concurrent.ExecutorService;
34import java.util.concurrent.Executors;
35import java.util.concurrent.RejectedExecutionException;
36
37/**
38 * The class responsible for handling vCard import/export requests.
39 *
40 * This Service creates one ImportRequest/ExportRequest object (as Runnable) per request and push
41 * it to {@link ExecutorService} with single thread executor. The executor handles each request
42 * one by one, and notifies users when needed.
43 */
44// TODO: Using IntentService looks simpler than using Service + ServiceConnection though this
45// works fine enough. Investigate the feasibility.
46public class VCardService extends Service {
47    private final static String LOG_TAG = "VCardService";
48
49    /* package */ final static boolean DEBUG = false;
50
51    /**
52     * Specifies the type of operation. Used when constructing a notification, canceling
53     * some operation, etc.
54     */
55    /* package */ static final int TYPE_IMPORT = 1;
56    /* package */ static final int TYPE_EXPORT = 2;
57
58    /* package */ static final String CACHE_FILE_PREFIX = "import_tmp_";
59
60    /* package */ static final String X_VCARD_MIME_TYPE = "text/x-vcard";
61
62    private class CustomMediaScannerConnectionClient implements MediaScannerConnectionClient {
63        final MediaScannerConnection mConnection;
64        final String mPath;
65
66        public CustomMediaScannerConnectionClient(String path) {
67            mConnection = new MediaScannerConnection(VCardService.this, this);
68            mPath = path;
69        }
70
71        public void start() {
72            mConnection.connect();
73        }
74
75        @Override
76        public void onMediaScannerConnected() {
77            if (DEBUG) { Log.d(LOG_TAG, "Connected to MediaScanner. Start scanning."); }
78            mConnection.scanFile(mPath, null);
79        }
80
81        @Override
82        public void onScanCompleted(String path, Uri uri) {
83            if (DEBUG) { Log.d(LOG_TAG, "scan completed: " + path); }
84            mConnection.disconnect();
85            removeConnectionClient(this);
86        }
87    }
88
89    // Should be single thread, as we don't want to simultaneously handle import and export
90    // requests.
91    private final ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
92
93    private int mCurrentJobId = 1;
94
95    // Stores all unfinished import/export jobs which will be executed by mExecutorService.
96    // Key is jobId.
97    private final SparseArray<ProcessorBase> mRunningJobMap = new SparseArray<ProcessorBase>();
98    // Stores ScannerConnectionClient objects until they finish scanning requested files.
99    // Uses List class for simplicity. It's not costly as we won't have multiple objects in
100    // almost all cases.
101    private final List<CustomMediaScannerConnectionClient> mRemainingScannerConnections =
102            new ArrayList<CustomMediaScannerConnectionClient>();
103
104    private MyBinder mBinder;
105
106    private String mCallingActivity;
107
108    // File names currently reserved by some export job.
109    private final Set<String> mReservedDestination = new HashSet<String>();
110    /* ** end of vCard exporter params ** */
111
112    public class MyBinder extends Binder {
113        public VCardService getService() {
114            return VCardService.this;
115        }
116    }
117
118   @Override
119    public void onCreate() {
120        super.onCreate();
121        mBinder = new MyBinder();
122        if (DEBUG) Log.d(LOG_TAG, "vCard Service is being created.");
123    }
124
125    @Override
126    public int onStartCommand(Intent intent, int flags, int id) {
127        if (intent != null && intent.getExtras() != null) {
128            mCallingActivity = intent.getExtras().getString(
129                    VCardCommonArguments.ARG_CALLING_ACTIVITY);
130        } else {
131            mCallingActivity = null;
132        }
133        return START_STICKY;
134    }
135
136    @Override
137    public IBinder onBind(Intent intent) {
138        return mBinder;
139    }
140
141    @Override
142    public void onDestroy() {
143        if (DEBUG) Log.d(LOG_TAG, "VCardService is being destroyed.");
144        cancelAllRequestsAndShutdown();
145        clearCache();
146        stopForeground(/* removeNotification */ false);
147        super.onDestroy();
148    }
149
150    public synchronized void handleImportRequest(List<ImportRequest> requests,
151            VCardImportExportListener listener) {
152        if (DEBUG) {
153            final ArrayList<String> uris = new ArrayList<String>();
154            final ArrayList<String> displayNames = new ArrayList<String>();
155            for (ImportRequest request : requests) {
156                uris.add(request.uri.toString());
157                displayNames.add(request.displayName);
158            }
159            Log.d(LOG_TAG,
160                    String.format("received multiple import request (uri: %s, displayName: %s)",
161                            uris.toString(), displayNames.toString()));
162        }
163        final int size = requests.size();
164        for (int i = 0; i < size; i++) {
165            ImportRequest request = requests.get(i);
166
167            if (tryExecute(new ImportProcessor(this, listener, request, mCurrentJobId))) {
168                if (listener != null) {
169                    final Notification notification =
170                            listener.onImportProcessed(request, mCurrentJobId, i);
171                    if (notification != null) {
172                        startForeground(mCurrentJobId, notification);
173                    }
174                }
175                mCurrentJobId++;
176            } else {
177                if (listener != null) {
178                    listener.onImportFailed(request);
179                }
180                // A rejection means executor doesn't run any more. Exit.
181                break;
182            }
183        }
184    }
185
186    public synchronized void handleExportRequest(ExportRequest request,
187            VCardImportExportListener listener) {
188        if (tryExecute(new ExportProcessor(this, request, mCurrentJobId, mCallingActivity))) {
189            final String path = request.destUri.getEncodedPath();
190            if (DEBUG) Log.d(LOG_TAG, "Reserve the path " + path);
191            if (!mReservedDestination.add(path)) {
192                Log.w(LOG_TAG,
193                        String.format("The path %s is already reserved. Reject export request",
194                                path));
195                if (listener != null) {
196                    listener.onExportFailed(request);
197                }
198                return;
199            }
200
201            if (listener != null) {
202                final Notification notification = listener.onExportProcessed(request,mCurrentJobId);
203                if (notification != null) {
204                    startForeground(mCurrentJobId, notification);
205                }
206            }
207            mCurrentJobId++;
208        } else {
209            if (listener != null) {
210                listener.onExportFailed(request);
211            }
212        }
213    }
214
215    /**
216     * Tries to call {@link ExecutorService#execute(Runnable)} toward a given processor.
217     * @return true when successful.
218     */
219    private synchronized boolean tryExecute(ProcessorBase processor) {
220        try {
221            if (DEBUG) {
222                Log.d(LOG_TAG, "Executor service status: shutdown: " + mExecutorService.isShutdown()
223                        + ", terminated: " + mExecutorService.isTerminated());
224            }
225            mExecutorService.execute(processor);
226            mRunningJobMap.put(mCurrentJobId, processor);
227            return true;
228        } catch (RejectedExecutionException e) {
229            Log.w(LOG_TAG, "Failed to excetute a job.", e);
230            return false;
231        }
232    }
233
234    public synchronized void handleCancelRequest(CancelRequest request,
235            VCardImportExportListener listener) {
236        final int jobId = request.jobId;
237        if (DEBUG) Log.d(LOG_TAG, String.format("Received cancel request. (id: %d)", jobId));
238
239        final ProcessorBase processor = mRunningJobMap.get(jobId);
240        mRunningJobMap.remove(jobId);
241
242        if (processor != null) {
243            processor.cancel(true);
244            final int type = processor.getType();
245            if (listener != null) {
246                listener.onCancelRequest(request, type);
247            }
248            if (type == TYPE_EXPORT) {
249                final String path =
250                        ((ExportProcessor)processor).getRequest().destUri.getEncodedPath();
251                Log.i(LOG_TAG,
252                        String.format("Cancel reservation for the path %s if appropriate", path));
253                if (!mReservedDestination.remove(path)) {
254                    Log.w(LOG_TAG, "Not reserved.");
255                }
256            }
257        } else {
258            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
259        }
260        stopServiceIfAppropriate();
261    }
262
263    /**
264     * Checks job list and call {@link #stopSelf()} when there's no job and no scanner connection
265     * is remaining.
266     * A new job (import/export) cannot be submitted any more after this call.
267     */
268    private synchronized void stopServiceIfAppropriate() {
269        if (mRunningJobMap.size() > 0) {
270            final int size = mRunningJobMap.size();
271
272            // Check if there are processors which aren't finished yet. If we still have ones to
273            // process, we cannot stop the service yet. Also clean up already finished processors
274            // here.
275
276            // Job-ids to be removed. At first all elements in the array are invalid and will
277            // be filled with real job-ids from the array's top. When we find a not-yet-finished
278            // processor, then we start removing those finished jobs. In that case latter half of
279            // this array will be invalid.
280            final int[] toBeRemoved = new int[size];
281            for (int i = 0; i < size; i++) {
282                final int jobId = mRunningJobMap.keyAt(i);
283                final ProcessorBase processor = mRunningJobMap.valueAt(i);
284                if (!processor.isDone()) {
285                    Log.i(LOG_TAG, String.format("Found unfinished job (id: %d)", jobId));
286
287                    // Remove processors which are already "done", all of which should be before
288                    // processors which aren't done yet.
289                    for (int j = 0; j < i; j++) {
290                        mRunningJobMap.remove(toBeRemoved[j]);
291                    }
292                    return;
293                }
294
295                // Remember the finished processor.
296                toBeRemoved[i] = jobId;
297            }
298
299            // We're sure we can remove all. Instead of removing one by one, just call clear().
300            mRunningJobMap.clear();
301        }
302
303        if (!mRemainingScannerConnections.isEmpty()) {
304            Log.i(LOG_TAG, "MediaScanner update is in progress.");
305            return;
306        }
307
308        Log.i(LOG_TAG, "No unfinished job. Stop this service.");
309        mExecutorService.shutdown();
310        stopSelf();
311    }
312
313    /* package */ synchronized void updateMediaScanner(String path) {
314        if (DEBUG) {
315            Log.d(LOG_TAG, "MediaScanner is being updated: " + path);
316        }
317
318        if (mExecutorService.isShutdown()) {
319            Log.w(LOG_TAG, "MediaScanner update is requested after executor's being shut down. " +
320                    "Ignoring the update request");
321            return;
322        }
323        final CustomMediaScannerConnectionClient client =
324                new CustomMediaScannerConnectionClient(path);
325        mRemainingScannerConnections.add(client);
326        client.start();
327    }
328
329    private synchronized void removeConnectionClient(
330            CustomMediaScannerConnectionClient client) {
331        if (DEBUG) {
332            Log.d(LOG_TAG, "Removing custom MediaScannerConnectionClient.");
333        }
334        mRemainingScannerConnections.remove(client);
335        stopServiceIfAppropriate();
336    }
337
338    /* package */ synchronized void handleFinishImportNotification(
339            int jobId, boolean successful) {
340        if (DEBUG) {
341            Log.d(LOG_TAG, String.format("Received vCard import finish notification (id: %d). "
342                    + "Result: %b", jobId, (successful ? "success" : "failure")));
343        }
344        mRunningJobMap.remove(jobId);
345        stopServiceIfAppropriate();
346    }
347
348    /* package */ synchronized void handleFinishExportNotification(
349            int jobId, boolean successful) {
350        if (DEBUG) {
351            Log.d(LOG_TAG, String.format("Received vCard export finish notification (id: %d). "
352                    + "Result: %b", jobId, (successful ? "success" : "failure")));
353        }
354        final ProcessorBase job = mRunningJobMap.get(jobId);
355        mRunningJobMap.remove(jobId);
356        if (job == null) {
357            Log.w(LOG_TAG, String.format("Tried to remove unknown job (id: %d)", jobId));
358        } else if (!(job instanceof ExportProcessor)) {
359            Log.w(LOG_TAG,
360                    String.format("Removed job (id: %s) isn't ExportProcessor", jobId));
361        } else {
362            final String path = ((ExportProcessor)job).getRequest().destUri.getEncodedPath();
363            if (DEBUG) Log.d(LOG_TAG, "Remove reserved path " + path);
364            mReservedDestination.remove(path);
365        }
366
367        stopServiceIfAppropriate();
368    }
369
370    /**
371     * Cancels all the import/export requests and calls {@link ExecutorService#shutdown()}, which
372     * means this Service becomes no longer ready for import/export requests.
373     *
374     * Mainly called from onDestroy().
375     */
376    private synchronized void cancelAllRequestsAndShutdown() {
377        for (int i = 0; i < mRunningJobMap.size(); i++) {
378            mRunningJobMap.valueAt(i).cancel(true);
379        }
380        mRunningJobMap.clear();
381        mExecutorService.shutdown();
382    }
383
384    /**
385     * Removes import caches stored locally.
386     */
387    private void clearCache() {
388        for (final String fileName : fileList()) {
389            if (fileName.startsWith(CACHE_FILE_PREFIX)) {
390                // We don't want to keep all the caches so we remove cache files old enough.
391                Log.i(LOG_TAG, "Remove a temporary file: " + fileName);
392                deleteFile(fileName);
393            }
394        }
395    }
396}
397