1 /*
2  * Copyright (C) 2008 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.providers.downloads;
18 
19 import static android.os.Environment.buildExternalStorageAndroidObbDirs;
20 import static android.os.Environment.buildExternalStorageAppDataDirs;
21 import static android.os.Environment.buildExternalStorageAppMediaDirs;
22 import static android.os.Environment.buildExternalStorageAppObbDirs;
23 import static android.os.Environment.buildExternalStoragePublicDirs;
24 import static android.os.Process.INVALID_UID;
25 import static android.provider.Downloads.Impl.COLUMN_DESTINATION;
26 import static android.provider.Downloads.Impl.DESTINATION_EXTERNAL;
27 import static android.provider.Downloads.Impl.DESTINATION_FILE_URI;
28 import static android.provider.Downloads.Impl.DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD;
29 import static android.provider.Downloads.Impl.FLAG_REQUIRES_CHARGING;
30 import static android.provider.Downloads.Impl.FLAG_REQUIRES_DEVICE_IDLE;
31 import static android.provider.Downloads.Impl._DATA;
32 
33 import static com.android.providers.downloads.Constants.TAG;
34 
35 import android.annotation.NonNull;
36 import android.annotation.Nullable;
37 import android.app.AppOpsManager;
38 import android.app.job.JobInfo;
39 import android.app.job.JobScheduler;
40 import android.content.ComponentName;
41 import android.content.ContentProvider;
42 import android.content.ContentResolver;
43 import android.content.ContentValues;
44 import android.content.Context;
45 import android.content.pm.PackageManager;
46 import android.database.Cursor;
47 import android.net.Uri;
48 import android.os.Binder;
49 import android.os.Environment;
50 import android.os.FileUtils;
51 import android.os.Handler;
52 import android.os.HandlerThread;
53 import android.os.Process;
54 import android.os.SystemClock;
55 import android.os.UserHandle;
56 import android.os.storage.StorageManager;
57 import android.os.storage.StorageVolume;
58 import android.provider.Downloads;
59 import android.provider.MediaStore;
60 import android.text.TextUtils;
61 import android.util.Log;
62 import android.util.SparseArray;
63 import android.webkit.MimeTypeMap;
64 
65 import com.android.internal.annotations.VisibleForTesting;
66 import com.android.internal.util.ArrayUtils;
67 
68 import java.io.File;
69 import java.io.IOException;
70 import java.lang.System;
71 import java.util.ArrayList;
72 import java.util.Arrays;
73 import java.util.Locale;
74 import java.util.Random;
75 import java.util.regex.Matcher;
76 import java.util.regex.Pattern;
77 
78 /**
79  * Some helper functions for the download manager
80  */
81 public class Helpers {
82     public static Random sRandom = new Random(SystemClock.uptimeMillis());
83 
84     /** Regex used to parse content-disposition headers */
85     private static final Pattern CONTENT_DISPOSITION_PATTERN =
86             Pattern.compile("attachment;\\s*filename\\s*=\\s*\"([^\"]*)\"");
87 
88     private static final Pattern PATTERN_ANDROID_DIRS =
89             Pattern.compile("(?i)^/storage/[^/]+(?:/[0-9]+)?/Android/(?:data|obb|media)/.+");
90 
91     private static final Pattern PATTERN_ANDROID_PRIVATE_DIRS =
92             Pattern.compile("(?i)^/storage/[^/]+(?:/[0-9]+)?/Android/(data|obb)/.+");
93 
94     private static final Pattern PATTERN_PUBLIC_DIRS =
95             Pattern.compile("(?i)^/storage/[^/]+(?:/[0-9]+)?/([^/]+)/.+");
96 
97     @VisibleForTesting
98     static final String DEFAULT_DOWNLOAD_FILE_NAME_PREFIX = "Download_";
99 
100     private static final Object sUniqueLock = new Object();
101 
102     private static HandlerThread sAsyncHandlerThread;
103     private static Handler sAsyncHandler;
104 
105     private static SystemFacade sSystemFacade;
106     private static DownloadNotifier sNotifier;
107 
Helpers()108     private Helpers() {
109     }
110 
getAsyncHandler()111     public synchronized static Handler getAsyncHandler() {
112         if (sAsyncHandlerThread == null) {
113             sAsyncHandlerThread = new HandlerThread("sAsyncHandlerThread",
114                     Process.THREAD_PRIORITY_BACKGROUND);
115             sAsyncHandlerThread.start();
116             sAsyncHandler = new Handler(sAsyncHandlerThread.getLooper());
117         }
118         return sAsyncHandler;
119     }
120 
121     @VisibleForTesting
setSystemFacade(SystemFacade systemFacade)122     public synchronized static void setSystemFacade(SystemFacade systemFacade) {
123         sSystemFacade = systemFacade;
124     }
125 
getSystemFacade(Context context)126     public synchronized static SystemFacade getSystemFacade(Context context) {
127         if (sSystemFacade == null) {
128             sSystemFacade = new RealSystemFacade(context);
129         }
130         return sSystemFacade;
131     }
132 
getDownloadNotifier(Context context)133     public synchronized static DownloadNotifier getDownloadNotifier(Context context) {
134         if (sNotifier == null) {
135             sNotifier = new DownloadNotifier(context);
136         }
137         return sNotifier;
138     }
139 
getString(Cursor cursor, String col)140     public static String getString(Cursor cursor, String col) {
141         return cursor.getString(cursor.getColumnIndexOrThrow(col));
142     }
143 
getInt(Cursor cursor, String col)144     public static int getInt(Cursor cursor, String col) {
145         return cursor.getInt(cursor.getColumnIndexOrThrow(col));
146     }
147 
scheduleJob(Context context, long downloadId)148     public static void scheduleJob(Context context, long downloadId) {
149         final boolean scheduled = scheduleJob(context,
150                 DownloadInfo.queryDownloadInfo(context, downloadId));
151         if (!scheduled) {
152             // If we didn't schedule a future job, kick off a notification
153             // update pass immediately
154             getDownloadNotifier(context).update();
155         }
156     }
157 
158     /**
159      * Schedule (or reschedule) a job for the given {@link DownloadInfo} using
160      * its current state to define job constraints.
161      */
scheduleJob(Context context, DownloadInfo info)162     public static boolean scheduleJob(Context context, DownloadInfo info) {
163         if (info == null) return false;
164 
165         final JobScheduler scheduler = context.getSystemService(JobScheduler.class);
166 
167         // Tear down any existing job for this download
168         final int jobId = (int) info.mId;
169         scheduler.cancel(jobId);
170 
171         // Skip scheduling if download is paused or finished
172         if (!info.isReadyToSchedule()) return false;
173 
174         final JobInfo.Builder builder = new JobInfo.Builder(jobId,
175                 new ComponentName(context, DownloadJobService.class));
176 
177         // When this download will show a notification, run with a higher
178         // bias, since it's effectively a foreground service
179         if (info.isVisible()) {
180             builder.setBias(JobInfo.BIAS_FOREGROUND_SERVICE);
181             builder.setFlags(JobInfo.FLAG_WILL_BE_FOREGROUND);
182         }
183 
184         // We might have a backoff constraint due to errors
185         final long latency = info.getMinimumLatency();
186         if (latency > 0) {
187             builder.setMinimumLatency(latency);
188         }
189 
190         // We always require a network, but the type of network might be further
191         // restricted based on download request or user override
192         builder.setRequiredNetworkType(info.getRequiredNetworkType(info.mTotalBytes));
193 
194         if ((info.mFlags & FLAG_REQUIRES_CHARGING) != 0) {
195             builder.setRequiresCharging(true);
196         }
197         if ((info.mFlags & FLAG_REQUIRES_DEVICE_IDLE) != 0) {
198             builder.setRequiresDeviceIdle(true);
199         }
200 
201         // Provide estimated network size, when possible
202         if (info.mTotalBytes > 0) {
203             if (info.mCurrentBytes > 0 && !TextUtils.isEmpty(info.mETag)) {
204                 // If we're resuming an in-progress download, we only need to
205                 // download the remaining bytes.
206                 final long remainingBytes;
207                 if (info.mTotalBytes > info.mCurrentBytes) {
208                     remainingBytes = info.mTotalBytes - info.mCurrentBytes;
209                 } else {
210                     // We've downloaded more than we expected. We no longer know how much is left.
211                     Log.i(TAG, "Downloaded more than expected during previous executions");
212                     remainingBytes = JobInfo.NETWORK_BYTES_UNKNOWN;
213                 }
214                 builder.setEstimatedNetworkBytes(remainingBytes,
215                         JobInfo.NETWORK_BYTES_UNKNOWN);
216             } else {
217                 builder.setEstimatedNetworkBytes(info.mTotalBytes, JobInfo.NETWORK_BYTES_UNKNOWN);
218             }
219         }
220 
221         // If package name was filtered during insert (probably due to being
222         // invalid), blame based on the requesting UID instead
223         String packageName = info.mPackage;
224         if (packageName == null) {
225             packageName = context.getPackageManager().getPackagesForUid(info.mUid)[0];
226         }
227 
228         scheduler.scheduleAsPackage(builder.build(), packageName, UserHandle.myUserId(), TAG);
229         return true;
230     }
231 
232     /*
233      * Parse the Content-Disposition HTTP Header. The format of the header
234      * is defined here: http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html
235      * This header provides a filename for content that is going to be
236      * downloaded to the file system. We only support the attachment type.
237      */
parseContentDisposition(String contentDisposition)238     private static String parseContentDisposition(String contentDisposition) {
239         try {
240             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
241             if (m.find()) {
242                 return m.group(1);
243             }
244         } catch (IllegalStateException ex) {
245              // This function is defined as returning null when it can't parse the header
246         }
247         return null;
248     }
249 
250     /**
251      * Creates a filename (where the file should be saved) from info about a download.
252      * This file will be touched to reserve it.
253      */
generateSaveFile(Context context, String url, String hint, String contentDisposition, String contentLocation, String mimeType, int destination)254     static String generateSaveFile(Context context, String url, String hint,
255             String contentDisposition, String contentLocation, String mimeType, int destination)
256             throws IOException {
257 
258         final File parent;
259         final File[] parentTest;
260         String name = null;
261 
262         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
263             final File file = new File(Uri.parse(hint).getPath());
264             parent = file.getParentFile().getAbsoluteFile();
265             parentTest = new File[] { parent };
266             name = file.getName();
267         } else {
268             parent = getRunningDestinationDirectory(context, destination);
269             parentTest = new File[] {
270                     parent,
271                     getSuccessDestinationDirectory(context, destination)
272             };
273             name = chooseFilename(url, hint, contentDisposition, contentLocation);
274         }
275 
276         // Ensure target directories are ready
277         for (File test : parentTest) {
278             if (!(test.isDirectory() || test.mkdirs())) {
279                 throw new IOException("Failed to create parent for " + test);
280             }
281         }
282 
283         if (DownloadDrmHelper.isDrmConvertNeeded(mimeType)) {
284             name = DownloadDrmHelper.modifyDrmFwLockFileExtension(name);
285         }
286 
287         final String prefix;
288         final String suffix;
289         final int dotIndex = name.lastIndexOf('.');
290         final boolean missingExtension = dotIndex < 0;
291         if (destination == Downloads.Impl.DESTINATION_FILE_URI) {
292             // Destination is explicitly set - do not change the extension
293             if (missingExtension) {
294                 prefix = name;
295                 suffix = "";
296             } else {
297                 prefix = name.substring(0, dotIndex);
298                 suffix = name.substring(dotIndex);
299             }
300         } else {
301             // Split filename between base and extension
302             // Add an extension if filename does not have one
303             if (missingExtension) {
304                 prefix = name;
305                 suffix = chooseExtensionFromMimeType(mimeType, true);
306             } else {
307                 prefix = name.substring(0, dotIndex);
308                 suffix = chooseExtensionFromFilename(mimeType, destination, name, dotIndex);
309             }
310         }
311 
312         synchronized (sUniqueLock) {
313             name = generateAvailableFilenameLocked(parentTest, prefix, suffix);
314 
315             // Claim this filename inside lock to prevent other threads from
316             // clobbering us. We're not paranoid enough to use O_EXCL.
317             final File file = new File(parent, name);
318             file.createNewFile();
319             return file.getAbsolutePath();
320         }
321     }
322 
323     private static String chooseFilename(String url, String hint, String contentDisposition,
324             String contentLocation) {
325         String filename = null;
326 
327         // First, try to use the hint from the application, if there's one
328         if (filename == null && hint != null && !hint.endsWith("/")) {
329             if (Constants.LOGVV) {
330                 Log.v(Constants.TAG, "getting filename from hint");
331             }
332             int index = hint.lastIndexOf('/') + 1;
333             if (index > 0) {
334                 filename = hint.substring(index);
335             } else {
336                 filename = hint;
337             }
338         }
339 
340         // If we couldn't do anything with the hint, move toward the content disposition
341         if (filename == null && contentDisposition != null) {
342             filename = parseContentDisposition(contentDisposition);
343             if (filename != null) {
344                 if (Constants.LOGVV) {
345                     Log.v(Constants.TAG, "getting filename from content-disposition");
346                 }
347                 int index = filename.lastIndexOf('/') + 1;
348                 if (index > 0) {
349                     filename = filename.substring(index);
350                 }
351             }
352         }
353 
354         // If we still have nothing at this point, try the content location
355         if (filename == null && contentLocation != null) {
356             String decodedContentLocation = Uri.decode(contentLocation);
357             if (decodedContentLocation != null
358                     && !decodedContentLocation.endsWith("/")
359                     && decodedContentLocation.indexOf('?') < 0) {
360                 if (Constants.LOGVV) {
361                     Log.v(Constants.TAG, "getting filename from content-location");
362                 }
363                 int index = decodedContentLocation.lastIndexOf('/') + 1;
364                 if (index > 0) {
365                     filename = decodedContentLocation.substring(index);
366                 } else {
367                     filename = decodedContentLocation;
368                 }
369             }
370         }
371 
372         // If all the other http-related approaches failed, use the plain uri
373         if (filename == null) {
374             String decodedUrl = Uri.decode(url);
375             if (decodedUrl != null
376                     && !decodedUrl.endsWith("/") && decodedUrl.indexOf('?') < 0) {
377                 int index = decodedUrl.lastIndexOf('/') + 1;
378                 if (index > 0) {
379                     if (Constants.LOGVV) {
380                         Log.v(Constants.TAG, "getting filename from uri");
381                     }
382                     filename = decodedUrl.substring(index);
383                 }
384             }
385         }
386 
387         // Finally, if couldn't get filename from URI, get a generic filename
388         if (filename == null) {
389             if (Constants.LOGVV) {
390                 Log.v(Constants.TAG, "using default filename");
391             }
392             filename = Constants.DEFAULT_DL_FILENAME;
393         }
394 
395         // The VFAT file system is assumed as target for downloads.
396         // Replace invalid characters according to the specifications of VFAT.
397         filename = FileUtils.buildValidFatFilename(filename);
398 
399         return filename;
400     }
401 
chooseExtensionFromMimeType(String mimeType, boolean useDefaults)402     private static String chooseExtensionFromMimeType(String mimeType, boolean useDefaults) {
403         String extension = null;
404         if (mimeType != null) {
405             extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
406             if (extension != null) {
407                 if (Constants.LOGVV) {
408                     Log.v(Constants.TAG, "adding extension from type");
409                 }
410                 extension = "." + extension;
411             } else {
412                 if (Constants.LOGVV) {
413                     Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
414                 }
415             }
416         }
417         if (extension == null) {
418             if (mimeType != null && mimeType.toLowerCase().startsWith("text/")) {
419                 if (mimeType.equalsIgnoreCase("text/html")) {
420                     if (Constants.LOGVV) {
421                         Log.v(Constants.TAG, "adding default html extension");
422                     }
423                     extension = Constants.DEFAULT_DL_HTML_EXTENSION;
424                 } else if (useDefaults) {
425                     if (Constants.LOGVV) {
426                         Log.v(Constants.TAG, "adding default text extension");
427                     }
428                     extension = Constants.DEFAULT_DL_TEXT_EXTENSION;
429                 }
430             } else if (useDefaults) {
431                 if (Constants.LOGVV) {
432                     Log.v(Constants.TAG, "adding default binary extension");
433                 }
434                 extension = Constants.DEFAULT_DL_BINARY_EXTENSION;
435             }
436         }
437         return extension;
438     }
439 
chooseExtensionFromFilename(String mimeType, int destination, String filename, int lastDotIndex)440     private static String chooseExtensionFromFilename(String mimeType, int destination,
441             String filename, int lastDotIndex) {
442         String extension = null;
443         if (mimeType != null) {
444             // Compare the last segment of the extension against the mime type.
445             // If there's a mismatch, discard the entire extension.
446             String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
447                     filename.substring(lastDotIndex + 1));
448             if (typeFromExt == null || !typeFromExt.equalsIgnoreCase(mimeType)) {
449                 extension = chooseExtensionFromMimeType(mimeType, false);
450                 if (extension != null) {
451                     if (Constants.LOGVV) {
452                         Log.v(Constants.TAG, "substituting extension from type");
453                     }
454                 } else {
455                     if (Constants.LOGVV) {
456                         Log.v(Constants.TAG, "couldn't find extension for " + mimeType);
457                     }
458                 }
459             }
460         }
461         if (extension == null) {
462             if (Constants.LOGVV) {
463                 Log.v(Constants.TAG, "keeping extension");
464             }
465             extension = filename.substring(lastDotIndex);
466         }
467         return extension;
468     }
469 
isFilenameAvailableLocked(File[] parents, String name)470     private static boolean isFilenameAvailableLocked(File[] parents, String name) {
471         if (Constants.RECOVERY_DIRECTORY.equalsIgnoreCase(name)) return false;
472 
473         for (File parent : parents) {
474             if (new File(parent, name).exists()) {
475                 return false;
476             }
477         }
478 
479         return true;
480     }
481 
generateAvailableFilenameLocked( File[] parents, String prefix, String suffix)482     private static String generateAvailableFilenameLocked(
483             File[] parents, String prefix, String suffix) throws IOException {
484         String name = prefix + suffix;
485         name = removeInvalidCharsAndGenerateName(name);
486         if (isFilenameAvailableLocked(parents, name)) {
487             return name;
488         }
489 
490         /*
491         * This number is used to generate partially randomized filenames to avoid
492         * collisions.
493         * It starts at 1.
494         * The next 9 iterations increment it by 1 at a time (up to 10).
495         * The next 9 iterations increment it by 1 to 10 (random) at a time.
496         * The next 9 iterations increment it by 1 to 100 (random) at a time.
497         * ... Up to the point where it increases by 100000000 at a time.
498         * (the maximum value that can be reached is 1000000000)
499         * As soon as a number is reached that generates a filename that doesn't exist,
500         *     that filename is used.
501         * If the filename coming in is [base].[ext], the generated filenames are
502         *     [base]-[sequence].[ext].
503         */
504         int sequence = 1;
505         for (int magnitude = 1; magnitude < 1000000000; magnitude *= 10) {
506             for (int iteration = 0; iteration < 9; ++iteration) {
507                 name = prefix + Constants.FILENAME_SEQUENCE_SEPARATOR + sequence + suffix;
508                 name = removeInvalidCharsAndGenerateName(name);
509                 if (isFilenameAvailableLocked(parents, name)) {
510                     return name;
511                 }
512                 sequence += sRandom.nextInt(magnitude) + 1;
513             }
514         }
515 
516         throw new IOException("Failed to generate an available filename");
517     }
518 
convertToMediaStoreDownloadsUri(Uri mediaStoreUri)519     public static Uri convertToMediaStoreDownloadsUri(Uri mediaStoreUri) {
520         final String volumeName = MediaStore.getVolumeName(mediaStoreUri);
521         final long id = android.content.ContentUris.parseId(mediaStoreUri);
522         return MediaStore.Downloads.getContentUri(volumeName, id);
523     }
524 
triggerMediaScan(android.content.ContentProviderClient mediaProviderClient, File file)525     public static Uri triggerMediaScan(android.content.ContentProviderClient mediaProviderClient,
526             File file) {
527         return MediaStore.scanFile(ContentResolver.wrap(mediaProviderClient), file);
528     }
529 
getContentUriForPath(Context context, String path)530     public static final Uri getContentUriForPath(Context context, String path) {
531         final StorageManager sm = context.getSystemService(StorageManager.class);
532         final String volumeName = sm.getStorageVolume(new File(path)).getMediaStoreVolumeName();
533         return MediaStore.Downloads.getContentUri(volumeName);
534     }
535 
isFileInExternalAndroidDirs(String filePath)536     public static boolean isFileInExternalAndroidDirs(String filePath) {
537         return PATTERN_ANDROID_DIRS.matcher(filePath).matches();
538     }
539 
isFilenameValid(Context context, File file)540     static boolean isFilenameValid(Context context, File file) {
541         return isFilenameValid(context, file, true);
542     }
543 
isFilenameValidInExternal(Context context, File file)544     static boolean isFilenameValidInExternal(Context context, File file) {
545         return isFilenameValid(context, file, false);
546     }
547 
548     /**
549      * Test if given file exists in one of the package-specific external storage
550      * directories that are always writable to apps, regardless of storage
551      * permission.
552      */
isFilenameValidInExternalPackage(File file, String packageName)553     static boolean isFilenameValidInExternalPackage(File file, String packageName) {
554         try {
555             if (containsCanonical(buildExternalStorageAppDataDirs(packageName), file) ||
556                     containsCanonical(buildExternalStorageAppObbDirs(packageName), file) ||
557                     containsCanonical(buildExternalStorageAppMediaDirs(packageName), file)) {
558                 return true;
559             }
560         } catch (IOException e) {
561             Log.w(TAG, "Failed to resolve canonical path: " + file.getAbsolutePath(), e);
562             return false;
563         }
564 
565         return false;
566     }
567 
isFilenameValidInExternalObbDir(File file)568     static boolean isFilenameValidInExternalObbDir(File file) {
569         try {
570             if (containsCanonical(buildExternalStorageAndroidObbDirs(), file)) {
571                 return true;
572             }
573         } catch (IOException e) {
574             Log.w(TAG, "Failed to resolve canonical path: " + file.getAbsolutePath(), e);
575             return false;
576         }
577 
578         return false;
579     }
580 
581     /**
582      * Check if given file exists in one of the private package-specific external storage
583      * directories.
584      */
isFileInPrivateExternalAndroidDirs(File file)585     static boolean isFileInPrivateExternalAndroidDirs(File file) {
586         try {
587             return PATTERN_ANDROID_PRIVATE_DIRS.matcher(file.getCanonicalPath()).matches();
588         } catch (IOException e) {
589             Log.w(TAG, "Failed to resolve canonical path: " + file.getAbsolutePath(), e);
590         }
591 
592         return false;
593     }
594 
595     /**
596      * Checks destination file path restrictions adhering to App privacy restrictions
597      *
598      * Note: This method is extracted to a static method for better test coverage.
599      */
600     @VisibleForTesting
checkDestinationFilePathRestrictions(File file, String callingPackage, Context context, AppOpsManager appOpsManager, String callingAttributionTag, boolean isLegacyMode, boolean allowDownloadsDirOnly)601     static void checkDestinationFilePathRestrictions(File file, String callingPackage,
602             Context context, AppOpsManager appOpsManager, String callingAttributionTag,
603             boolean isLegacyMode, boolean allowDownloadsDirOnly) {
604         boolean isFileNameValid = allowDownloadsDirOnly ? isFilenameValidInPublicDownloadsDir(file)
605                 : isFilenameValidInKnownPublicDir(file.getAbsolutePath());
606         if (isFilenameValidInExternalPackage(file, callingPackage) || isFileNameValid) {
607             // No permissions required for paths belonging to calling package or
608             // public downloads dir.
609             return;
610         } else if (isFilenameValidInExternalObbDir(file) &&
611                 isCallingAppInstaller(context, appOpsManager, callingPackage)) {
612             // Installers are allowed to download in OBB dirs, even outside their own package
613             return;
614         } else if (isFileInPrivateExternalAndroidDirs(file)) {
615             // Positive cases of writing to external Android dirs is covered in the if blocks above.
616             // If the caller made it this far, then it cannot write to this path as it is restricted
617             // from writing to other app's external Android dirs.
618             throw new SecurityException("Unsupported path " + file);
619         } else if (isLegacyMode && isFilenameValidInExternal(context, file)) {
620             // Otherwise we require write permission
621             context.enforceCallingOrSelfPermission(
622                     android.Manifest.permission.WRITE_EXTERNAL_STORAGE,
623                     "No permission to write to " + file);
624 
625             if (appOpsManager.noteProxyOp(AppOpsManager.OP_WRITE_EXTERNAL_STORAGE,
626                     callingPackage, Binder.getCallingUid(), callingAttributionTag, null)
627                     != AppOpsManager.MODE_ALLOWED) {
628                 throw new SecurityException("No permission to write to " + file);
629             }
630         } else {
631             throw new SecurityException("Unsupported path " + file);
632         }
633     }
634 
isCallingAppInstaller(Context context, AppOpsManager appOpsManager, String callingPackage)635     private static boolean isCallingAppInstaller(Context context, AppOpsManager appOpsManager,
636             String callingPackage) {
637         return (appOpsManager.noteOp(AppOpsManager.OP_REQUEST_INSTALL_PACKAGES,
638                 Binder.getCallingUid(), callingPackage, null, "obb_download")
639                 == AppOpsManager.MODE_ALLOWED)
640                 || (context.checkCallingOrSelfPermission(
641                 android.Manifest.permission.REQUEST_INSTALL_PACKAGES)
642                 == PackageManager.PERMISSION_GRANTED);
643     }
644 
isFilenameValidInPublicDownloadsDir(File file)645     static boolean isFilenameValidInPublicDownloadsDir(File file) {
646         try {
647             if (containsCanonical(buildExternalStoragePublicDirs(
648                     Environment.DIRECTORY_DOWNLOADS), file)) {
649                 return true;
650             }
651         } catch (IOException e) {
652             Log.w(TAG, "Failed to resolve canonical path: " + file.getAbsolutePath(), e);
653             return false;
654         }
655 
656         return false;
657     }
658 
659     @com.android.internal.annotations.VisibleForTesting
isFilenameValidInKnownPublicDir(@ullable String filePath)660     public static boolean isFilenameValidInKnownPublicDir(@Nullable String filePath) {
661         if (filePath == null) {
662             return false;
663         }
664         final Matcher matcher = PATTERN_PUBLIC_DIRS.matcher(filePath);
665         if (matcher.matches()) {
666             final String publicDir = matcher.group(1);
667             return ArrayUtils.contains(Environment.STANDARD_DIRECTORIES, publicDir);
668         }
669         return false;
670     }
671 
672     /**
673      * Checks whether the filename looks legitimate for security purposes. This
674      * prevents us from opening files that aren't actually downloads.
675      */
isFilenameValid(Context context, File file, boolean allowInternal)676     static boolean isFilenameValid(Context context, File file, boolean allowInternal) {
677         try {
678             if (allowInternal) {
679                 if (containsCanonical(context.getFilesDir(), file)
680                         || containsCanonical(context.getCacheDir(), file)
681                         || containsCanonical(Environment.getDownloadCacheDirectory(), file)) {
682                     return true;
683                 }
684             }
685 
686             final StorageVolume[] volumes = StorageManager.getVolumeList(UserHandle.myUserId(),
687                     StorageManager.FLAG_FOR_WRITE);
688             for (StorageVolume volume : volumes) {
689                 if (containsCanonical(volume.getPathFile(), file)) {
690                     return true;
691                 }
692             }
693         } catch (IOException e) {
694             Log.w(TAG, "Failed to resolve canonical path: " + file.getAbsolutePath(), e);
695             return false;
696         }
697 
698         return false;
699     }
700 
701     /**
702      * Shamelessly borrowed from
703      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
704      */
705     private static final Pattern PATTERN_RELATIVE_PATH = Pattern.compile(
706             "(?i)^/storage/(?:emulated/[0-9]+/|[^/]+/)(Android/sandbox/([^/]+)/)?");
707 
708     /**
709      * Shamelessly borrowed from
710      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
711      */
712     private static final Pattern PATTERN_VOLUME_NAME = Pattern.compile(
713             "(?i)^/storage/([^/]+)");
714 
715     /**
716      * Shamelessly borrowed from
717      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
718      */
normalizeUuid(@ullable String fsUuid)719     private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
720         return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
721     }
722 
723     /**
724      * Shamelessly borrowed from
725      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
726      */
extractVolumeName(@ullable String data)727     public static @Nullable String extractVolumeName(@Nullable String data) {
728         if (data == null) return null;
729         final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
730         if (matcher.find()) {
731             final String volumeName = matcher.group(1);
732             if (volumeName.equals("emulated")) {
733                 return MediaStore.VOLUME_EXTERNAL_PRIMARY;
734             } else {
735                 return normalizeUuid(volumeName);
736             }
737         } else {
738             return MediaStore.VOLUME_INTERNAL;
739         }
740     }
741 
742     /**
743      * Shamelessly borrowed from
744      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
745      */
extractRelativePath(@ullable String data)746     public static @Nullable String extractRelativePath(@Nullable String data) {
747         if (data == null) return null;
748         final Matcher matcher = PATTERN_RELATIVE_PATH.matcher(data);
749         if (matcher.find()) {
750             final int lastSlash = data.lastIndexOf('/');
751             if (lastSlash == -1 || lastSlash < matcher.end()) {
752                 // This is a file in the top-level directory, so relative path is "/"
753                 // which is different than null, which means unknown path
754                 return "/";
755             } else {
756                 return data.substring(matcher.end(), lastSlash + 1);
757             }
758         } else {
759             return null;
760         }
761     }
762 
763     /**
764      * Shamelessly borrowed from
765      * {@code packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java}.
766      */
extractDisplayName(@ullable String data)767     public static @Nullable String extractDisplayName(@Nullable String data) {
768         if (data == null) return null;
769         if (data.indexOf('/') == -1) {
770             return data;
771         }
772         if (data.endsWith("/")) {
773             data = data.substring(0, data.length() - 1);
774         }
775         return data.substring(data.lastIndexOf('/') + 1);
776     }
777 
containsCanonical(File dir, File file)778     private static boolean containsCanonical(File dir, File file) throws IOException {
779         return FileUtils.contains(dir.getCanonicalFile(), file);
780     }
781 
containsCanonical(File[] dirs, File file)782     private static boolean containsCanonical(File[] dirs, File file) throws IOException {
783         for (File dir : dirs) {
784             if (containsCanonical(dir, file)) {
785                 return true;
786             }
787         }
788         return false;
789     }
790 
getRunningDestinationDirectory(Context context, int destination)791     public static File getRunningDestinationDirectory(Context context, int destination)
792             throws IOException {
793         return getDestinationDirectory(context, destination, true);
794     }
795 
getSuccessDestinationDirectory(Context context, int destination)796     public static File getSuccessDestinationDirectory(Context context, int destination)
797             throws IOException {
798         return getDestinationDirectory(context, destination, false);
799     }
800 
getDestinationDirectory(Context context, int destination, boolean running)801     private static File getDestinationDirectory(Context context, int destination, boolean running)
802             throws IOException {
803         switch (destination) {
804             case Downloads.Impl.DESTINATION_CACHE_PARTITION:
805             case Downloads.Impl.DESTINATION_CACHE_PARTITION_PURGEABLE:
806             case Downloads.Impl.DESTINATION_CACHE_PARTITION_NOROAMING:
807                 if (running) {
808                     return context.getFilesDir();
809                 } else {
810                     return context.getCacheDir();
811                 }
812 
813             case Downloads.Impl.DESTINATION_EXTERNAL:
814                 final File target = new File(
815                         Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOWNLOADS);
816                 if (!target.isDirectory() && target.mkdirs()) {
817                     throw new IOException("unable to create external downloads directory");
818                 }
819                 return target;
820 
821             default:
822                 throw new IllegalStateException("unexpected destination: " + destination);
823         }
824     }
825 
826     @VisibleForTesting
handleRemovedUidEntries(@onNull Context context, ContentProvider downloadProvider, int removedUid)827     public static void handleRemovedUidEntries(@NonNull Context context,
828             ContentProvider downloadProvider, int removedUid) {
829         final SparseArray<String> knownUids = new SparseArray<>();
830         final ArrayList<Long> idsToDelete = new ArrayList<>();
831         final ArrayList<Long> idsToOrphan = new ArrayList<>();
832         final String selection = removedUid == INVALID_UID ? Constants.UID + " IS NOT NULL"
833                 : Constants.UID + "=" + removedUid;
834         try (Cursor cursor = downloadProvider.query(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
835                 new String[] { Downloads.Impl._ID, Constants.UID, COLUMN_DESTINATION, _DATA },
836                 selection, null, null)) {
837             while (cursor.moveToNext()) {
838                 final long downloadId = cursor.getLong(0);
839                 final int uid = cursor.getInt(1);
840 
841                 final String ownerPackageName;
842                 final int index = knownUids.indexOfKey(uid);
843                 if (index >= 0) {
844                     ownerPackageName = knownUids.valueAt(index);
845                 } else {
846                     ownerPackageName = getPackageForUid(context, uid);
847                     knownUids.put(uid, ownerPackageName);
848                 }
849 
850                 if (ownerPackageName == null) {
851                     final int destination = cursor.getInt(2);
852                     final String filePath = cursor.getString(3);
853 
854                     if ((destination == DESTINATION_EXTERNAL
855                             || destination == DESTINATION_FILE_URI
856                             || destination == DESTINATION_NON_DOWNLOADMANAGER_DOWNLOAD)
857                             && isFilenameValidInKnownPublicDir(filePath)) {
858                         idsToOrphan.add(downloadId);
859                     } else {
860                         idsToDelete.add(downloadId);
861                     }
862                 }
863             }
864         }
865 
866         if (idsToOrphan.size() > 0) {
867             Log.i(Constants.TAG, "Orphaning downloads with ids "
868                     + Arrays.toString(idsToOrphan.toArray()) + " as owner package is removed");
869             final ContentValues values = new ContentValues();
870             values.putNull(Constants.UID);
871             downloadProvider.update(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI, values,
872                     buildQueryWithIds(idsToOrphan), null);
873         }
874         if (idsToDelete.size() > 0) {
875             Log.i(Constants.TAG, "Deleting downloads with ids "
876                     + Arrays.toString(idsToDelete.toArray()) + " as owner package is removed");
877             downloadProvider.delete(Downloads.Impl.ALL_DOWNLOADS_CONTENT_URI,
878                     buildQueryWithIds(idsToDelete), null);
879         }
880     }
881 
buildQueryWithIds(ArrayList<Long> downloadIds)882     public static String buildQueryWithIds(ArrayList<Long> downloadIds) {
883         final StringBuilder queryBuilder = new StringBuilder(Downloads.Impl._ID + " in (");
884         final int size = downloadIds.size();
885         for (int i = 0; i < size; i++) {
886             queryBuilder.append(downloadIds.get(i));
887             queryBuilder.append((i == size - 1) ? ")" : ",");
888         }
889         return queryBuilder.toString();
890     }
891 
getPackageForUid(Context context, int uid)892     public static String getPackageForUid(Context context, int uid) {
893         String[] packages = context.getPackageManager().getPackagesForUid(uid);
894         if (packages == null || packages.length == 0) {
895             return null;
896         }
897         // For permission related purposes, any package belonging to the given uid should work.
898         return packages[0];
899     }
900 
removeInvalidCharsAndGenerateName(String name)901     public static String removeInvalidCharsAndGenerateName(String name) {
902         String newValue = name.replaceAll("[*/:<>?\\|]", "_");
903         if (onlyContainsUnderscore(newValue)) {
904             newValue = DEFAULT_DOWNLOAD_FILE_NAME_PREFIX + System.currentTimeMillis();
905         }
906         return newValue;
907     }
908 
onlyContainsUnderscore(String name)909     private static boolean onlyContainsUnderscore(String name) {
910         return name != null && name.replaceAll("_","").trim().isEmpty();
911     }
912 }
913