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.content.ContentValues; 21 import android.database.Cursor; 22 import android.database.DatabaseUtils; 23 import android.database.sqlite.SQLiteDatabase; 24 import android.mtp.MtpObjectInfo; 25 import android.provider.DocumentsContract.Document; 26 import android.provider.DocumentsContract.Root; 27 import android.util.ArraySet; 28 import android.util.Log; 29 30 import com.android.internal.util.Preconditions; 31 32 import java.io.FileNotFoundException; 33 import java.util.Set; 34 35 import static com.android.mtp.MtpDatabaseConstants.*; 36 import static com.android.mtp.MtpDatabase.strings; 37 38 /** 39 * Mapping operations for MtpDatabase. 40 * Also see the comments of {@link MtpDatabase}. 41 */ 42 class Mapper { 43 private static final String[] EMPTY_ARGS = new String[0]; 44 private final MtpDatabase mDatabase; 45 46 /** 47 * IDs which currently Mapper operates mapping for. 48 */ 49 private final Set<String> mInMappingIds = new ArraySet<>(); 50 Mapper(MtpDatabase database)51 Mapper(MtpDatabase database) { 52 mDatabase = database; 53 } 54 55 /** 56 * Puts device information to database. 57 * 58 * @return If device is added to the database. 59 * @throws FileNotFoundException 60 */ putDeviceDocument(MtpDeviceRecord device)61 synchronized boolean putDeviceDocument(MtpDeviceRecord device) throws FileNotFoundException { 62 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 63 database.beginTransaction(); 64 try { 65 final ContentValues[] valuesList = new ContentValues[1]; 66 final ContentValues[] extraValuesList = new ContentValues[1]; 67 valuesList[0] = new ContentValues(); 68 extraValuesList[0] = new ContentValues(); 69 MtpDatabase.getDeviceDocumentValues(valuesList[0], extraValuesList[0], device); 70 final boolean changed = putDocuments( 71 null, 72 valuesList, 73 extraValuesList, 74 COLUMN_PARENT_DOCUMENT_ID + " IS NULL", 75 EMPTY_ARGS, 76 strings(COLUMN_DEVICE_ID, COLUMN_MAPPING_KEY)); 77 database.setTransactionSuccessful(); 78 return changed; 79 } finally { 80 database.endTransaction(); 81 } 82 } 83 84 /** 85 * Puts root information to database. 86 * 87 * @param parentDocumentId Document ID of device document. 88 * @param roots List of root information. 89 * @return If roots are added or removed from the database. 90 * @throws FileNotFoundException 91 */ putStorageDocuments( String parentDocumentId, int[] operationsSupported, MtpRoot[] roots)92 synchronized boolean putStorageDocuments( 93 String parentDocumentId, int[] operationsSupported, MtpRoot[] roots) 94 throws FileNotFoundException { 95 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 96 database.beginTransaction(); 97 try { 98 final ContentValues[] valuesList = new ContentValues[roots.length]; 99 final ContentValues[] extraValuesList = new ContentValues[roots.length]; 100 for (int i = 0; i < roots.length; i++) { 101 valuesList[i] = new ContentValues(); 102 extraValuesList[i] = new ContentValues(); 103 MtpDatabase.getStorageDocumentValues( 104 valuesList[i], 105 extraValuesList[i], 106 parentDocumentId, 107 operationsSupported, 108 roots[i]); 109 } 110 final boolean changed = putDocuments( 111 parentDocumentId, 112 valuesList, 113 extraValuesList, 114 COLUMN_PARENT_DOCUMENT_ID + " = ?", 115 strings(parentDocumentId), 116 strings(COLUMN_STORAGE_ID, Document.COLUMN_DISPLAY_NAME)); 117 118 database.setTransactionSuccessful(); 119 return changed; 120 } finally { 121 database.endTransaction(); 122 } 123 } 124 125 /** 126 * Puts document information to database. 127 * 128 * @param deviceId Device ID 129 * @param parentId Parent document ID. 130 * @param documents List of document information. 131 * @param documentSizes 64-bit size of documents. MtpObjectInfo#getComporessedSize will be 132 * ignored because it does not contain 4GB> object size. Can be -1 if the size is unknown. 133 * @throws FileNotFoundException 134 */ putChildDocuments( int deviceId, String parentId, int[] operationsSupported, MtpObjectInfo[] documents, long[] documentSizes)135 synchronized void putChildDocuments( 136 int deviceId, String parentId, 137 int[] operationsSupported, 138 MtpObjectInfo[] documents, 139 long[] documentSizes) 140 throws FileNotFoundException { 141 assert documents.length == documentSizes.length; 142 final ContentValues[] valuesList = new ContentValues[documents.length]; 143 for (int i = 0; i < documents.length; i++) { 144 valuesList[i] = new ContentValues(); 145 MtpDatabase.getObjectDocumentValues( 146 valuesList[i], 147 deviceId, 148 parentId, 149 operationsSupported, 150 documents[i], 151 documentSizes[i]); 152 } 153 putDocuments( 154 parentId, 155 valuesList, 156 null, 157 COLUMN_PARENT_DOCUMENT_ID + " = ?", 158 strings(parentId), 159 strings(COLUMN_OBJECT_HANDLE, Document.COLUMN_DISPLAY_NAME)); 160 } 161 clearMapping()162 void clearMapping() { 163 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 164 database.beginTransaction(); 165 try { 166 mInMappingIds.clear(); 167 // Disconnect all device rows. 168 try { 169 startAddingDocuments(null); 170 stopAddingDocuments(null); 171 } catch (FileNotFoundException exception) { 172 Log.e(MtpDocumentsProvider.TAG, "Unexpected FileNotFoundException.", exception); 173 throw new RuntimeException(exception); 174 } 175 database.setTransactionSuccessful(); 176 } finally { 177 database.endTransaction(); 178 } 179 } 180 181 /** 182 * Starts adding new documents. 183 * It changes the direct child documents of the given document from VALID to INVALIDATED. 184 * Note that it keeps DISCONNECTED documents as they are. 185 * 186 * @param parentDocumentId Parent document ID or NULL for root documents. 187 * @throws FileNotFoundException 188 */ startAddingDocuments(@ullable String parentDocumentId)189 void startAddingDocuments(@Nullable String parentDocumentId) throws FileNotFoundException { 190 final String selection; 191 final String[] args; 192 if (parentDocumentId != null) { 193 selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; 194 args = strings(parentDocumentId); 195 } else { 196 selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; 197 args = EMPTY_ARGS; 198 } 199 200 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 201 database.beginTransaction(); 202 try { 203 getParentOrHaltMapping(parentDocumentId); 204 Preconditions.checkState(!mInMappingIds.contains(parentDocumentId)); 205 206 // Set all valid documents as invalidated. 207 final ContentValues values = new ContentValues(); 208 values.put(COLUMN_ROW_STATE, ROW_STATE_INVALIDATED); 209 database.update( 210 TABLE_DOCUMENTS, 211 values, 212 selection + " AND " + COLUMN_ROW_STATE + " = ?", 213 DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_VALID))); 214 215 database.setTransactionSuccessful(); 216 mInMappingIds.add(parentDocumentId); 217 } finally { 218 database.endTransaction(); 219 } 220 } 221 222 /** 223 * Puts the documents into the database. 224 * If the mapping mode is not heuristic, it just adds the rows to the database or updates the 225 * existing rows with the new values. If the mapping mode is heuristic, it adds some new rows as 226 * 'pending' state when that rows may be corresponding to existing 'invalidated' rows. Then 227 * {@link #stopAddingDocuments(String)} turns the pending rows into 'valid' 228 * rows. If the methods adds rows to database, it updates valueList with correct document ID. 229 * 230 * @param parentId Parent document ID. 231 * @param valuesList Values for documents to be stored in the database. 232 * @param rootExtraValuesList Values for root extra to be stored in the database. 233 * @param selection SQL where closure to select rows that shares the same parent. 234 * @param args Argument for selection SQL. 235 * @return Whether the database content is changed. 236 * @throws FileNotFoundException When parentId is not registered in the database. 237 */ putDocuments( String parentId, ContentValues[] valuesList, @Nullable ContentValues[] rootExtraValuesList, String selection, String[] args, String[] mappingKeys)238 private boolean putDocuments( 239 String parentId, 240 ContentValues[] valuesList, 241 @Nullable ContentValues[] rootExtraValuesList, 242 String selection, 243 String[] args, 244 String[] mappingKeys) throws FileNotFoundException { 245 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 246 boolean changed = false; 247 database.beginTransaction(); 248 try { 249 getParentOrHaltMapping(parentId); 250 Preconditions.checkState(mInMappingIds.contains(parentId)); 251 final ContentValues oldRowSnapshot = new ContentValues(); 252 final ContentValues newRowSnapshot = new ContentValues(); 253 for (int i = 0; i < valuesList.length; i++) { 254 final ContentValues values = valuesList[i]; 255 final ContentValues rootExtraValues; 256 if (rootExtraValuesList != null) { 257 rootExtraValues = rootExtraValuesList[i]; 258 } else { 259 rootExtraValues = null; 260 } 261 try (final Cursor candidateCursor = 262 queryCandidate(selection, args, mappingKeys, values)) { 263 final long rowId; 264 if (candidateCursor == null) { 265 rowId = database.insert(TABLE_DOCUMENTS, null, values); 266 changed = true; 267 } else { 268 candidateCursor.moveToNext(); 269 rowId = candidateCursor.getLong(0); 270 if (!changed) { 271 mDatabase.writeRowSnapshot(String.valueOf(rowId), oldRowSnapshot); 272 } 273 database.update( 274 TABLE_DOCUMENTS, 275 values, 276 SELECTION_DOCUMENT_ID, 277 strings(rowId)); 278 } 279 // Document ID is a primary integer key of the table. So the returned row 280 // IDs should be same with the document ID. 281 values.put(Document.COLUMN_DOCUMENT_ID, rowId); 282 if (rootExtraValues != null) { 283 rootExtraValues.put(Root.COLUMN_ROOT_ID, rowId); 284 database.replace(TABLE_ROOT_EXTRA, null, rootExtraValues); 285 } 286 287 if (!changed) { 288 mDatabase.writeRowSnapshot(String.valueOf(rowId), newRowSnapshot); 289 // Put row state as string because SQLite returns snapshot values as string. 290 oldRowSnapshot.put(COLUMN_ROW_STATE, String.valueOf(ROW_STATE_VALID)); 291 if (!oldRowSnapshot.equals(newRowSnapshot)) { 292 changed = true; 293 } 294 } 295 } 296 } 297 298 database.setTransactionSuccessful(); 299 return changed; 300 } finally { 301 database.endTransaction(); 302 } 303 } 304 305 /** 306 * Stops adding documents. 307 * It handles 'invalidated' and 'disconnected' documents which we don't put corresponding 308 * documents so far. 309 * If the type adding document is 'device' or 'storage', the document may appear again 310 * afterward. The method marks such documents as 'disconnected'. If the type of adding document 311 * is 'object', it seems the documents are really removed from the remote MTP device. So the 312 * method deletes the metadata from the database. 313 * 314 * @param parentId Parent document ID or null for root documents. 315 * @return Whether the methods changes file metadata in database. 316 * @throws FileNotFoundException 317 */ stopAddingDocuments(@ullable String parentId)318 boolean stopAddingDocuments(@Nullable String parentId) throws FileNotFoundException { 319 final String selection; 320 final String[] args; 321 if (parentId != null) { 322 selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; 323 args = strings(parentId); 324 } else { 325 selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; 326 args = EMPTY_ARGS; 327 } 328 329 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 330 database.beginTransaction(); 331 try { 332 final Identifier parentIdentifier = getParentOrHaltMapping(parentId); 333 Preconditions.checkState(mInMappingIds.contains(parentId)); 334 mInMappingIds.remove(parentId); 335 336 boolean changed = false; 337 // Delete/disconnect all invalidated/disconnected rows that cannot be mapped. 338 // If parentIdentifier is null, added documents are devices. 339 // if parentIdentifier is DOCUMENT_TYPE_DEVICE, added documents are storages. 340 final boolean keepUnmatchedDocument = 341 parentIdentifier == null || 342 parentIdentifier.mDocumentType == DOCUMENT_TYPE_DEVICE; 343 if (keepUnmatchedDocument) { 344 if (mDatabase.disconnectDocumentsRecursively( 345 COLUMN_ROW_STATE + " = ? AND " + selection, 346 DatabaseUtils.appendSelectionArgs(strings(ROW_STATE_INVALIDATED), args))) { 347 changed = true; 348 } 349 } else { 350 if (mDatabase.deleteDocumentsAndRootsRecursively( 351 COLUMN_ROW_STATE + " IN (?, ?) AND " + selection, 352 DatabaseUtils.appendSelectionArgs( 353 strings(ROW_STATE_INVALIDATED, ROW_STATE_DISCONNECTED), args))) { 354 changed = true; 355 } 356 } 357 358 database.setTransactionSuccessful(); 359 return changed; 360 } finally { 361 database.endTransaction(); 362 } 363 } 364 365 /** 366 * Cancels adding documents. 367 * @param parentId 368 */ cancelAddingDocuments(@ullable String parentId)369 void cancelAddingDocuments(@Nullable String parentId) { 370 final String selection; 371 final String[] args; 372 if (parentId != null) { 373 selection = COLUMN_PARENT_DOCUMENT_ID + " = ?"; 374 args = strings(parentId); 375 } else { 376 selection = COLUMN_PARENT_DOCUMENT_ID + " IS NULL"; 377 args = EMPTY_ARGS; 378 } 379 380 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 381 database.beginTransaction(); 382 try { 383 if (!mInMappingIds.contains(parentId)) { 384 return; 385 } 386 mInMappingIds.remove(parentId); 387 final ContentValues values = new ContentValues(); 388 values.put(COLUMN_ROW_STATE, ROW_STATE_VALID); 389 mDatabase.getSQLiteDatabase().update( 390 TABLE_DOCUMENTS, 391 values, 392 selection + " AND " + COLUMN_ROW_STATE + " = ?", 393 DatabaseUtils.appendSelectionArgs(args, strings(ROW_STATE_INVALIDATED))); 394 database.setTransactionSuccessful(); 395 } finally { 396 database.endTransaction(); 397 } 398 } 399 400 /** 401 * Queries candidate for each mappingKey, and returns the first cursor that includes a 402 * candidate. 403 * 404 * @param selection Pre-selection for candidate. 405 * @param args Arguments for selection. 406 * @param mappingKeys List of mapping key columns. 407 * @param values Values of document that Mapper tries to map. 408 * @return Cursor for mapping candidate or null when Mapper does not find any candidate. 409 */ queryCandidate( String selection, String[] args, String[] mappingKeys, ContentValues values)410 private @Nullable Cursor queryCandidate( 411 String selection, String[] args, String[] mappingKeys, ContentValues values) { 412 for (final String mappingKey : mappingKeys) { 413 final Cursor candidateCursor = queryCandidate(selection, args, mappingKey, values); 414 if (candidateCursor.getCount() == 0) { 415 candidateCursor.close(); 416 continue; 417 } 418 return candidateCursor; 419 } 420 return null; 421 } 422 423 /** 424 * Looks for mapping candidate with given mappingKey. 425 * 426 * @param selection Pre-selection for candidate. 427 * @param args Arguments for selection. 428 * @param mappingKey Column name of mapping key. 429 * @param values Values of document that Mapper tries to map. 430 * @return Cursor for mapping candidate. 431 */ queryCandidate( String selection, String[] args, String mappingKey, ContentValues values)432 private Cursor queryCandidate( 433 String selection, String[] args, String mappingKey, ContentValues values) { 434 final SQLiteDatabase database = mDatabase.getSQLiteDatabase(); 435 return database.query( 436 TABLE_DOCUMENTS, 437 strings(Document.COLUMN_DOCUMENT_ID), 438 selection + " AND " + 439 COLUMN_ROW_STATE + " IN (?, ?) AND " + 440 mappingKey + " = ?", 441 DatabaseUtils.appendSelectionArgs( 442 args, 443 strings(ROW_STATE_INVALIDATED, 444 ROW_STATE_DISCONNECTED, 445 values.getAsString(mappingKey))), 446 null, 447 null, 448 null, 449 "1"); 450 } 451 452 /** 453 * Returns the parent identifier from parent document ID if the parent ID is found in the 454 * database. Otherwise it halts mapping and throws FileNotFoundException. 455 * 456 * @param parentId Parent document ID 457 * @return Parent identifier 458 * @throws FileNotFoundException 459 */ getParentOrHaltMapping( @ullable String parentId)460 private @Nullable Identifier getParentOrHaltMapping( 461 @Nullable String parentId) throws FileNotFoundException { 462 if (parentId == null) { 463 return null; 464 } 465 try { 466 return mDatabase.createIdentifier(parentId); 467 } catch (FileNotFoundException error) { 468 mInMappingIds.remove(parentId); 469 throw error; 470 } 471 } 472 } 473