1 /*
2  * Copyright (C) 2015 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  */
16 
17 package com.android.mtp;
18 
19 import android.annotation.Nullable;
20 import android.annotation.WorkerThread;
21 import android.content.ContentResolver;
22 import android.database.Cursor;
23 import android.mtp.MtpConstants;
24 import android.mtp.MtpObjectInfo;
25 import android.net.Uri;
26 import android.os.Bundle;
27 import android.os.Process;
28 import android.provider.DocumentsContract;
29 import android.util.Log;
30 
31 import com.android.internal.util.Preconditions;
32 
33 import java.io.FileNotFoundException;
34 import java.io.IOException;
35 import java.util.ArrayList;
36 import java.util.Date;
37 import java.util.LinkedList;
38 
39 /**
40  * Loader for MTP document.
41  * At the first request, the loader returns only first NUM_INITIAL_ENTRIES. Then it launches
42  * background thread to load the rest documents and caches its result for next requests.
43  * TODO: Rename this class to ObjectInfoLoader
44  */
45 class DocumentLoader implements AutoCloseable {
46     static final int NUM_INITIAL_ENTRIES = 10;
47     static final int NUM_LOADING_ENTRIES = 20;
48     static final int NOTIFY_PERIOD_MS = 500;
49 
50     private final MtpDeviceRecord mDevice;
51     private final MtpManager mMtpManager;
52     private final ContentResolver mResolver;
53     private final MtpDatabase mDatabase;
54     private final TaskList mTaskList = new TaskList();
55     private Thread mBackgroundThread;
56 
DocumentLoader(MtpDeviceRecord device, MtpManager mtpManager, ContentResolver resolver, MtpDatabase database)57     DocumentLoader(MtpDeviceRecord device, MtpManager mtpManager, ContentResolver resolver,
58                    MtpDatabase database) {
59         mDevice = device;
60         mMtpManager = mtpManager;
61         mResolver = resolver;
62         mDatabase = database;
63     }
64 
65     /**
66      * Queries the child documents of given parent.
67      * It loads the first NUM_INITIAL_ENTRIES of object info, then launches the background thread
68      * to load the rest.
69      */
queryChildDocuments(String[] columnNames, Identifier parent)70     synchronized Cursor queryChildDocuments(String[] columnNames, Identifier parent)
71             throws IOException {
72         assert parent.mDeviceId == mDevice.deviceId;
73 
74         LoaderTask task = mTaskList.findTask(parent);
75         if (task == null) {
76             if (parent.mDocumentId == null) {
77                 throw new FileNotFoundException("Parent not found.");
78             }
79             // TODO: Handle nit race around here.
80             // 1. getObjectHandles.
81             // 2. putNewDocument.
82             // 3. startAddingChildDocuemnts.
83             // 4. stopAddingChildDocuments - It removes the new document added at the step 2,
84             //     because it is not updated between start/stopAddingChildDocuments.
85             task = new LoaderTask(mMtpManager, mDatabase, mDevice.operationsSupported, parent);
86             task.loadObjectHandles();
87             task.loadObjectInfoList(NUM_INITIAL_ENTRIES);
88         } else {
89             // Once remove the existing task in order to add it to the head of the list.
90             mTaskList.remove(task);
91         }
92 
93         mTaskList.addFirst(task);
94         if (task.getState() == LoaderTask.STATE_LOADING) {
95             resume();
96         }
97         return task.createCursor(mResolver, columnNames);
98     }
99 
100     /**
101      * Resumes a background thread.
102      */
resume()103     synchronized void resume() {
104         if (mBackgroundThread == null) {
105             mBackgroundThread = new BackgroundLoaderThread();
106             mBackgroundThread.start();
107         }
108     }
109 
110     /**
111      * Obtains next task to be run in background thread, or release the reference to background
112      * thread.
113      *
114      * Worker thread that receives null task needs to exit.
115      */
116     @WorkerThread
getNextTaskOrReleaseBackgroundThread()117     synchronized @Nullable LoaderTask getNextTaskOrReleaseBackgroundThread() {
118         Preconditions.checkState(mBackgroundThread != null);
119 
120         for (final LoaderTask task : mTaskList) {
121             if (task.getState() == LoaderTask.STATE_LOADING) {
122                 return task;
123             }
124         }
125 
126         final Identifier identifier = mDatabase.getUnmappedDocumentsParent(mDevice.deviceId);
127         if (identifier != null) {
128             final LoaderTask existingTask = mTaskList.findTask(identifier);
129             if (existingTask != null) {
130                 Preconditions.checkState(existingTask.getState() != LoaderTask.STATE_LOADING);
131                 mTaskList.remove(existingTask);
132             }
133             final LoaderTask newTask = new LoaderTask(
134                     mMtpManager, mDatabase, mDevice.operationsSupported, identifier);
135             newTask.loadObjectHandles();
136             mTaskList.addFirst(newTask);
137             return newTask;
138         }
139 
140         mBackgroundThread = null;
141         return null;
142     }
143 
144     /**
145      * Terminates background thread.
146      */
147     @Override
close()148     public void close() throws InterruptedException {
149         final Thread thread;
150         synchronized (this) {
151             mTaskList.clear();
152             thread = mBackgroundThread;
153         }
154         if (thread != null) {
155             thread.interrupt();
156             thread.join();
157         }
158     }
159 
clearCompletedTasks()160     synchronized void clearCompletedTasks() {
161         mTaskList.clearCompletedTasks();
162     }
163 
164     /**
165      * Cancels the task for |parentIdentifier|.
166      *
167      * Task is removed from the cached list and it will create new task when |parentIdentifier|'s
168      * children are queried next.
169      */
cancelTask(Identifier parentIdentifier)170     void cancelTask(Identifier parentIdentifier) {
171         final LoaderTask task;
172         synchronized (this) {
173             task = mTaskList.findTask(parentIdentifier);
174         }
175         if (task != null) {
176             task.cancel();
177             mTaskList.remove(task);
178         }
179     }
180 
181     /**
182      * Background thread to fetch object info.
183      */
184     private class BackgroundLoaderThread extends Thread {
185         /**
186          * Finds task that needs to be processed, then loads NUM_LOADING_ENTRIES of object info and
187          * store them to the database. If it does not find a task, exits the thread.
188          */
189         @Override
run()190         public void run() {
191             Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
192             while (!Thread.interrupted()) {
193                 final LoaderTask task = getNextTaskOrReleaseBackgroundThread();
194                 if (task == null) {
195                     return;
196                 }
197                 task.loadObjectInfoList(NUM_LOADING_ENTRIES);
198                 final boolean shouldNotify =
199                         task.getState() != LoaderTask.STATE_CANCELLED &&
200                         (task.mLastNotified.getTime() <
201                          new Date().getTime() - NOTIFY_PERIOD_MS ||
202                          task.getState() != LoaderTask.STATE_LOADING);
203                 if (shouldNotify) {
204                     task.notify(mResolver);
205                 }
206             }
207         }
208     }
209 
210     /**
211      * Task list that has helper methods to search/clear tasks.
212      */
213     private static class TaskList extends LinkedList<LoaderTask> {
findTask(Identifier parent)214         LoaderTask findTask(Identifier parent) {
215             for (int i = 0; i < size(); i++) {
216                 if (get(i).mIdentifier.equals(parent))
217                     return get(i);
218             }
219             return null;
220         }
221 
clearCompletedTasks()222         void clearCompletedTasks() {
223             int i = 0;
224             while (i < size()) {
225                 if (get(i).getState() == LoaderTask.STATE_COMPLETED) {
226                     remove(i);
227                 } else {
228                     i++;
229                 }
230             }
231         }
232     }
233 
234     /**
235      * Loader task.
236      * Each task is responsible for fetching child documents for the given parent document.
237      */
238     private static class LoaderTask {
239         static final int STATE_START = 0;
240         static final int STATE_LOADING = 1;
241         static final int STATE_COMPLETED = 2;
242         static final int STATE_ERROR = 3;
243         static final int STATE_CANCELLED = 4;
244 
245         final MtpManager mManager;
246         final MtpDatabase mDatabase;
247         final int[] mOperationsSupported;
248         final Identifier mIdentifier;
249         int[] mObjectHandles;
250         int mState;
251         Date mLastNotified;
252         int mPosition;
253         IOException mError;
254 
LoaderTask(MtpManager manager, MtpDatabase database, int[] operationsSupported, Identifier identifier)255         LoaderTask(MtpManager manager, MtpDatabase database, int[] operationsSupported,
256                 Identifier identifier) {
257             assert operationsSupported != null;
258             assert identifier.mDocumentType != MtpDatabaseConstants.DOCUMENT_TYPE_DEVICE;
259             mManager = manager;
260             mDatabase = database;
261             mOperationsSupported = operationsSupported;
262             mIdentifier = identifier;
263             mObjectHandles = null;
264             mState = STATE_START;
265             mPosition = 0;
266             mLastNotified = new Date();
267         }
268 
loadObjectHandles()269         synchronized void loadObjectHandles() {
270             assert mState == STATE_START;
271             mPosition = 0;
272             int parentHandle = mIdentifier.mObjectHandle;
273             // Need to pass the special value MtpManager.OBJECT_HANDLE_ROOT_CHILDREN to
274             // getObjectHandles if we would like to obtain children under the root.
275             if (mIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_STORAGE) {
276                 parentHandle = MtpManager.OBJECT_HANDLE_ROOT_CHILDREN;
277             }
278             try {
279                 mObjectHandles = mManager.getObjectHandles(
280                         mIdentifier.mDeviceId, mIdentifier.mStorageId, parentHandle);
281                 mState = STATE_LOADING;
282             } catch (IOException error) {
283                 mError = error;
284                 mState = STATE_ERROR;
285             }
286         }
287 
288         /**
289          * Returns a cursor that traverses the child document of the parent document handled by the
290          * task.
291          * The returned task may have a EXTRA_LOADING flag.
292          */
createCursor(ContentResolver resolver, String[] columnNames)293         synchronized Cursor createCursor(ContentResolver resolver, String[] columnNames)
294                 throws IOException {
295             final Bundle extras = new Bundle();
296             switch (getState()) {
297                 case STATE_LOADING:
298                     extras.putBoolean(DocumentsContract.EXTRA_LOADING, true);
299                     break;
300                 case STATE_ERROR:
301                     throw mError;
302             }
303             final Cursor cursor =
304                     mDatabase.queryChildDocuments(columnNames, mIdentifier.mDocumentId);
305             cursor.setExtras(extras);
306             cursor.setNotificationUri(resolver, createUri());
307             return cursor;
308         }
309 
310         /**
311          * Stores object information into database.
312          */
loadObjectInfoList(int count)313         void loadObjectInfoList(int count) {
314             synchronized (this) {
315                 if (mState != STATE_LOADING) {
316                     return;
317                 }
318                 if (mPosition == 0) {
319                     try{
320                         mDatabase.getMapper().startAddingDocuments(mIdentifier.mDocumentId);
321                     } catch (FileNotFoundException error) {
322                         mError = error;
323                         mState = STATE_ERROR;
324                         return;
325                     }
326                 }
327             }
328             final ArrayList<MtpObjectInfo> infoList = new ArrayList<>();
329             for (int chunkEnd = mPosition + count;
330                     mPosition < mObjectHandles.length && mPosition < chunkEnd;
331                     mPosition++) {
332                 try {
333                     infoList.add(mManager.getObjectInfo(
334                             mIdentifier.mDeviceId, mObjectHandles[mPosition]));
335                 } catch (IOException error) {
336                     Log.e(MtpDocumentsProvider.TAG, "Failed to load object info", error);
337                 }
338             }
339             final long[] objectSizeList = new long[infoList.size()];
340             for (int i = 0; i < infoList.size(); i++) {
341                 final MtpObjectInfo info = infoList.get(i);
342                 // Compressed size is 32-bit unsigned integer but getCompressedSize returns the
343                 // value in Java int (signed 32-bit integer). Use getCompressedSizeLong instead
344                 // to get the value in Java long.
345                 if (info.getCompressedSizeLong() != 0xffffffffl) {
346                     objectSizeList[i] = info.getCompressedSizeLong();
347                     continue;
348                 }
349 
350                 if (!MtpDeviceRecord.isSupported(
351                         mOperationsSupported,
352                         MtpConstants.OPERATION_GET_OBJECT_PROP_DESC) ||
353                         !MtpDeviceRecord.isSupported(
354                                 mOperationsSupported,
355                                 MtpConstants.OPERATION_GET_OBJECT_PROP_VALUE)) {
356                     objectSizeList[i] = -1;
357                     continue;
358                 }
359 
360                 // Object size is more than 4GB.
361                 try {
362                     objectSizeList[i] = mManager.getObjectSizeLong(
363                             mIdentifier.mDeviceId,
364                             info.getObjectHandle(),
365                             info.getFormat());
366                 } catch (IOException error) {
367                     Log.e(MtpDocumentsProvider.TAG, "Failed to get object size property.", error);
368                     objectSizeList[i] = -1;
369                 }
370             }
371             synchronized (this) {
372                 // Check if the task is cancelled or not.
373                 if (mState != STATE_LOADING) {
374                     return;
375                 }
376                 try {
377                     mDatabase.getMapper().putChildDocuments(
378                             mIdentifier.mDeviceId,
379                             mIdentifier.mDocumentId,
380                             mOperationsSupported,
381                             infoList.toArray(new MtpObjectInfo[infoList.size()]),
382                             objectSizeList);
383                 } catch (FileNotFoundException error) {
384                     // Looks like the parent document information is removed.
385                     // Adding documents has already cancelled in Mapper so we don't need to invoke
386                     // stopAddingDocuments.
387                     mError = error;
388                     mState = STATE_ERROR;
389                     return;
390                 }
391                 if (mPosition >= mObjectHandles.length) {
392                     try{
393                         mDatabase.getMapper().stopAddingDocuments(mIdentifier.mDocumentId);
394                         mState = STATE_COMPLETED;
395                     } catch (FileNotFoundException error) {
396                         mError = error;
397                         mState = STATE_ERROR;
398                         return;
399                     }
400                 }
401             }
402         }
403 
404         /**
405          * Cancels the task.
406          */
cancel()407         synchronized void cancel() {
408             mDatabase.getMapper().cancelAddingDocuments(mIdentifier.mDocumentId);
409             mState = STATE_CANCELLED;
410         }
411 
412         /**
413          * Returns a state of the task.
414          */
getState()415         int getState() {
416             return mState;
417         }
418 
419         /**
420          * Notifies a change of child list of the document.
421          */
notify(ContentResolver resolver)422         void notify(ContentResolver resolver) {
423             resolver.notifyChange(createUri(), null, false);
424             mLastNotified = new Date();
425         }
426 
createUri()427         private Uri createUri() {
428             return DocumentsContract.buildChildDocumentsUri(
429                     MtpDocumentsProvider.AUTHORITY, mIdentifier.mDocumentId);
430         }
431     }
432 }
433