/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.mtp;
import android.annotation.Nullable;
import android.annotation.WorkerThread;
import android.content.ContentResolver;
import android.database.Cursor;
import android.mtp.MtpConstants;
import android.mtp.MtpObjectInfo;
import android.net.Uri;
import android.os.Bundle;
import android.os.Process;
import android.provider.DocumentsContract;
import android.util.Log;
import com.android.internal.util.Preconditions;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedList;
/**
* Loader for MTP document.
* At the first request, the loader returns only first NUM_INITIAL_ENTRIES. Then it launches
* background thread to load the rest documents and caches its result for next requests.
* TODO: Rename this class to ObjectInfoLoader
*/
class DocumentLoader implements AutoCloseable {
static final int NUM_INITIAL_ENTRIES = 10;
static final int NUM_LOADING_ENTRIES = 20;
static final int NOTIFY_PERIOD_MS = 500;
private final MtpDeviceRecord mDevice;
private final MtpManager mMtpManager;
private final ContentResolver mResolver;
private final MtpDatabase mDatabase;
private final TaskList mTaskList = new TaskList();
private Thread mBackgroundThread;
DocumentLoader(MtpDeviceRecord device, MtpManager mtpManager, ContentResolver resolver,
MtpDatabase database) {
mDevice = device;
mMtpManager = mtpManager;
mResolver = resolver;
mDatabase = database;
}
/**
* Queries the child documents of given parent.
* It loads the first NUM_INITIAL_ENTRIES of object info, then launches the background thread
* to load the rest.
*/
synchronized Cursor queryChildDocuments(String[] columnNames, Identifier parent)
throws IOException {
assert parent.mDeviceId == mDevice.deviceId;
LoaderTask task = mTaskList.findTask(parent);
if (task == null) {
if (parent.mDocumentId == null) {
throw new FileNotFoundException("Parent not found.");
}
// TODO: Handle nit race around here.
// 1. getObjectHandles.
// 2. putNewDocument.
// 3. startAddingChildDocuemnts.
// 4. stopAddingChildDocuments - It removes the new document added at the step 2,
// because it is not updated between start/stopAddingChildDocuments.
task = new LoaderTask(mMtpManager, mDatabase, mDevice.operationsSupported, parent);
task.loadObjectHandles();
task.loadObjectInfoList(NUM_INITIAL_ENTRIES);
} else {
// Once remove the existing task in order to add it to the head of the list.
mTaskList.remove(task);
}
mTaskList.addFirst(task);
if (task.getState() == LoaderTask.STATE_LOADING) {
resume();
}
return task.createCursor(mResolver, columnNames);
}
/**
* Resumes a background thread.
*/
synchronized void resume() {
if (mBackgroundThread == null) {
mBackgroundThread = new BackgroundLoaderThread();
mBackgroundThread.start();
}
}
/**
* Obtains next task to be run in background thread, or release the reference to background
* thread.
*
* Worker thread that receives null task needs to exit.
*/
@WorkerThread
synchronized @Nullable LoaderTask getNextTaskOrReleaseBackgroundThread() {
Preconditions.checkState(mBackgroundThread != null);
for (final LoaderTask task : mTaskList) {
if (task.getState() == LoaderTask.STATE_LOADING) {
return task;
}
}
final Identifier identifier = mDatabase.getUnmappedDocumentsParent(mDevice.deviceId);
if (identifier != null) {
final LoaderTask existingTask = mTaskList.findTask(identifier);
if (existingTask != null) {
Preconditions.checkState(existingTask.getState() != LoaderTask.STATE_LOADING);
mTaskList.remove(existingTask);
}
final LoaderTask newTask = new LoaderTask(
mMtpManager, mDatabase, mDevice.operationsSupported, identifier);
newTask.loadObjectHandles();
mTaskList.addFirst(newTask);
return newTask;
}
mBackgroundThread = null;
return null;
}
/**
* Terminates background thread.
*/
@Override
public void close() throws InterruptedException {
final Thread thread;
synchronized (this) {
mTaskList.clear();
thread = mBackgroundThread;
}
if (thread != null) {
thread.interrupt();
thread.join();
}
}
synchronized void clearCompletedTasks() {
mTaskList.clearCompletedTasks();
}
/**
* Cancels the task for |parentIdentifier|.
*
* Task is removed from the cached list and it will create new task when |parentIdentifier|'s
* children are queried next.
*/
void cancelTask(Identifier parentIdentifier) {
final LoaderTask task;
synchronized (this) {
task = mTaskList.findTask(parentIdentifier);
}
if (task != null) {
task.cancel();
mTaskList.remove(task);
}
}
/**
* Background thread to fetch object info.
*/
private class BackgroundLoaderThread extends Thread {
/**
* Finds task that needs to be processed, then loads NUM_LOADING_ENTRIES of object info and
* store them to the database. If it does not find a task, exits the thread.
*/
@Override
public void run() {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
while (!Thread.interrupted()) {
final LoaderTask task = getNextTaskOrReleaseBackgroundThread();
if (task == null) {
return;
}
task.loadObjectInfoList(NUM_LOADING_ENTRIES);
final boolean shouldNotify =
task.getState() != LoaderTask.STATE_CANCELLED &&
(task.mLastNotified.getTime() <
new Date().getTime() - NOTIFY_PERIOD_MS ||
task.getState() != LoaderTask.STATE_LOADING);
if (shouldNotify) {
task.notify(mResolver);
}
}
}
}
/**
* Task list that has helper methods to search/clear tasks.
*/
private static class TaskList extends LinkedList<LoaderTask> {
LoaderTask findTask(Identifier parent) {
for (int i = 0; i < size(); i++) {
if (get(i).mIdentifier.equals(parent))
return get(i);
}
return null;
}
void clearCompletedTasks() {
int i = 0;
while (i < size()) {
if (get(i).getState() == LoaderTask.STATE_COMPLETED) {
remove(i);
} else {
i++;
}
}
}
}
/**
* Loader task.
* Each task is responsible for fetching child documents for the given parent document.
*/
private static class LoaderTask {
static final int STATE_START = 0;
static final int STATE_LOADING = 1;
static final int STATE_COMPLETED = 2;
static final int STATE_ERROR = 3;
static final int STATE_CANCELLED = 4;
final MtpManager mManager;
final MtpDatabase mDatabase;
final int[] mOperationsSupported;
final Identifier mIdentifier;
int[] mObjectHandles;
int mState;
Date mLastNotified;
int mPosition;
IOException mError;
LoaderTask(MtpManager manager, MtpDatabase database, int[] operationsSupported,
Identifier identifier) {
assert operationsSupported != null;
assert identifier.mDocumentType != MtpDatabaseConstants.DOCUMENT_TYPE_DEVICE;
mManager = manager;
mDatabase = database;
mOperationsSupported = operationsSupported;
mIdentifier = identifier;
mObjectHandles = null;
mState = STATE_START;
mPosition = 0;
mLastNotified = new Date();
}
synchronized void loadObjectHandles() {
assert mState == STATE_START;
mPosition = 0;
int parentHandle = mIdentifier.mObjectHandle;
// Need to pass the special value MtpManager.OBJECT_HANDLE_ROOT_CHILDREN to
// getObjectHandles if we would like to obtain children under the root.
if (mIdentifier.mDocumentType == MtpDatabaseConstants.DOCUMENT_TYPE_STORAGE) {
parentHandle = MtpManager.OBJECT_HANDLE_ROOT_CHILDREN;
}
try {
mObjectHandles = mManager.getObjectHandles(
mIdentifier.mDeviceId, mIdentifier.mStorageId, parentHandle);
mState = STATE_LOADING;
} catch (IOException error) {
mError = error;
mState = STATE_ERROR;
}
}
/**
* Returns a cursor that traverses the child document of the parent document handled by the
* task.
* The returned task may have a EXTRA_LOADING flag.
*/
synchronized Cursor createCursor(ContentResolver resolver, String[] columnNames)
throws IOException {
final Bundle extras = new Bundle();
switch (getState()) {
case STATE_LOADING:
extras.putBoolean(DocumentsContract.EXTRA_LOADING, true);
break;
case STATE_ERROR:
throw mError;
}
final Cursor cursor =
mDatabase.queryChildDocuments(columnNames, mIdentifier.mDocumentId);
cursor.setExtras(extras);
cursor.setNotificationUri(resolver, createUri());
return cursor;
}
/**
* Stores object information into database.
*/
void loadObjectInfoList(int count) {
synchronized (this) {
if (mState != STATE_LOADING) {
return;
}
if (mPosition == 0) {
try{
mDatabase.getMapper().startAddingDocuments(mIdentifier.mDocumentId);
} catch (FileNotFoundException error) {
mError = error;
mState = STATE_ERROR;
return;
}
}
}
final ArrayList<MtpObjectInfo> infoList = new ArrayList<>();
for (int chunkEnd = mPosition + count;
mPosition < mObjectHandles.length && mPosition < chunkEnd;
mPosition++) {
try {
infoList.add(mManager.getObjectInfo(
mIdentifier.mDeviceId, mObjectHandles[mPosition]));
} catch (IOException error) {
Log.e(MtpDocumentsProvider.TAG, "Failed to load object info", error);
}
}
final long[] objectSizeList = new long[infoList.size()];
for (int i = 0; i < infoList.size(); i++) {
final MtpObjectInfo info = infoList.get(i);
// Compressed size is 32-bit unsigned integer but getCompressedSize returns the
// value in Java int (signed 32-bit integer). Use getCompressedSizeLong instead
// to get the value in Java long.
if (info.getCompressedSizeLong() != 0xffffffffl) {
objectSizeList[i] = info.getCompressedSizeLong();
continue;
}
if (!MtpDeviceRecord.isSupported(
mOperationsSupported,
MtpConstants.OPERATION_GET_OBJECT_PROP_DESC) ||
!MtpDeviceRecord.isSupported(
mOperationsSupported,
MtpConstants.OPERATION_GET_OBJECT_PROP_VALUE)) {
objectSizeList[i] = -1;
continue;
}
// Object size is more than 4GB.
try {
objectSizeList[i] = mManager.getObjectSizeLong(
mIdentifier.mDeviceId,
info.getObjectHandle(),
info.getFormat());
} catch (IOException error) {
Log.e(MtpDocumentsProvider.TAG, "Failed to get object size property.", error);
objectSizeList[i] = -1;
}
}
synchronized (this) {
// Check if the task is cancelled or not.
if (mState != STATE_LOADING) {
return;
}
try {
mDatabase.getMapper().putChildDocuments(
mIdentifier.mDeviceId,
mIdentifier.mDocumentId,
mOperationsSupported,
infoList.toArray(new MtpObjectInfo[infoList.size()]),
objectSizeList);
} catch (FileNotFoundException error) {
// Looks like the parent document information is removed.
// Adding documents has already cancelled in Mapper so we don't need to invoke
// stopAddingDocuments.
mError = error;
mState = STATE_ERROR;
return;
}
if (mPosition >= mObjectHandles.length) {
try{
mDatabase.getMapper().stopAddingDocuments(mIdentifier.mDocumentId);
mState = STATE_COMPLETED;
} catch (FileNotFoundException error) {
mError = error;
mState = STATE_ERROR;
return;
}
}
}
}
/**
* Cancels the task.
*/
synchronized void cancel() {
mDatabase.getMapper().cancelAddingDocuments(mIdentifier.mDocumentId);
mState = STATE_CANCELLED;
}
/**
* Returns a state of the task.
*/
int getState() {
return mState;
}
/**
* Notifies a change of child list of the document.
*/
void notify(ContentResolver resolver) {
resolver.notifyChange(createUri(), null, false);
mLastNotified = new Date();
}
private Uri createUri() {
return DocumentsContract.buildChildDocumentsUri(
MtpDocumentsProvider.AUTHORITY, mIdentifier.mDocumentId);
}
}
}