blob: 0d9d60c0b460c1a44b45b406109a10c89487a511 [file] [log] [blame]
Daichi Hirono259ce802015-11-20 17:51:53 +09001/*
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
17package com.android.mtp;
18
19import static com.android.mtp.MtpDatabaseConstants.*;
20
21import android.content.ContentValues;
22import android.content.res.Resources;
23import android.database.Cursor;
24import android.database.DatabaseUtils;
25import android.database.sqlite.SQLiteDatabase;
26import android.database.sqlite.SQLiteException;
27import android.mtp.MtpObjectInfo;
28import android.provider.DocumentsContract.Document;
29import android.provider.DocumentsContract.Root;
30
31import com.android.internal.annotations.VisibleForTesting;
32import com.android.internal.util.Preconditions;
33
34import java.util.HashMap;
35import java.util.Map;
36
37import static com.android.mtp.MtpDatabase.strings;
38
39
40/**
41 * Mapping operations for MtpDatabase.
42 * Also see the comments of {@link MtpDatabase}.
43 */
44class Mapper {
45 private final MtpDatabase mDatabase;
46
47 /**
48 * Mapping mode for roots/documents where we start adding child documents.
49 * Methods operate the state needs to be synchronized.
50 */
51 private final Map<String, Integer> mMappingMode = new HashMap<>();
52
53 Mapper(MtpDatabase database) {
54 mDatabase = database;
55 }
56
57 /**
58 * Invokes {@link #startAddingDocuments} for root documents.
59 * @param deviceId Device ID.
60 */
61 synchronized void startAddingRootDocuments(int deviceId) {
62 final String mappingStateKey = getRootDocumentsMappingStateKey(deviceId);
63 Preconditions.checkState(!mMappingMode.containsKey(mappingStateKey));
64 mMappingMode.put(
65 mappingStateKey,
66 startAddingDocuments(
67 SELECTION_ROOT_DOCUMENTS, Integer.toString(deviceId)));
68 }
69
70 /**
71 * Invokes {@link #startAddingDocuments} for child of specific documents.
72 * @param parentDocumentId Document ID for parent document.
73 */
74 @VisibleForTesting
75 synchronized void startAddingChildDocuments(String parentDocumentId) {
76 final String mappingStateKey = getChildDocumentsMappingStateKey(parentDocumentId);
77 Preconditions.checkState(!mMappingMode.containsKey(mappingStateKey));
78 mMappingMode.put(
79 mappingStateKey,
80 startAddingDocuments(SELECTION_CHILD_DOCUMENTS, parentDocumentId));
81 }
82
83 /**
84 * Puts root information to database.
85 * @param deviceId Device ID
86 * @param resources Resources required to localize root name.
87 * @param roots List of root information.
88 * @return If roots are added or removed from the database.
89 */
90 synchronized boolean putRootDocuments(int deviceId, Resources resources, MtpRoot[] roots) {
91 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
92 database.beginTransaction();
93 try {
94 final boolean heuristic;
95 final String mapColumn;
96 final String key = getRootDocumentsMappingStateKey(deviceId);
97 Preconditions.checkState(mMappingMode.containsKey(key));
98 switch (mMappingMode.get(key)) {
99 case MAP_BY_MTP_IDENTIFIER:
100 heuristic = false;
101 mapColumn = COLUMN_STORAGE_ID;
102 break;
103 case MAP_BY_NAME:
104 heuristic = true;
105 mapColumn = Document.COLUMN_DISPLAY_NAME;
106 break;
107 default:
108 throw new Error("Unexpected map mode.");
109 }
110 final ContentValues[] valuesList = new ContentValues[roots.length];
111 for (int i = 0; i < roots.length; i++) {
112 if (roots[i].mDeviceId != deviceId) {
113 throw new IllegalArgumentException();
114 }
115 valuesList[i] = new ContentValues();
116 MtpDatabase.getRootDocumentValues(valuesList[i], resources, roots[i]);
117 }
118 final boolean changed = putDocuments(
119 valuesList,
120 SELECTION_ROOT_DOCUMENTS,
121 Integer.toString(deviceId),
122 heuristic,
123 mapColumn);
124 final ContentValues values = new ContentValues();
125 int i = 0;
126 for (final MtpRoot root : roots) {
127 // Use the same value for the root ID and the corresponding document ID.
128 final String documentId = valuesList[i++].getAsString(Document.COLUMN_DOCUMENT_ID);
129 // If it fails to insert/update documents, the document ID will be set with -1.
130 // In this case we don't insert/update root extra information neither.
131 if (documentId == null) {
132 continue;
133 }
134 values.put(Root.COLUMN_ROOT_ID, documentId);
135 values.put(
136 Root.COLUMN_FLAGS,
137 Root.FLAG_SUPPORTS_IS_CHILD | Root.FLAG_SUPPORTS_CREATE);
138 values.put(Root.COLUMN_AVAILABLE_BYTES, root.mFreeSpace);
139 values.put(Root.COLUMN_CAPACITY_BYTES, root.mMaxCapacity);
140 values.put(Root.COLUMN_MIME_TYPES, "");
141 database.replace(TABLE_ROOT_EXTRA, null, values);
142 }
143 database.setTransactionSuccessful();
144 return changed;
145 } finally {
146 database.endTransaction();
147 }
148 }
149
150 /**
151 * Puts document information to database.
152 * @param deviceId Device ID
153 * @param parentId Parent document ID.
154 * @param documents List of document information.
155 */
156 synchronized void putChildDocuments(int deviceId, String parentId, MtpObjectInfo[] documents) {
157 final boolean heuristic;
158 final String mapColumn;
159 final String key = getChildDocumentsMappingStateKey(parentId);
160 Preconditions.checkState(mMappingMode.containsKey(key));
161 switch (mMappingMode.get(key)) {
162 case MAP_BY_MTP_IDENTIFIER:
163 heuristic = false;
164 mapColumn = COLUMN_OBJECT_HANDLE;
165 break;
166 case MAP_BY_NAME:
167 heuristic = true;
168 mapColumn = Document.COLUMN_DISPLAY_NAME;
169 break;
170 default:
171 throw new Error("Unexpected map mode.");
172 }
173 final ContentValues[] valuesList = new ContentValues[documents.length];
174 for (int i = 0; i < documents.length; i++) {
175 valuesList[i] = new ContentValues();
176 MtpDatabase.getChildDocumentValues(
177 valuesList[i], deviceId, parentId, documents[i]);
178 }
179 putDocuments(
180 valuesList, SELECTION_CHILD_DOCUMENTS, parentId, heuristic, mapColumn);
181 }
182
183 /**
184 * Stops adding root documents.
185 * @param deviceId Device ID.
186 * @return True if new rows are added/removed.
187 */
188 synchronized boolean stopAddingRootDocuments(int deviceId) {
189 final String key = getRootDocumentsMappingStateKey(deviceId);
190 Preconditions.checkState(mMappingMode.containsKey(key));
191 switch (mMappingMode.get(key)) {
192 case MAP_BY_MTP_IDENTIFIER:
193 mMappingMode.remove(key);
194 return stopAddingDocuments(
195 SELECTION_ROOT_DOCUMENTS,
196 Integer.toString(deviceId),
197 COLUMN_STORAGE_ID);
198 case MAP_BY_NAME:
199 mMappingMode.remove(key);
200 return stopAddingDocuments(
201 SELECTION_ROOT_DOCUMENTS,
202 Integer.toString(deviceId),
203 Document.COLUMN_DISPLAY_NAME);
204 default:
205 throw new Error("Unexpected mapping state.");
206 }
207 }
208
209 /**
210 * Stops adding documents under the parent.
211 * @param parentId Document ID of the parent.
212 */
213 synchronized void stopAddingChildDocuments(String parentId) {
214 final String key = getChildDocumentsMappingStateKey(parentId);
215 Preconditions.checkState(mMappingMode.containsKey(key));
216 switch (mMappingMode.get(key)) {
217 case MAP_BY_MTP_IDENTIFIER:
218 stopAddingDocuments(
219 SELECTION_CHILD_DOCUMENTS,
220 parentId,
221 COLUMN_OBJECT_HANDLE);
222 break;
223 case MAP_BY_NAME:
224 stopAddingDocuments(
225 SELECTION_CHILD_DOCUMENTS,
226 parentId,
227 Document.COLUMN_DISPLAY_NAME);
228 break;
229 default:
230 throw new Error("Unexpected mapping state.");
231 }
232 mMappingMode.remove(key);
233 }
234
235 @VisibleForTesting
236 void clearMapping() {
237 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
238 database.beginTransaction();
239 try {
240 mDatabase.deleteDocumentsAndRootsRecursively(
241 COLUMN_ROW_STATE + " = ?", strings(ROW_STATE_PENDING));
242 final ContentValues values = new ContentValues();
243 values.putNull(COLUMN_OBJECT_HANDLE);
244 values.putNull(COLUMN_STORAGE_ID);
245 values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
246 database.update(TABLE_DOCUMENTS, values, null, null);
247 database.setTransactionSuccessful();
248 mMappingMode.clear();
249 } finally {
250 database.endTransaction();
251 }
252 }
253
254 /**
255 * Starts adding new documents.
256 * The methods decides mapping mode depends on if all documents under the given parent have MTP
257 * identifier or not. If all the documents have MTP identifier, it uses the identifier to find
258 * a corresponding existing row. Otherwise it does heuristic.
259 *
260 * @param selection Query matches valid documents.
261 * @param arg Argument for selection.
262 * @return Mapping mode.
263 */
264 private int startAddingDocuments(String selection, String arg) {
265 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
266 database.beginTransaction();
267 try {
268 // Delete all pending rows.
269 mDatabase.deleteDocumentsAndRootsRecursively(
270 selection + " AND " + COLUMN_ROW_STATE + "=?", strings(arg, ROW_STATE_PENDING));
271
272 // Set all documents as invalidated.
273 final ContentValues values = new ContentValues();
274 values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED);
275 database.update(TABLE_DOCUMENTS, values, selection, new String[] { arg });
276
277 // If we have rows that does not have MTP identifier, do heuristic mapping by name.
278 final boolean useNameForResolving = DatabaseUtils.queryNumEntries(
279 database,
280 TABLE_DOCUMENTS,
281 selection + " AND " + COLUMN_STORAGE_ID + " IS NULL",
282 new String[] { arg }) > 0;
283 database.setTransactionSuccessful();
284 return useNameForResolving ? MAP_BY_NAME : MAP_BY_MTP_IDENTIFIER;
285 } finally {
286 database.endTransaction();
287 }
288 }
289
290 /**
291 * Puts the documents into the database.
292 * If the mapping mode is not heuristic, it just adds the rows to the database or updates the
293 * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as
294 * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then
295 * {@link #stopAddingDocuments(String, String, String)} turns the pending rows into 'valid'
296 * rows. If the methods adds rows to database, it updates valueList with correct document ID.
297 *
298 * @param valuesList Values for documents to be stored in the database.
299 * @param selection SQL where closure to select rows that shares the same parent.
300 * @param arg Argument for selection SQL.
301 * @param heuristic Whether the mapping mode is heuristic.
302 * @return Whether the method adds new rows.
303 */
304 private boolean putDocuments(
305 ContentValues[] valuesList,
306 String selection,
307 String arg,
308 boolean heuristic,
309 String mappingKey) {
310 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
311 boolean added = false;
312 database.beginTransaction();
313 try {
314 for (final ContentValues values : valuesList) {
315 final Cursor candidateCursor = database.query(
316 TABLE_DOCUMENTS,
317 strings(Document.COLUMN_DOCUMENT_ID),
318 selection + " AND " +
319 COLUMN_ROW_STATE + "=? AND " +
320 mappingKey + "=?",
321 strings(arg, ROW_STATE_INVALIDATED, values.getAsString(mappingKey)),
322 null,
323 null,
324 null,
325 "1");
326 try {
327 final long rowId;
328 if (candidateCursor.getCount() == 0) {
329 rowId = database.insert(TABLE_DOCUMENTS, null, values);
330 if (rowId == -1) {
331 throw new SQLiteException("Failed to put a document into database.");
332 }
333 added = true;
334 } else if (!heuristic) {
335 candidateCursor.moveToNext();
336 final String documentId = candidateCursor.getString(0);
337 rowId = database.update(
338 TABLE_DOCUMENTS,
339 values,
340 SELECTION_DOCUMENT_ID,
341 strings(documentId));
342 } else {
343 values.put(COLUMN_ROW_STATE, ROW_STATE_PENDING);
344 rowId = database.insert(TABLE_DOCUMENTS, null, values);
345 }
346 // Document ID is a primary integer key of the table. So the returned row
347 // IDs should be same with the document ID.
348 values.put(Document.COLUMN_DOCUMENT_ID, rowId);
349 } finally {
350 candidateCursor.close();
351 }
352 }
353
354 database.setTransactionSuccessful();
355 return added;
356 } finally {
357 database.endTransaction();
358 }
359 }
360
361 /**
362 * Maps 'pending' document and 'invalidated' document that shares the same column of groupKey.
363 * If the database does not find corresponding 'invalidated' document, it just removes
364 * 'invalidated' document from the database.
365 * @param selection Query to select rows for resolving.
366 * @param arg Argument for selection SQL.
367 * @param groupKey Column name used to find corresponding rows.
368 * @return Whether the methods adds or removed visible rows.
369 */
370 private boolean stopAddingDocuments(String selection, String arg, String groupKey) {
371 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
372 database.beginTransaction();
373 try {
374 // Get 1-to-1 mapping of invalidated document and pending document.
375 final String invalidatedIdQuery = createStateFilter(
376 ROW_STATE_INVALIDATED, Document.COLUMN_DOCUMENT_ID);
377 final String pendingIdQuery = createStateFilter(
378 ROW_STATE_PENDING, Document.COLUMN_DOCUMENT_ID);
379 // SQL should be like:
380 // SELECT group_concat(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END),
381 // group_concat(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END)
382 // WHERE device_id = ? AND parent_document_id IS NULL
383 // GROUP BY display_name
384 // HAVING count(CASE WHEN raw_state = 1 THEN document_id ELSE NULL END) = 1 AND
385 // count(CASE WHEN raw_state = 2 THEN document_id ELSE NULL END) = 1
386 final Cursor mergingCursor = database.query(
387 TABLE_DOCUMENTS,
388 new String[] {
389 "group_concat(" + invalidatedIdQuery + ")",
390 "group_concat(" + pendingIdQuery + ")"
391 },
392 selection,
393 strings(arg),
394 groupKey,
395 "count(" + invalidatedIdQuery + ") = 1 AND count(" + pendingIdQuery + ") = 1",
396 null);
397
398 final ContentValues values = new ContentValues();
399 while (mergingCursor.moveToNext()) {
400 final String invalidatedId = mergingCursor.getString(0);
401 final String pendingId = mergingCursor.getString(1);
402
403 // Obtain the new values including the latest object handle from mapping row.
404 getFirstRow(
405 TABLE_DOCUMENTS,
406 SELECTION_DOCUMENT_ID,
407 new String[] { pendingId },
408 values);
409 values.remove(Document.COLUMN_DOCUMENT_ID);
410 values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
411 database.update(
412 TABLE_DOCUMENTS,
413 values,
414 SELECTION_DOCUMENT_ID,
415 new String[] { invalidatedId });
416
417 getFirstRow(
418 TABLE_ROOT_EXTRA,
419 SELECTION_ROOT_ID,
420 new String[] { pendingId },
421 values);
422 if (values.size() > 0) {
423 values.remove(Root.COLUMN_ROOT_ID);
424 database.update(
425 TABLE_ROOT_EXTRA,
426 values,
427 SELECTION_ROOT_ID,
428 new String[] { invalidatedId });
429 }
430
431 // Delete 'pending' row.
432 mDatabase.deleteDocumentsAndRootsRecursively(
433 SELECTION_DOCUMENT_ID, new String[] { pendingId });
434 }
435 mergingCursor.close();
436
437 boolean changed = false;
438
439 // Delete all invalidated rows that cannot be mapped.
440 if (mDatabase.deleteDocumentsAndRootsRecursively(
441 COLUMN_ROW_STATE + " = ? AND " + selection,
442 strings(ROW_STATE_INVALIDATED, arg))) {
443 changed = true;
444 }
445
446 // The database cannot find old document ID for the pending rows.
447 // Turn the all pending rows into valid state, which means the rows become to be
448 // valid with new document ID.
449 values.clear();
450 values.put(COLUMN_ROW_STATE, ROW_STATE_VALID);
451 if (database.update(
452 TABLE_DOCUMENTS,
453 values,
454 COLUMN_ROW_STATE + " = ? AND " + selection,
455 strings(ROW_STATE_PENDING, arg)) != 0) {
456 changed = true;
457 }
458 database.setTransactionSuccessful();
459 return changed;
460 } finally {
461 database.endTransaction();
462 }
463 }
464
465 /**
466 * Obtains values of the first row for the query.
467 * @param values ContentValues that the values are stored to.
468 * @param table Target table.
469 * @param selection Query to select rows.
470 * @param args Argument for query.
471 */
472 private void getFirstRow(String table, String selection, String[] args, ContentValues values) {
473 final SQLiteDatabase database = mDatabase.getSQLiteDatabase();
474 values.clear();
475 final Cursor cursor = database.query(table, null, selection, args, null, null, null, "1");
476 if (cursor.getCount() == 0) {
477 return;
478 }
479 cursor.moveToNext();
480 DatabaseUtils.cursorRowToContentValues(cursor, values);
481 cursor.close();
482 }
483
484 /**
485 * Gets SQL expression that represents the given value or NULL depends on the row state.
486 * You must pass static constants to this methods otherwise you may be suffered from SQL
487 * injections.
488 * @param state Expected row state.
489 * @param a SQL value.
490 * @return Expression that represents a if the row state is expected one, and represents NULL
491 * otherwise.
492 */
493 private static String createStateFilter(int state, String a) {
494 return "CASE WHEN " + COLUMN_ROW_STATE + " = " + Integer.toString(state) +
495 " THEN " + a + " ELSE NULL END";
496 }
497
498 /**
499 * @param deviceId Device ID.
500 * @return Key for {@link #mMappingMode}.
501 */
502 private static String getRootDocumentsMappingStateKey(int deviceId) {
503 return "RootDocuments/" + deviceId;
504 }
505
506 /**
507 * @param parentDocumentId Document ID for the parent document.
508 * @return Key for {@link #mMappingMode}.
509 */
510 private static String getChildDocumentsMappingStateKey(String parentDocumentId) {
511 return "ChildDocuments/" + parentDocumentId;
512 }
513}