1 /* 2 * Copyright (C) 2018 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.documentsui.base; 18 19 import static android.os.Environment.isStandardDirectory; 20 21 import static com.android.documentsui.ScopedAccessMetrics.SCOPED_DIRECTORY_ACCESS_ERROR; 22 import static com.android.documentsui.ScopedAccessMetrics.SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY; 23 import static com.android.documentsui.ScopedAccessMetrics.logInvalidScopedAccessRequest; 24 25 import android.annotation.Nullable; 26 import android.content.ContentProviderClient; 27 import android.content.Context; 28 import android.net.Uri; 29 import android.os.Build; 30 import android.os.Bundle; 31 import android.os.RemoteException; 32 import android.os.storage.StorageManager; 33 import android.os.storage.StorageVolume; 34 import android.os.storage.VolumeInfo; 35 import android.provider.DocumentsContract; 36 import android.text.TextUtils; 37 import android.util.Log; 38 39 import java.io.File; 40 import java.io.IOException; 41 import java.util.List; 42 43 /** 44 * Contains the minimum number of utilities (contants, helpers, etc...) that can be used by both the 45 * main package and the minimal APK that's used by Android TV (and other devices). 46 * 47 * <p>In other words, it should not include any external dependency that would increase the APK 48 * size. 49 */ 50 public final class SharedMinimal { 51 52 public static final String TAG = "Documents"; 53 54 public static final boolean DEBUG = Build.IS_DEBUGGABLE; 55 public static final boolean VERBOSE = DEBUG && Log.isLoggable(TAG, Log.VERBOSE); 56 57 /** 58 * Special directory name representing the full volume of a scoped directory request. 59 */ 60 public static final String DIRECTORY_ROOT = "ROOT_DIRECTORY"; 61 62 /** 63 * Callback for {@link SharedMinimal#getUriPermission(Context, ContentProviderClient, 64 * StorageVolume, String, int, boolean, GetUriPermissionCallback)}. 65 */ 66 public static interface GetUriPermissionCallback { 67 68 /** 69 * Evaluates the result of the request. 70 * 71 * @param file the path of the requested URI. 72 * @param volumeLabel user-friendly label of the volume. 73 * @param isRoot whether the requested directory is the root directory. 74 * @param isPrimary whether the requested volume is the primary storage volume. 75 * @param requestedUri the requested URI. 76 * @param rootUri the URI for the volume's root directory. 77 * @return whethe the result was sucessfully. 78 */ onResult(File file, String volumeLabel, boolean isRoot, boolean isPrimary, Uri requestedUri, Uri rootUri)79 boolean onResult(File file, String volumeLabel, boolean isRoot, boolean isPrimary, 80 Uri requestedUri, Uri rootUri); 81 } 82 83 /** 84 * Gets the name of a directory name in the format that's used internally by the app 85 * (i.e., mapping {@code null} to {@link #DIRECTORY_ROOT}); 86 * if necessary. 87 */ getInternalDirectoryName(@ullable String name)88 public static String getInternalDirectoryName(@Nullable String name) { 89 return name == null ? DIRECTORY_ROOT : name; 90 } 91 92 /** 93 * Gets the name of a directory name in the format that is used externally 94 * (i.e., mapping {@link #DIRECTORY_ROOT} to {@code null} if necessary); 95 */ 96 @Nullable getExternalDirectoryName(String name)97 public static String getExternalDirectoryName(String name) { 98 return name.equals(DIRECTORY_ROOT) ? null : name; 99 } 100 101 /** 102 * Gets the URI permission for the given volume and directory. 103 * 104 * @param context caller's context. 105 * @param storageClient storage provider client. 106 * @param storageVolume volume. 107 * @param directoryName directory name, or {@link #DIRECTORY_ROOT} for full volume. 108 * @param userId caller's user handle. 109 * @param logMetrics whether intermediate errors should be logged. 110 * @param callback callback that receives the results. 111 * 112 * @return whether the call was succesfull or not. 113 */ getUriPermission(Context context, ContentProviderClient storageClient, StorageVolume storageVolume, String directoryName, int userId, boolean logMetrics, GetUriPermissionCallback callback)114 public static boolean getUriPermission(Context context, 115 ContentProviderClient storageClient, StorageVolume storageVolume, 116 String directoryName, int userId, boolean logMetrics, 117 GetUriPermissionCallback callback) { 118 if (DEBUG) { 119 Log.d(TAG, "getUriPermission() for volume " + storageVolume.dump() + ", directory " 120 + directoryName + ", and user " + userId); 121 } 122 final boolean isRoot = directoryName.equals(DIRECTORY_ROOT); 123 final boolean isPrimary = storageVolume.isPrimary(); 124 125 if (isRoot && isPrimary) { 126 if (DEBUG) Log.d(TAG, "root access requested on primary volume"); 127 return false; 128 } 129 130 final File volumeRoot = storageVolume.getPathFile(); 131 File file; 132 try { 133 file = isRoot ? volumeRoot : new File(volumeRoot, directoryName).getCanonicalFile(); 134 } catch (IOException e) { 135 Log.e(TAG, "Could not get canonical file for volume " + storageVolume.dump() 136 + " and directory " + directoryName); 137 if (logMetrics) logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR); 138 return false; 139 } 140 final StorageManager sm = context.getSystemService(StorageManager.class); 141 142 final String root, directory; 143 if (isRoot) { 144 root = volumeRoot.getAbsolutePath(); 145 directory = "."; 146 } else { 147 root = file.getParent(); 148 directory = file.getName(); 149 // Verify directory is valid. 150 if (TextUtils.isEmpty(directory) || !isStandardDirectory(directory)) { 151 if (DEBUG) { 152 Log.d(TAG, "Directory '" + directory + "' is not standard (full path: '" 153 + file.getAbsolutePath() + "')"); 154 } 155 if (logMetrics) { 156 logInvalidScopedAccessRequest(context, 157 SCOPED_DIRECTORY_ACCESS_INVALID_DIRECTORY); 158 } 159 return false; 160 } 161 } 162 163 // Gets volume label and converted path. 164 String volumeLabel = null; 165 final List<VolumeInfo> volumes = sm.getVolumes(); 166 if (DEBUG) Log.d(TAG, "Number of volumes: " + volumes.size()); 167 File internalRoot = null; 168 for (VolumeInfo volume : volumes) { 169 if (isRightVolume(volume, root, userId)) { 170 internalRoot = volume.getInternalPathForUser(userId); 171 // Must convert path before calling getDocIdForFileCreateNewDir() 172 if (DEBUG) Log.d(TAG, "Converting " + root + " to " + internalRoot); 173 file = isRoot ? internalRoot : new File(internalRoot, directory); 174 volumeLabel = sm.getBestVolumeDescription(volume); 175 if (TextUtils.isEmpty(volumeLabel)) { 176 volumeLabel = storageVolume.getDescription(context); 177 } 178 if (TextUtils.isEmpty(volumeLabel)) { 179 volumeLabel = context.getString(android.R.string.unknownName); 180 Log.w(TAG, "No volume description for " + volume + "; using " + volumeLabel); 181 } 182 break; 183 } 184 } 185 if (internalRoot == null) { 186 // Should not happen on normal circumstances, unless app crafted an invalid volume 187 // using reflection or the list of mounted volumes changed. 188 Log.e(TAG, "Didn't find right volume for '" + storageVolume.dump() + "' on " + volumes); 189 return false; 190 } 191 192 final Uri requestedUri = getUriPermission(context, storageClient, file); 193 final Uri rootUri = internalRoot.equals(file) ? requestedUri 194 : getUriPermission(context, storageClient, internalRoot); 195 196 return callback.onResult(file, volumeLabel, isRoot, isPrimary, requestedUri, rootUri); 197 } 198 199 /** 200 * Creates an URI permission for the given file. 201 */ getUriPermission(Context context, ContentProviderClient storageProvider, File file)202 public static Uri getUriPermission(Context context, ContentProviderClient storageProvider, 203 File file) { 204 // Calls ExternalStorageProvider to get the doc id for the file 205 final Bundle bundle; 206 try { 207 bundle = storageProvider.call("getDocIdForFileCreateNewDir", file.getPath(), null); 208 } catch (RemoteException e) { 209 Log.e(TAG, "Did not get doc id from External Storage provider for " + file, e); 210 logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR); 211 return null; 212 } 213 final String docId = bundle == null ? null : bundle.getString("DOC_ID"); 214 if (docId == null) { 215 Log.e(TAG, "Did not get doc id from External Storage provider for " + file); 216 logInvalidScopedAccessRequest(context, SCOPED_DIRECTORY_ACCESS_ERROR); 217 return null; 218 } 219 if (DEBUG) Log.d(TAG, "doc id for " + file + ": " + docId); 220 221 final Uri uri = DocumentsContract.buildTreeDocumentUri(Providers.AUTHORITY_STORAGE, docId); 222 if (uri == null) { 223 Log.e(TAG, "Could not get URI for doc id " + docId); 224 return null; 225 } 226 if (DEBUG) Log.d(TAG, "URI for " + file + ": " + uri); 227 return uri; 228 } 229 isRightVolume(VolumeInfo volume, String root, int userId)230 private static boolean isRightVolume(VolumeInfo volume, String root, int userId) { 231 final File userPath = volume.getPathForUser(userId); 232 final String path = userPath == null ? null : volume.getPathForUser(userId).getPath(); 233 final boolean isMounted = volume.isMountedReadable(); 234 if (DEBUG) 235 Log.d(TAG, "Volume: " + volume 236 + "\n\tuserId: " + userId 237 + "\n\tuserPath: " + userPath 238 + "\n\troot: " + root 239 + "\n\tpath: " + path 240 + "\n\tisMounted: " + isMounted); 241 242 return isMounted && root.equals(path); 243 } 244 SharedMinimal()245 private SharedMinimal() { 246 throw new UnsupportedOperationException("provides static fields only"); 247 } 248 } 249