From 3cfee662547f060c171decb780b8f494b2fb3e42 Mon Sep 17 00:00:00 2001 From: yayaws_zk <584363327@qq.com> Date: Fri, 8 Aug 2025 00:10:01 +0800 Subject: [PATCH] fix screencap, fuse,tmpfs size,language --- .../base/cmds/screencap/screencap.cpp | 316 + .../am/ActivityManagerShellCommand.java | 4541 ++++++ .../providers/media/MediaProvider.java | 11680 ++++++++++++++++ aosp/system/core/init/first_stage_init.cpp | 2 +- aosp/vendor/isula/properties.mk | 1 + 5 files changed, 16539 insertions(+), 1 deletion(-) create mode 100644 aosp/frameworks/base/cmds/screencap/screencap.cpp create mode 100644 aosp/frameworks/base/services/core/java/com/android/server/am/ActivityManagerShellCommand.java create mode 100644 aosp/packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java diff --git a/aosp/frameworks/base/cmds/screencap/screencap.cpp b/aosp/frameworks/base/cmds/screencap/screencap.cpp new file mode 100644 index 000000000..b824f2bd6 --- /dev/null +++ b/aosp/frameworks/base/cmds/screencap/screencap.cpp @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2010 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. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +using namespace android; + +#define COLORSPACE_UNKNOWN 0 +#define COLORSPACE_SRGB 1 +#define COLORSPACE_DISPLAY_P3 2 + +void usage(const char* pname, ftl::Optional displayIdOpt) { + fprintf(stderr, R"( +usage: %s [-hp] [-d display-id] [FILENAME] + -h: this message + -p: save the file as a png. + -d: specify the display ID to capture%s + see "dumpsys SurfaceFlinger --display-id" for valid display IDs. + --hint-for-seamless If set will use the hintForSeamless path in SF + +If FILENAME ends with .png it will be saved as a png. +If FILENAME is not given, the results will be printed to stdout. +)", + pname, + displayIdOpt + .transform([](DisplayId id) { + return std::string(ftl::Concat(" (default: ", id.value, ')').str()); + }) + .value_or(std::string()) + .c_str()); +} + +// For options that only exist in long-form. Anything in the +// 0-255 range is reserved for short options (which just use their ASCII value) +namespace LongOpts { +enum { + Reserved = 255, + HintForSeamless, +}; +} + +static const struct option LONG_OPTIONS[] = { + {"png", no_argument, nullptr, 'p'}, + {"help", no_argument, nullptr, 'h'}, + {"hint-for-seamless", no_argument, nullptr, LongOpts::HintForSeamless}, + {0, 0, 0, 0}}; + +static int32_t flinger2bitmapFormat(PixelFormat f) +{ + switch (f) { + case PIXEL_FORMAT_RGB_565: + return ANDROID_BITMAP_FORMAT_RGB_565; + default: + return ANDROID_BITMAP_FORMAT_RGBA_8888; + } +} + +static uint32_t dataSpaceToInt(ui::Dataspace d) +{ + switch (d) { + case ui::Dataspace::V0_SRGB: + return COLORSPACE_SRGB; + case ui::Dataspace::DISPLAY_P3: + return COLORSPACE_DISPLAY_P3; + default: + return COLORSPACE_UNKNOWN; + } +} + +static status_t notifyMediaScanner(const char* fileName) { + char absolutePath[PATH_MAX] = {0}; + if (realpath(fileName, absolutePath) == nullptr) { + fprintf(stderr, "Get realpath failed.\n"); + return INVALID_OPERATION; + } + std::string filePath("file://"); + filePath.append(absolutePath); + char *cmd[] = { + (char*) "am", + (char*) "broadcast", + (char*) "-a", + (char*) "android.intent.action.MEDIA_SCANNER_SCAN_FILE", + (char*) "-d", + &filePath[0], + nullptr + }; + + int status; + int pid = fork(); + if (pid < 0){ + fprintf(stderr, "Unable to fork in order to send intent for media scanner.\n"); + return UNKNOWN_ERROR; + } + if (pid == 0){ + int fd = open("/dev/null", O_WRONLY); + if (fd < 0){ + fprintf(stderr, "Unable to open /dev/null for media scanner stdout redirection.\n"); + exit(1); + } + dup2(fd, 1); + int result = execvp(cmd[0], cmd); + close(fd); + exit(result); + } + wait(&status); + + if (status < 0) { + fprintf(stderr, "Unable to broadcast intent for media scanner.\n"); + return UNKNOWN_ERROR; + } + return NO_ERROR; +} + +int main(int argc, char** argv) +{ + const std::vector ids = SurfaceComposerClient::getPhysicalDisplayIds(); + if (ids.empty()) { + fprintf(stderr, "Failed to get ID for any displays.\n"); + return 1; + } + std::optional displayIdOpt; + gui::CaptureArgs captureArgs; + const char* pname = argv[0]; + bool png = false; + int c; + while ((c = getopt_long(argc, argv, "phd:", LONG_OPTIONS, nullptr)) != -1) { + switch (c) { + case 'p': + png = true; + break; + case 'd': { + errno = 0; + char* end = nullptr; + const uint64_t id = strtoull(optarg, &end, 10); + if (!end || *end != '\0' || errno == ERANGE) { + fprintf(stderr, "Invalid display ID: Out of range [0, 2^64).\n"); + return 1; + } + + displayIdOpt = DisplayId::fromValue(id); + if (!displayIdOpt) { + fprintf(stderr, "Invalid display ID: Incorrect encoding.\n"); + return 1; + } + break; + } + case '?': + case 'h': + if (ids.size() == 1) { + displayIdOpt = ids.front(); + } + usage(pname, displayIdOpt); + return 1; + case LongOpts::HintForSeamless: + captureArgs.hintForSeamlessTransition = true; + break; + } + } + + if (!displayIdOpt) { + displayIdOpt = ids.front(); + if (ids.size() > 1) { + fprintf(stderr, + "[Warning] Multiple displays were found, but no display id was specified! " + "Defaulting to the first display found, however this default is not guaranteed " + "to be consistent across captures. A display id should be specified.\n"); + fprintf(stderr, "A display ID can be specified with the [-d display-id] option.\n"); + fprintf(stderr, "See \"dumpsys SurfaceFlinger --display-id\" for valid display IDs.\n"); + } + } + + argc -= optind; + argv += optind; + + int fd = -1; + const char* fn = NULL; + if (argc == 0) { + fd = dup(STDOUT_FILENO); + } else if (argc == 1) { + fn = argv[0]; + fd = open(fn, O_WRONLY | O_CREAT | O_TRUNC, 0664); + if (fd == -1) { + fprintf(stderr, "Error opening file: %s (%s)\n", fn, strerror(errno)); + return 1; + } + const int len = strlen(fn); + if (len >= 4 && 0 == strcmp(fn+len-4, ".png")) { + png = true; + } + } + + if (fd == -1) { + usage(pname, displayIdOpt); + return 1; + } + + void* base = NULL; + + // setThreadPoolMaxThreadCount(0) actually tells the kernel it's + // not allowed to spawn any additional threads, but we still spawn + // a binder thread from userspace when we call startThreadPool(). + // See b/36066697 for rationale + ProcessState::self()->setThreadPoolMaxThreadCount(0); + ProcessState::self()->startThreadPool(); + + sp captureListener = new SyncScreenCaptureListener(); + ScreenshotClient::captureDisplay(*displayIdOpt, captureArgs, captureListener); + + ScreenCaptureResults captureResults = captureListener->waitForResults(); + if (!captureResults.fenceResult.ok()) { + close(fd); + fprintf(stderr, "Failed to take screenshot. Status: %d\n", + fenceStatus(captureResults.fenceResult)); + return 1; + } + ui::Dataspace dataspace = captureResults.capturedDataspace; + sp buffer = captureResults.buffer; + + status_t result = buffer->lock(GraphicBuffer::USAGE_SW_READ_OFTEN, &base); + + if (base == nullptr || result != NO_ERROR) { + String8 reason; + if (result != NO_ERROR) { + reason.appendFormat(" Error Code: %d", result); + } else { + reason = "Failed to write to buffer"; + } + fprintf(stderr, "Failed to take screenshot (%s)\n", reason.c_str()); + close(fd); + return 1; + } + + if (png) { + AndroidBitmapInfo info; + info.format = flinger2bitmapFormat(buffer->getPixelFormat()); + info.flags = ANDROID_BITMAP_FLAGS_ALPHA_PREMUL; + info.width = buffer->getWidth(); + info.height = buffer->getHeight(); + info.stride = buffer->getStride() * bytesPerPixel(buffer->getPixelFormat()); + + int result = AndroidBitmap_compress(&info, static_cast(dataspace), base, + ANDROID_BITMAP_COMPRESS_FORMAT_PNG, 100, &fd, + [](void* fdPtr, const void* data, size_t size) -> bool { + int bytesWritten = write(*static_cast(fdPtr), + data, size); + return bytesWritten == size; + }); + + if (result != ANDROID_BITMAP_RESULT_SUCCESS) { + fprintf(stderr, "Failed to compress PNG (error code: %d)\n", result); + } + + if (fn != NULL) { + notifyMediaScanner(fn); + } + } else { + uint32_t w = buffer->getWidth(); + uint32_t h = buffer->getHeight(); + uint32_t s = buffer->getStride(); + uint32_t f = buffer->getPixelFormat(); + uint32_t c = dataSpaceToInt(dataspace); + + write(fd, &w, 4); + write(fd, &h, 4); + write(fd, &f, 4); + write(fd, &c, 4); + size_t Bpp = bytesPerPixel(f); + for (size_t y=0 ; y activities = mPm.queryIntentActivities(intent, mimeType, + 0, userIdForQuery).getList(); + if (activities == null || activities.size() <= 0) { + getErrPrintWriter().println("Error: Intent does not match any activities: " + + intent); + return 1; + } else if (activities.size() > 1) { + getErrPrintWriter().println( + "Error: Intent matches multiple activities; can't stop: " + + intent); + return 1; + } + packageName = activities.get(0).activityInfo.packageName; + } + pw.println("Stopping: " + packageName); + pw.flush(); + mInterface.forceStopPackage(packageName, mUserId); + try { + Thread.sleep(250); + } catch (InterruptedException e) { + } + } + + ProfilerInfo profilerInfo = null; + + if (mProfileFile != null || mAgent != null) { + ParcelFileDescriptor fd = null; + if (mProfileFile != null) { + fd = openFileForSystem(mProfileFile, "w"); + if (fd == null) { + return 1; + } + } + profilerInfo = new ProfilerInfo(mProfileFile, fd, mSamplingInterval, mAutoStop, + mStreaming, mAgent, mAttachAgentDuringBind, mClockType); + } + + pw.println("Starting: " + intent); + pw.flush(); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + WaitResult result = null; + int res; + final long startTime = SystemClock.uptimeMillis(); + ActivityOptions options = null; + if (mDisplayId != INVALID_DISPLAY) { + options = ActivityOptions.makeBasic(); + options.setLaunchDisplayId(mDisplayId); + } + if (mTaskDisplayAreaFeatureId != FEATURE_UNDEFINED) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setLaunchTaskDisplayAreaFeatureId(mTaskDisplayAreaFeatureId); + } + if (mWindowingMode != WINDOWING_MODE_UNDEFINED) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setLaunchWindowingMode(mWindowingMode); + } + if (mActivityType != ACTIVITY_TYPE_UNDEFINED) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setLaunchActivityType(mActivityType); + } + if (mTaskId != INVALID_TASK_ID) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setLaunchTaskId(mTaskId); + + if (mIsTaskOverlay) { + options.setTaskOverlay(true, true /* canResume */); + } + } + if (mIsLockTask) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setLockTaskEnabled(true); + } + if (mShowSplashScreen) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setSplashScreenStyle(SplashScreen.SPLASH_SCREEN_STYLE_ICON); + } + if (mDismissKeyguardIfInsecure) { + if (options == null) { + options = ActivityOptions.makeBasic(); + } + options.setDismissKeyguardIfInsecure(); + } + if (mWaitOption) { + result = mInternal.startActivityAndWait(null, SHELL_PACKAGE_NAME, null, intent, + mimeType, null, null, 0, mStartFlags, profilerInfo, + options != null ? options.toBundle() : null, mUserId); + res = result.result; + } else { + res = mInternal.startActivityAsUserWithFeature(null, SHELL_PACKAGE_NAME, null, + intent, mimeType, null, null, 0, mStartFlags, profilerInfo, + options != null ? options.toBundle() : null, mUserId); + } + final long endTime = SystemClock.uptimeMillis(); + PrintWriter out = mWaitOption ? pw : getErrPrintWriter(); + boolean launched = false; + switch (res) { + case ActivityManager.START_SUCCESS: + launched = true; + break; + case ActivityManager.START_SWITCHES_CANCELED: + launched = true; + out.println( + "Warning: Activity not started because the " + + " current activity is being kept for the user."); + break; + case ActivityManager.START_DELIVERED_TO_TOP: + launched = true; + out.println( + "Warning: Activity not started, intent has " + + "been delivered to currently running " + + "top-most instance."); + break; + case ActivityManager.START_RETURN_INTENT_TO_CALLER: + launched = true; + out.println( + "Warning: Activity not started because intent " + + "should be handled by the caller"); + break; + case ActivityManager.START_TASK_TO_FRONT: + launched = true; + out.println( + "Warning: Activity not started, its current " + + "task has been brought to the front"); + break; + case ActivityManager.START_INTENT_NOT_RESOLVED: + out.println( + "Error: Activity not started, unable to " + + "resolve " + intent.toString()); + return 1; + case ActivityManager.START_CLASS_NOT_FOUND: + out.println(NO_CLASS_ERROR_CODE); + out.println("Error: Activity class " + + intent.getComponent().toShortString() + + " does not exist."); + return 1; + case ActivityManager.START_FORWARD_AND_REQUEST_CONFLICT: + out.println( + "Error: Activity not started, you requested to " + + "both forward and receive its result"); + return 1; + case ActivityManager.START_PERMISSION_DENIED: + out.println( + "Error: Activity not started, you do not " + + "have permission to access it."); + return 1; + case ActivityManager.START_NOT_VOICE_COMPATIBLE: + out.println( + "Error: Activity not started, voice control not allowed for: " + + intent); + return 1; + case ActivityManager.START_NOT_CURRENT_USER_ACTIVITY: + out.println( + "Error: Not allowed to start background user activity" + + " that shouldn't be displayed for all users."); + return 1; + default: + out.println( + "Error: Activity not started, unknown error code " + res); + return 1; + } + out.flush(); + if (mWaitOption && launched) { + if (result == null) { + result = new WaitResult(); + result.who = intent.getComponent(); + } + pw.println("Status: " + (result.timeout ? "timeout" : "ok")); + pw.println("LaunchState: " + launchStateToString(result.launchState)); + if (result.who != null) { + pw.println("Activity: " + result.who.flattenToShortString()); + } + if (result.totalTime >= 0) { + pw.println("TotalTime: " + result.totalTime); + } + pw.println("WaitTime: " + (endTime-startTime)); + pw.println("Complete"); + pw.flush(); + } + mRepeat--; + if (mRepeat > 0) { + mTaskInterface.unhandledBack(); + } + } while (mRepeat > 0); + return 0; + } + + int runStartService(PrintWriter pw, boolean asForeground) throws RemoteException { + final PrintWriter err = getErrPrintWriter(); + Intent intent; + try { + intent = makeIntent(UserHandle.USER_CURRENT); + } catch (URISyntaxException e) { + throw new RuntimeException(e.getMessage(), e); + } + if (mUserId == UserHandle.USER_ALL) { + err.println("Error: Can't start activity with user 'all'"); + return -1; + } + pw.println("Starting service: " + intent); + pw.flush(); + ComponentName cn = mInterface.startService(null, intent, intent.getType(), + asForeground, SHELL_PACKAGE_NAME, null, mUserId); + if (cn == null) { + err.println("Error: Not found; no service started."); + return -1; + } else if (cn.getPackageName().equals("!")) { + err.println("Error: Requires permission " + cn.getClassName()); + return -1; + } else if (cn.getPackageName().equals("!!")) { + err.println("Error: " + cn.getClassName()); + return -1; + } else if (cn.getPackageName().equals("?")) { + err.println("Error: " + cn.getClassName()); + return -1; + } + return 0; + } + + int runStopService(PrintWriter pw) throws RemoteException { + final PrintWriter err = getErrPrintWriter(); + Intent intent; + try { + intent = makeIntent(UserHandle.USER_CURRENT); + } catch (URISyntaxException e) { + throw new RuntimeException(e.getMessage(), e); + } + if (mUserId == UserHandle.USER_ALL) { + err.println("Error: Can't stop activity with user 'all'"); + return -1; + } + pw.println("Stopping service: " + intent); + pw.flush(); + int result = mInterface.stopService(null, intent, intent.getType(), mUserId); + if (result == 0) { + err.println("Service not stopped: was not running."); + return -1; + } else if (result == 1) { + err.println("Service stopped"); + return -1; + } else if (result == -1) { + err.println("Error stopping service"); + return -1; + } + return 0; + } + + final static class IntentReceiver extends IIntentReceiver.Stub { + private final PrintWriter mPw; + private boolean mFinished = false; + + IntentReceiver(PrintWriter pw) { + mPw = pw; + } + + @Override + public void performReceive(Intent intent, int resultCode, String data, Bundle extras, + boolean ordered, boolean sticky, int sendingUser) { + String line = "Broadcast completed: result=" + resultCode; + if (data != null) line = line + ", data=\"" + data + "\""; + if (extras != null) line = line + ", extras: " + extras; + mPw.println(line); + mPw.flush(); + synchronized (this) { + mFinished = true; + notifyAll(); + } + } + + public synchronized void waitForFinish() { + try { + while (!mFinished) wait(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + } + } + + int runSendBroadcast(PrintWriter pw) throws RemoteException { + pw = new PrintWriter(new TeeWriter(LOG_WRITER_INFO, pw)); + Intent intent; + try { + intent = makeIntent(UserHandle.USER_CURRENT); + } catch (URISyntaxException e) { + throw new RuntimeException(e.getMessage(), e); + } + intent.addFlags(Intent.FLAG_RECEIVER_FROM_SHELL); + IntentReceiver receiver = new IntentReceiver(pw); + String[] requiredPermissions = mReceiverPermission == null ? null + : new String[] {mReceiverPermission}; + pw.println("Broadcasting: " + intent); + pw.flush(); + Bundle bundle = mBroadcastOptions == null ? null : mBroadcastOptions.toBundle(); + final int result = mInterface.broadcastIntentWithFeature(null, null, intent, null, + receiver, 0, null, null, requiredPermissions, null, null, + android.app.AppOpsManager.OP_NONE, bundle, true, false, mUserId); + Slogf.i(TAG, "Enqueued broadcast %s: " + result, intent); + if (result == ActivityManager.BROADCAST_SUCCESS && !mAsync) { + receiver.waitForFinish(); + } + return 0; + } + + int runTraceIpc(PrintWriter pw) throws RemoteException { + String op = getNextArgRequired(); + if (op.equals("start")) { + return runTraceIpcStart(pw); + } else if (op.equals("stop")) { + return runTraceIpcStop(pw); + } else { + getErrPrintWriter().println("Error: unknown trace ipc command '" + op + "'"); + return -1; + } + } + + int runTraceIpcStart(PrintWriter pw) throws RemoteException { + pw.println("Starting IPC tracing."); + pw.flush(); + mInterface.startBinderTracking(); + return 0; + } + + int runTraceIpcStop(PrintWriter pw) throws RemoteException { + final PrintWriter err = getErrPrintWriter(); + String opt; + String filename = null; + while ((opt=getNextOption()) != null) { + if (opt.equals("--dump-file")) { + filename = getNextArgRequired(); + } else { + err.println("Error: Unknown option: " + opt); + return -1; + } + } + if (filename == null) { + err.println("Error: Specify filename to dump logs to."); + return -1; + } + + // Writes an error message to stderr on failure + ParcelFileDescriptor fd = openFileForSystem(filename, "w"); + if (fd == null) { + return -1; + } + + if (!mInterface.stopBinderTrackingAndDump(fd)) { + err.println("STOP TRACE FAILED."); + return -1; + } + + pw.println("Stopped IPC tracing. Dumping logs to: " + filename); + return 0; + } + + // NOTE: current profiles can only be started on default display (even on automotive builds with + // passenger displays), so there's no need to pass a display-id + private int runProfile(PrintWriter pw) throws RemoteException { + final PrintWriter err = getErrPrintWriter(); + String profileFile = null; + boolean start = false; + int userId = UserHandle.USER_CURRENT; + int profileType = 0; + mSamplingInterval = 0; + mStreaming = false; + mClockType = ProfilerInfo.CLOCK_TYPE_DEFAULT; + + String process = null; + + String cmd = getNextArgRequired(); + + if ("start".equals(cmd)) { + start = true; + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else if (opt.equals("--clock-type")) { + String clock_type = getNextArgRequired(); + mClockType = ProfilerInfo.getClockTypeFromString(clock_type); + } else if (opt.equals("--streaming")) { + mStreaming = true; + } else if (opt.equals("--sampling")) { + mSamplingInterval = Integer.parseInt(getNextArgRequired()); + } else { + err.println("Error: Unknown option: " + opt); + return -1; + } + } + process = getNextArgRequired(); + } else if ("stop".equals(cmd)) { + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + err.println("Error: Unknown option: " + opt); + return -1; + } + } + process = getNextArgRequired(); + } else { + // Compatibility with old syntax: process is specified first. + process = cmd; + cmd = getNextArgRequired(); + if ("start".equals(cmd)) { + start = true; + } else if (!"stop".equals(cmd)) { + throw new IllegalArgumentException("Profile command " + process + " not valid"); + } + } + + if (userId == UserHandle.USER_ALL) { + err.println("Error: Can't profile with user 'all'"); + return -1; + } + + ParcelFileDescriptor fd = null; + ProfilerInfo profilerInfo = null; + + if (start) { + profileFile = getNextArgRequired(); + fd = openFileForSystem(profileFile, "w"); + if (fd == null) { + return -1; + } + profilerInfo = new ProfilerInfo(profileFile, fd, mSamplingInterval, false, mStreaming, + null, false, mClockType); + } + + if (!mInterface.profileControl(process, userId, start, profilerInfo, profileType)) { + err.println("PROFILE FAILED on process " + process); + return -1; + } + return 0; + } + + @NeverCompile + int runCompact(PrintWriter pw) throws RemoteException { + ProcessRecord app; + String op = getNextArgRequired(); + boolean isFullCompact = op.equals("full"); + boolean isSomeCompact = op.equals("some"); + if (isFullCompact || isSomeCompact) { + app = getProcessFromShell(); + if (app == null) { + getErrPrintWriter().println("Error: could not find process"); + return -1; + } + pw.println("Process record found pid: " + app.mPid); + if (isFullCompact) { + pw.println("Executing full compaction for " + app.mPid); + synchronized (mInternal.mProcLock) { + mInternal.mOomAdjuster.mCachedAppOptimizer.compactApp(app, + CachedAppOptimizer.CompactProfile.FULL, + CachedAppOptimizer.CompactSource.SHELL, true); + } + pw.println("Finished full compaction for " + app.mPid); + } else if (isSomeCompact) { + pw.println("Executing some compaction for " + app.mPid); + synchronized (mInternal.mProcLock) { + mInternal.mOomAdjuster.mCachedAppOptimizer.compactApp(app, + CachedAppOptimizer.CompactProfile.SOME, + CachedAppOptimizer.CompactSource.SHELL, true); + } + pw.println("Finished some compaction for " + app.mPid); + } + } else if (op.equals("system")) { + pw.println("Executing system compaction"); + synchronized (mInternal.mProcLock) { + mInternal.mOomAdjuster.mCachedAppOptimizer.compactAllSystem(); + } + pw.println("Finished system compaction"); + } else if (op.equals("native")) { + op = getNextArgRequired(); + isFullCompact = op.equals("full"); + isSomeCompact = op.equals("some"); + int pid; + String pidStr = getNextArgRequired(); + try { + pid = Integer.parseInt(pidStr); + } catch (Exception e) { + getErrPrintWriter().println("Error: failed to parse '" + pidStr + "' as a PID"); + return -1; + } + if (isFullCompact) { + mInternal.mOomAdjuster.mCachedAppOptimizer.compactNative( + CachedAppOptimizer.CompactProfile.FULL, pid); + } else if (isSomeCompact) { + mInternal.mOomAdjuster.mCachedAppOptimizer.compactNative( + CachedAppOptimizer.CompactProfile.SOME, pid); + } else { + getErrPrintWriter().println("Error: unknown compaction type '" + op + "'"); + return -1; + } + } else { + getErrPrintWriter().println("Error: unknown compact command '" + op + "'"); + return -1; + } + + return 0; + } + + @NeverCompile + int runFreeze(PrintWriter pw) throws RemoteException { + String freezerOpt = getNextOption(); + boolean isSticky = false; + if (freezerOpt != null) { + isSticky = freezerOpt.equals("--sticky"); + } + ProcessRecord app = getProcessFromShell(); + if (app == null) { + getErrPrintWriter().println("Error: could not find process"); + return -1; + } + pw.println("Freezing pid: " + app.mPid + " sticky=" + isSticky); + synchronized (mInternal) { + synchronized (mInternal.mProcLock) { + app.mOptRecord.setFreezeSticky(isSticky); + mInternal.mOomAdjuster.mCachedAppOptimizer.freezeAppAsyncInternalLSP(app, 0, true); + } + } + return 0; + } + + @NeverCompile + int runUnfreeze(PrintWriter pw) throws RemoteException { + String freezerOpt = getNextOption(); + boolean isSticky = false; + if (freezerOpt != null) { + isSticky = freezerOpt.equals("--sticky"); + } + ProcessRecord app = getProcessFromShell(); + if (app == null) { + getErrPrintWriter().println("Error: could not find process"); + return -1; + } + pw.println("Unfreezing pid: " + app.mPid); + synchronized (mInternal) { + synchronized (mInternal.mProcLock) { + synchronized (mInternal.mOomAdjuster.mCachedAppOptimizer.mFreezerLock) { + app.mOptRecord.setFreezeSticky(isSticky); + mInternal.mOomAdjuster.mCachedAppOptimizer.unfreezeAppInternalLSP(app, 0, + true); + } + } + } + return 0; + } + + /** + * Parses from the shell the process name and user id if provided and provides the corresponding + * {@link ProcessRecord)} If no user is provided, it will fallback to current user. + * Example usage: {@code --user current} or {@code } + * @return process record of process, null if none found. + * @throws RemoteException + */ + @NeverCompile + ProcessRecord getProcessFromShell() throws RemoteException { + ProcessRecord app; + String processName = getNextArgRequired(); + synchronized (mInternal.mProcLock) { + // Default to current user + int userId = getUserIdFromShellOrFallback(); + final int uid = + mInternal.getPackageManagerInternal().getPackageUid(processName, 0, userId); + app = mInternal.getProcessRecordLocked(processName, uid); + } + return app; + } + + /** + * @return User id from command line provided in the form of + * {@code --user } and if the argument is not found it will fallback + * to current user. + * @throws RemoteException + */ + @NeverCompile + int getUserIdFromShellOrFallback() throws RemoteException { + int userId = mInterface.getCurrentUserId(); + String userOpt = getNextOption(); + if (userOpt != null && "--user".equals(userOpt)) { + int inputUserId = UserHandle.parseUserArg(getNextArgRequired()); + if (inputUserId != UserHandle.USER_CURRENT) { + userId = inputUserId; + } + } + return userId; + } + + int runDumpHeap(PrintWriter pw) throws RemoteException { + final PrintWriter err = getErrPrintWriter(); + boolean managed = true; + boolean mallocInfo = false; + int userId = UserHandle.USER_CURRENT; + boolean runGc = false; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + if (userId == UserHandle.USER_ALL) { + err.println("Error: Can't dump heap with user 'all'"); + return -1; + } + } else if (opt.equals("-n")) { + managed = false; + } else if (opt.equals("-g")) { + runGc = true; + } else if (opt.equals("-m")) { + managed = false; + mallocInfo = true; + } else { + err.println("Error: Unknown option: " + opt); + return -1; + } + } + String process = getNextArgRequired(); + String heapFile = getNextArg(); + if (heapFile == null) { + LocalDateTime localDateTime = LocalDateTime.now(Clock.systemDefaultZone()); + String logNameTimeString = LOG_NAME_TIME_FORMATTER.format(localDateTime); + heapFile = "/data/local/tmp/heapdump-" + logNameTimeString + ".prof"; + } + + // Writes an error message to stderr on failure + ParcelFileDescriptor fd = openFileForSystem(heapFile, "w"); + if (fd == null) { + return -1; + } + + pw.println("File: " + heapFile); + pw.flush(); + + final CountDownLatch latch = new CountDownLatch(1); + + final RemoteCallback finishCallback = new RemoteCallback(new OnResultListener() { + @Override + public void onResult(Bundle result) { + latch.countDown(); + } + }, null); + + if (!mInterface.dumpHeap(process, userId, managed, mallocInfo, runGc, heapFile, fd, + finishCallback)) { + err.println("HEAP DUMP FAILED on process " + process); + return -1; + } + pw.println("Waiting for dump to finish..."); + pw.flush(); + try { + latch.await(); + } catch (InterruptedException e) { + err.println("Caught InterruptedException"); + } + + return 0; + } + + int runSetDebugApp(PrintWriter pw) throws RemoteException { + boolean wait = false; + boolean persistent = false; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("-w")) { + wait = true; + } else if (opt.equals("--persistent")) { + persistent = true; + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + + String pkg = getNextArgRequired(); + mInterface.setDebugApp(pkg, wait, persistent); + return 0; + } + + int runSetAgentApp(PrintWriter pw) throws RemoteException { + String pkg = getNextArgRequired(); + String agent = getNextArg(); + mInterface.setAgentApp(pkg, agent); + return 0; + } + + int runClearDebugApp(PrintWriter pw) throws RemoteException { + mInterface.setDebugApp(null, false, true); + return 0; + } + + int runSetWatchHeap(PrintWriter pw) throws RemoteException { + String proc = getNextArgRequired(); + String limit = getNextArgRequired(); + mInterface.setDumpHeapDebugLimit(proc, 0, Long.parseLong(limit), null); + return 0; + } + + int runClearWatchHeap(PrintWriter pw) throws RemoteException { + String proc = getNextArgRequired(); + mInterface.setDumpHeapDebugLimit(proc, 0, -1, null); + return 0; + } + + int runClearStartInfo(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS, + "runClearStartInfo()"); + String opt; + int userId = UserHandle.USER_CURRENT; + String packageName = null; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + packageName = opt; + } + } + if (userId == UserHandle.USER_CURRENT) { + UserInfo user = mInterface.getCurrentUser(); + if (user == null) { + return -1; + } + userId = user.id; + } + mInternal.mProcessList.getAppStartInfoTracker() + .clearHistoryProcessStartInfo(packageName, userId); + return 0; + } + + int runClearExitInfo(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS, + "runClearExitInfo()"); + String opt; + int userId = UserHandle.USER_CURRENT; + String packageName = null; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + packageName = opt; + } + } + if (userId == UserHandle.USER_CURRENT) { + UserInfo user = mInterface.getCurrentUser(); + if (user == null) { + return -1; + } + userId = user.id; + } + mInternal.mProcessList.mAppExitInfoTracker.clearHistoryProcessExitInfo(packageName, userId); + return 0; + } + + int runBugReport(PrintWriter pw) throws RemoteException { + String opt; + boolean fullBugreport = true; + while ((opt=getNextOption()) != null) { + if (opt.equals("--progress")) { + fullBugreport = false; + mInterface.requestInteractiveBugReport(); + } else if (opt.equals("--telephony")) { + fullBugreport = false; + // no title and description specified + mInterface.requestTelephonyBugReport("" /* no title */, "" /* no descriptions */); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + if (fullBugreport) { + mInterface.requestFullBugReport(); + } + pw.println("Your lovely bug report is being created; please be patient."); + return 0; + } + + int runForceStop(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_ALL; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInterface.forceStopPackage(getNextArgRequired(), userId); + return 0; + } + + int runStopApp(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_SYSTEM; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInterface.stopAppForUser(getNextArgRequired(), userId); + return 0; + } + + int runClearRecentApps(PrintWriter pw) throws RemoteException { + mTaskInterface.removeAllVisibleRecentTasks(); + return 0; + } + + int runFgsNotificationRateLimit(PrintWriter pw) throws RemoteException { + final String toggleValue = getNextArgRequired(); + final boolean enable; + switch (toggleValue) { + case "enable": + enable = true; + break; + case "disable": + enable = false; + break; + default: + throw new IllegalArgumentException( + "Argument must be either 'enable' or 'disable'"); + } + mInterface.enableFgsNotificationRateLimit(enable); + return 0; + } + + int runCrash(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_ALL; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + + int pid = -1; + String packageName = null; + final String arg = getNextArgRequired(); + // The argument is either a pid or a package name + try { + pid = Integer.parseInt(arg); + } catch (NumberFormatException e) { + packageName = arg; + } + + int[] userIds = (userId == UserHandle.USER_ALL) ? mInternal.mUserController.getUserIds() + : new int[]{userId}; + for (int id : userIds) { + if (mInternal.mUserController.hasUserRestriction( + UserManager.DISALLOW_DEBUGGING_FEATURES, id)) { + getOutPrintWriter().println( + "Shell does not have permission to crash packages for user " + id); + continue; + } + mInterface.crashApplicationWithType(-1, pid, packageName, id, "shell-induced crash", + false, CrashedByAdbException.TYPE_ID); + } + return 0; + } + + int runKill(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_ALL; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInterface.killBackgroundProcesses(getNextArgRequired(), userId); + return 0; + } + + int runKillAll(PrintWriter pw) throws RemoteException { + mInterface.killAllBackgroundProcesses(); + return 0; + } + + int runMakeIdle(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_ALL; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInterface.makePackageIdle(getNextArgRequired(), userId); + return 0; + } + + int runSetDeterministicUidIdle(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_ALL; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + boolean deterministic = Boolean.parseBoolean(getNextArgRequired()); + mInterface.setDeterministicUidIdle(deterministic); + return 0; + } + + static final class MyActivityController extends IActivityController.Stub { + final IActivityManager mInterface; + final PrintWriter mPw; + final InputStream mInput; + final String mGdbPort; + final boolean mMonkey; + final boolean mSimpleMode; + final String mTarget; + final boolean mAlwaysContinue; + final boolean mAlwaysKill; + + static final int STATE_NORMAL = 0; + static final int STATE_CRASHED = 1; + static final int STATE_EARLY_ANR = 2; + static final int STATE_ANR = 3; + + int mState; + + static final int RESULT_DEFAULT = 0; + + static final int RESULT_CRASH_DIALOG = 0; + static final int RESULT_CRASH_KILL = 1; + + static final int RESULT_EARLY_ANR_CONTINUE = 0; + static final int RESULT_EARLY_ANR_KILL = 1; + + static final int RESULT_ANR_DIALOG = 0; + static final int RESULT_ANR_KILL = 1; + static final int RESULT_ANR_WAIT = 2; + + int mResult; + + Process mGdbProcess; + Thread mGdbThread; + boolean mGotGdbPrint; + + MyActivityController(IActivityManager iam, PrintWriter pw, InputStream input, + String gdbPort, boolean monkey, boolean simpleMode, String target, + boolean alwaysContinue, boolean alwaysKill) { + mInterface = iam; + mPw = pw; + mInput = input; + mGdbPort = gdbPort; + mMonkey = monkey; + mSimpleMode = simpleMode; + mTarget = target; + mAlwaysContinue = alwaysContinue; + mAlwaysKill = alwaysKill; + } + + private boolean shouldHandlePackageOrProcess(String packageOrProcess) { + if (mTarget == null) { + return true; // Always handle all packages / processes. + } + return mTarget.equals(packageOrProcess); + } + + @Override + public boolean activityResuming(String pkg) { + if (!shouldHandlePackageOrProcess(pkg)) { + return true; + } + synchronized (this) { + mPw.println("** Activity resuming: " + pkg); + mPw.flush(); + } + return true; + } + + @Override + public boolean activityStarting(Intent intent, String pkg) { + if (!shouldHandlePackageOrProcess(pkg)) { + return true; + } + synchronized (this) { + mPw.println("** Activity starting: " + pkg); + mPw.flush(); + } + return true; + } + + @Override + public boolean appCrashed(String processName, int pid, String shortMsg, String longMsg, + long timeMillis, String stackTrace) { + if (!shouldHandlePackageOrProcess(processName)) { + return true; // Don't kill + } + synchronized (this) { + if (mSimpleMode) { + mPw.println("** PROCESS CRASHED: " + processName); + } else { + mPw.println("** ERROR: PROCESS CRASHED"); + mPw.println("processName: " + processName); + mPw.println("processPid: " + pid); + mPw.println("shortMsg: " + shortMsg); + mPw.println("longMsg: " + longMsg); + mPw.println("timeMillis: " + timeMillis); + mPw.println("uptime: " + SystemClock.uptimeMillis()); + mPw.println("stack:"); + mPw.print(stackTrace); + mPw.println("#"); + } + mPw.flush(); + if (mAlwaysContinue) { + return true; + } + if (mAlwaysKill) { + return false; + } + int result = waitControllerLocked(pid, STATE_CRASHED); + return result == RESULT_CRASH_KILL ? false : true; + } + } + + @Override + public int appEarlyNotResponding(String processName, int pid, String annotation) { + if (!shouldHandlePackageOrProcess(processName)) { + return 0; // Continue + } + synchronized (this) { + if (mSimpleMode) { + mPw.println("** EARLY PROCESS NOT RESPONDING: " + processName); + } else { + mPw.println("** ERROR: EARLY PROCESS NOT RESPONDING"); + mPw.println("processName: " + processName); + mPw.println("processPid: " + pid); + mPw.println("annotation: " + annotation); + mPw.println("uptime: " + SystemClock.uptimeMillis()); + } + mPw.flush(); + if (mAlwaysContinue) { + return 0; + } + if (mAlwaysKill) { + return -1; + } + int result = waitControllerLocked(pid, STATE_EARLY_ANR); + if (result == RESULT_EARLY_ANR_KILL) return -1; + return 0; + } + } + + @Override + public int appNotResponding(String processName, int pid, String processStats) { + if (!shouldHandlePackageOrProcess(processName)) { + return 0; // Default == show dialog + } + synchronized (this) { + if (mSimpleMode) { + mPw.println("** PROCESS NOT RESPONDING: " + processName); + } else { + mPw.println("** ERROR: PROCESS NOT RESPONDING"); + mPw.println("processName: " + processName); + mPw.println("processPid: " + pid); + mPw.println("uptime: " + SystemClock.uptimeMillis()); + mPw.println("processStats:"); + mPw.print(processStats); + mPw.println("#"); + } + mPw.flush(); + if (mAlwaysContinue) { + return 0; + } + if (mAlwaysKill) { + return -1; + } + int result = waitControllerLocked(pid, STATE_ANR); + if (result == RESULT_ANR_KILL) return -1; + if (result == RESULT_ANR_WAIT) return 1; + return 0; + } + } + + @Override + public int systemNotResponding(String message) { + if (mTarget != null) { + return -1; // If any target is set, just return. + } + synchronized (this) { + mPw.println("** ERROR: PROCESS NOT RESPONDING"); + if (!mSimpleMode) { + mPw.println("message: " + message); + mPw.println("#"); + mPw.println("Allowing system to die."); + } + mPw.flush(); + return -1; + } + } + + void killGdbLocked() { + mGotGdbPrint = false; + if (mGdbProcess != null) { + mPw.println("Stopping gdbserver"); + mPw.flush(); + mGdbProcess.destroy(); + mGdbProcess = null; + } + if (mGdbThread != null) { + mGdbThread.interrupt(); + mGdbThread = null; + } + } + + int waitControllerLocked(int pid, int state) { + if (mGdbPort != null) { + killGdbLocked(); + + try { + mPw.println("Starting gdbserver on port " + mGdbPort); + mPw.println("Do the following:"); + mPw.println(" adb forward tcp:" + mGdbPort + " tcp:" + mGdbPort); + mPw.println(" gdbclient app_process :" + mGdbPort); + mPw.flush(); + + mGdbProcess = Runtime.getRuntime().exec(new String[] { + "gdbserver", ":" + mGdbPort, "--attach", Integer.toString(pid) + }); + final InputStreamReader converter = new InputStreamReader( + mGdbProcess.getInputStream()); + mGdbThread = new Thread() { + @Override + public void run() { + BufferedReader in = new BufferedReader(converter); + String line; + int count = 0; + while (true) { + synchronized (MyActivityController.this) { + if (mGdbThread == null) { + return; + } + if (count == 2) { + mGotGdbPrint = true; + MyActivityController.this.notifyAll(); + } + } + try { + line = in.readLine(); + if (line == null) { + return; + } + mPw.println("GDB: " + line); + mPw.flush(); + count++; + } catch (IOException e) { + return; + } + } + } + }; + mGdbThread.start(); + + // Stupid waiting for .5s. Doesn't matter if we end early. + try { + this.wait(500); + } catch (InterruptedException e) { + } + + } catch (IOException e) { + mPw.println("Failure starting gdbserver: " + e); + mPw.flush(); + killGdbLocked(); + } + } + mState = state; + mPw.println(""); + printMessageForState(); + mPw.flush(); + + while (mState != STATE_NORMAL) { + try { + wait(); + } catch (InterruptedException e) { + } + } + + killGdbLocked(); + + return mResult; + } + + void resumeController(int result) { + synchronized (this) { + mState = STATE_NORMAL; + mResult = result; + notifyAll(); + } + } + + void printMessageForState() { + if ((mAlwaysContinue || mAlwaysKill) && mSimpleMode) { + return; // In the simplest mode, we don't need to show anything. + } + switch (mState) { + case STATE_NORMAL: + mPw.println("Monitoring activity manager... available commands:"); + break; + case STATE_CRASHED: + mPw.println("Waiting after crash... available commands:"); + mPw.println("(c)ontinue: show crash dialog"); + mPw.println("(k)ill: immediately kill app"); + break; + case STATE_EARLY_ANR: + mPw.println("Waiting after early ANR... available commands:"); + mPw.println("(c)ontinue: standard ANR processing"); + mPw.println("(k)ill: immediately kill app"); + break; + case STATE_ANR: + mPw.println("Waiting after ANR... available commands:"); + mPw.println("(c)ontinue: show ANR dialog"); + mPw.println("(k)ill: immediately kill app"); + mPw.println("(w)ait: wait some more"); + break; + } + mPw.println("(q)uit: finish monitoring"); + } + + void run() throws RemoteException { + try { + printMessageForState(); + mPw.flush(); + + mInterface.setActivityController(this, mMonkey); + mState = STATE_NORMAL; + + InputStreamReader converter = new InputStreamReader(mInput); + BufferedReader in = new BufferedReader(converter); + String line; + + while ((line = in.readLine()) != null) { + boolean addNewline = true; + if (line.length() <= 0) { + addNewline = false; + } else if ("q".equals(line) || "quit".equals(line)) { + resumeController(RESULT_DEFAULT); + break; + } else if (mState == STATE_CRASHED) { + if ("c".equals(line) || "continue".equals(line)) { + resumeController(RESULT_CRASH_DIALOG); + } else if ("k".equals(line) || "kill".equals(line)) { + resumeController(RESULT_CRASH_KILL); + } else { + mPw.println("Invalid command: " + line); + } + } else if (mState == STATE_ANR) { + if ("c".equals(line) || "continue".equals(line)) { + resumeController(RESULT_ANR_DIALOG); + } else if ("k".equals(line) || "kill".equals(line)) { + resumeController(RESULT_ANR_KILL); + } else if ("w".equals(line) || "wait".equals(line)) { + resumeController(RESULT_ANR_WAIT); + } else { + mPw.println("Invalid command: " + line); + } + } else if (mState == STATE_EARLY_ANR) { + if ("c".equals(line) || "continue".equals(line)) { + resumeController(RESULT_EARLY_ANR_CONTINUE); + } else if ("k".equals(line) || "kill".equals(line)) { + resumeController(RESULT_EARLY_ANR_KILL); + } else { + mPw.println("Invalid command: " + line); + } + } else { + mPw.println("Invalid command: " + line); + } + + synchronized (this) { + if (addNewline) { + mPw.println(""); + } + printMessageForState(); + mPw.flush(); + } + } + + } catch (IOException e) { + e.printStackTrace(mPw); + mPw.flush(); + } finally { + mInterface.setActivityController(null, mMonkey); + } + } + } + + int runMonitor(PrintWriter pw) throws RemoteException { + String opt; + String gdbPort = null; + boolean monkey = false; + boolean simpleMode = false; + boolean alwaysContinue = false; + boolean alwaysKill = false; + String target = null; + + while ((opt=getNextOption()) != null) { + if (opt.equals("--gdb")) { + gdbPort = getNextArgRequired(); + } else if (opt.equals("-p")) { + target = getNextArgRequired(); + } else if (opt.equals("-m")) { + monkey = true; + } else if (opt.equals("-s")) { + simpleMode = true; + } else if (opt.equals("-c")) { + alwaysContinue = true; + } else if (opt.equals("-k")) { + alwaysKill = true; + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + if (alwaysContinue && alwaysKill) { + getErrPrintWriter().println("Error: -k and -c options can't be used together."); + return -1; + } + + MyActivityController controller = new MyActivityController(mInterface, pw, + getRawInputStream(), gdbPort, monkey, simpleMode, target, alwaysContinue, + alwaysKill); + controller.run(); + return 0; + } + + static final class MyUidObserver extends UidObserver + implements ActivityManagerService.OomAdjObserver { + final IActivityManager mInterface; + final ActivityManagerService mInternal; + final PrintWriter mPw; + final InputStream mInput; + final int mUid; + + final int mMask; + + static final int STATE_NORMAL = 0; + + int mState; + + MyUidObserver(ActivityManagerService service, PrintWriter pw, InputStream input, int uid, + int mask) { + mInterface = service; + mInternal = service; + mPw = pw; + mInput = input; + mUid = uid; + mMask = mask; + } + + @Override + public void onUidStateChanged(int uid, int procState, long procStateSeq, int capability) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print(uid); + mPw.print(" procstate "); + mPw.print(ProcessList.makeProcStateString(procState)); + mPw.print(" seq "); + mPw.print(procStateSeq); + mPw.print(" capability "); + mPw.println(capability & mMask); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + @Override + public void onUidGone(int uid, boolean disabled) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print(uid); + mPw.print(" gone"); + if (disabled) { + mPw.print(" disabled"); + } + mPw.println(); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + @Override + public void onUidActive(int uid) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print(uid); + mPw.println(" active"); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + @Override + public void onUidIdle(int uid, boolean disabled) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print(uid); + mPw.print(" idle"); + if (disabled) { + mPw.print(" disabled"); + } + mPw.println(); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + @Override + public void onUidCachedChanged(int uid, boolean cached) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print(uid); + mPw.println(cached ? " cached" : " uncached"); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + @Override + public void onOomAdjMessage(String msg) { + synchronized (this) { + final StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites(); + try { + mPw.print("# "); + mPw.println(msg); + mPw.flush(); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + } + + void printMessageForState() { + switch (mState) { + case STATE_NORMAL: + mPw.println("Watching uid states... available commands:"); + break; + } + mPw.println("(q)uit: finish watching"); + } + + void run() throws RemoteException { + try { + printMessageForState(); + mPw.flush(); + + mInterface.registerUidObserver(this, ActivityManager.UID_OBSERVER_ACTIVE + | ActivityManager.UID_OBSERVER_GONE | ActivityManager.UID_OBSERVER_PROCSTATE + | ActivityManager.UID_OBSERVER_IDLE | ActivityManager.UID_OBSERVER_CACHED, + ActivityManager.PROCESS_STATE_UNKNOWN, null); + if (mUid >= 0) { + mInternal.setOomAdjObserver(mUid, this); + } + mState = STATE_NORMAL; + + InputStreamReader converter = new InputStreamReader(mInput); + BufferedReader in = new BufferedReader(converter); + String line; + + while ((line = in.readLine()) != null) { + boolean addNewline = true; + if (line.length() <= 0) { + addNewline = false; + } else if ("q".equals(line) || "quit".equals(line)) { + break; + } else { + mPw.println("Invalid command: " + line); + } + + synchronized (this) { + if (addNewline) { + mPw.println(""); + } + printMessageForState(); + mPw.flush(); + } + } + + } catch (IOException e) { + e.printStackTrace(mPw); + mPw.flush(); + } finally { + if (mUid >= 0) { + mInternal.clearOomAdjObserver(); + } + mInterface.unregisterUidObserver(this); + } + } + } + + int runWatchUids(PrintWriter pw) throws RemoteException { + String opt; + int uid = -1; + + // Because a lot of CTS won't ignore capabilities newly added, we report + // only the following capabilities -- the ones we had on Android T -- by default. + int mask = PROCESS_CAPABILITY_FOREGROUND_LOCATION + | PROCESS_CAPABILITY_FOREGROUND_CAMERA + | PROCESS_CAPABILITY_FOREGROUND_MICROPHONE + | PROCESS_CAPABILITY_POWER_RESTRICTED_NETWORK; + + while ((opt=getNextOption()) != null) { + if (opt.equals("--oom")) { + uid = Integer.parseInt(getNextArgRequired()); + } else if (opt.equals("--mask")) { + mask = Integer.parseInt(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + + } + } + + MyUidObserver controller = new MyUidObserver(mInternal, pw, getRawInputStream(), uid, mask); + controller.run(); + return 0; + } + + int runHang(PrintWriter pw) throws RemoteException { + String opt; + boolean allowRestart = false; + while ((opt=getNextOption()) != null) { + if (opt.equals("--allow-restart")) { + allowRestart = true; + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + + pw.println("Hanging the system..."); + pw.flush(); + try { + mInterface.hang(getShellCallback().getShellCallbackBinder(), allowRestart); + } catch (NullPointerException e) { + pw.println("Hanging failed, since caller " + Binder.getCallingPid() + + " did not provide a ShellCallback!"); + pw.flush(); + return 1; + } + return 0; + } + + int runRestart(PrintWriter pw) throws RemoteException { + String opt; + while ((opt=getNextOption()) != null) { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + + pw.println("Restart the system..."); + pw.flush(); + mInterface.restart(); + return 0; + } + + int runIdleMaintenance(PrintWriter pw) throws RemoteException { + String opt; + while ((opt=getNextOption()) != null) { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + + pw.println("Performing idle maintenance..."); + mInterface.sendIdleJobTrigger(); + mInternal.performIdleMaintenance(); + return 0; + } + + int runScreenCompat(PrintWriter pw) throws RemoteException { + String mode = getNextArgRequired(); + boolean enabled; + if ("on".equals(mode)) { + enabled = true; + } else if ("off".equals(mode)) { + enabled = false; + } else { + getErrPrintWriter().println("Error: enabled mode must be 'on' or 'off' at " + mode); + return -1; + } + + String packageName = getNextArgRequired(); + do { + try { + mInterface.setPackageScreenCompatMode(packageName, enabled + ? ActivityManager.COMPAT_MODE_ENABLED + : ActivityManager.COMPAT_MODE_DISABLED); + } catch (RemoteException e) { + } + packageName = getNextArg(); + } while (packageName != null); + return 0; + } + + int runPackageImportance(PrintWriter pw) throws RemoteException { + String packageName = getNextArgRequired(); + int procState = mInterface.getPackageProcessState(packageName, "com.android.shell"); + pw.println(ActivityManager.RunningAppProcessInfo.procStateToImportance(procState)); + return 0; + } + + int runToUri(PrintWriter pw, int flags) throws RemoteException { + Intent intent; + try { + intent = makeIntent(UserHandle.USER_CURRENT); + } catch (URISyntaxException e) { + throw new RuntimeException(e.getMessage(), e); + } + pw.println(intent.toUri(flags)); + return 0; + } + + private boolean switchUserAndWaitForComplete(int userId) throws RemoteException { + UserInfo currentUser = mInterface.getCurrentUser(); + if (currentUser != null && userId == currentUser.id) { + // Already switched to the correct user, exit early. + return true; + } + + // Register switch observer. + final CountDownLatch switchLatch = new CountDownLatch(1); + final IUserSwitchObserver userSwitchObserver = new UserSwitchObserver() { + @Override + public void onUserSwitchComplete(int newUserId) { + if (userId == newUserId) { + switchLatch.countDown(); + } + } + }; + try { + mInterface.registerUserSwitchObserver(userSwitchObserver, + ActivityManagerShellCommand.class.getName()); + + // Switch. + boolean switched = mInterface.switchUser(userId); + if (!switched) { + // Switching failed, don't wait for the user switch observer. + return false; + } + + // Wait. + try { + switched = switchLatch.await(USER_OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + getErrPrintWriter().println("Error: Thread interrupted unexpectedly."); + } + + return switched; + } finally { + mInterface.unregisterUserSwitchObserver(userSwitchObserver); + } + } + + int runSwitchUser(PrintWriter pw) throws RemoteException { + boolean wait = false; + String opt; + while ((opt = getNextOption()) != null) { + if ("-w".equals(opt)) { + wait = true; + } else { + getErrPrintWriter().println("Error: unknown option: " + opt); + return -1; + } + } + + int userId = Integer.parseInt(getNextArgRequired()); + + UserManager userManager = mInternal.mContext.getSystemService(UserManager.class); + final int userSwitchable = userManager.getUserSwitchability(UserHandle.of(userId)); + if (userSwitchable != UserManager.SWITCHABILITY_STATUS_OK) { + getErrPrintWriter().println("Error: UserSwitchabilityResult=" + userSwitchable); + return -1; + } + + boolean switched; + Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "shell_runSwitchUser"); + try { + if (wait) { + switched = switchUserAndWaitForComplete(userId); + } else { + switched = mInterface.switchUser(userId); + } + if (switched) { + return 0; + } else { + pw.printf("Error: Failed to switch to user %d\n", userId); + return 1; + } + } finally { + Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); + } + } + + int runGetCurrentUser(PrintWriter pw) throws RemoteException { + int userId = mInterface.getCurrentUserId(); + if (userId == UserHandle.USER_NULL) { + throw new IllegalStateException("Current user not set"); + } + pw.println(userId); + return 0; + } + + int runStartUser(PrintWriter pw) throws RemoteException { + boolean wait = false; + String opt; + int displayId = Display.INVALID_DISPLAY; + while ((opt = getNextOption()) != null) { + switch(opt) { + case "-w": + wait = true; + break; + case "--display": + displayId = getDisplayIdFromNextArg(); + break; + default: + getErrPrintWriter().println("Error: unknown option: " + opt); + return -1; + } + } + final int userId = Integer.parseInt(getNextArgRequired()); + final ProgressWaiter waiter = wait ? new ProgressWaiter(userId) : null; + + // For backwards compatibility, if the user is a profile, we need to define whether it + // should be started visible (when its parent is the current user) or not (when it isn't) + final UserManagerInternal umi = LocalServices.getService(UserManagerInternal.class); + final ActivityManagerInternal ami = LocalServices.getService(ActivityManagerInternal.class); + final int parentUserId = umi.getProfileParentId(userId); + final int currentUserId = ami.getCurrentUserId(); + final boolean isProfile = parentUserId != userId; + final boolean isVisibleProfile = isProfile && parentUserId == currentUserId; + Slogf.d(TAG, "runStartUser(): userId=%d, parentUserId=%d, currentUserId=%d, isProfile=%b, " + + "isVisibleProfile=%b, display=%d, waiter=%s", userId, parentUserId, currentUserId, + isProfile, isVisibleProfile, displayId, waiter); + + boolean success; + String displaySuffix = ""; + Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "shell_runStartUser" + userId); + try { + if (isVisibleProfile) { + Slogf.d(TAG, "calling startProfileWithListener(%d, %s)", userId, waiter); + // startProfileWithListener() will start the profile visible (as long its parent is + // the current user), while startUserInBackgroundWithListener() will always start + // the user (or profile) invisible + success = mInterface.startProfileWithListener(userId, waiter); + } else if (displayId == Display.INVALID_DISPLAY) { + Slogf.d(TAG, "calling startUserInBackgroundWithListener(%d)", userId); + success = mInterface.startUserInBackgroundWithListener(userId, waiter); + } else { + if (!UserManager.isVisibleBackgroundUsersEnabled()) { + pw.println("Not supported"); + return -1; + } + Slogf.d(TAG, "calling startUserInBackgroundVisibleOnDisplay(%d, %d, %s)", userId, + displayId, waiter); + success = mInterface.startUserInBackgroundVisibleOnDisplay(userId, displayId, + waiter); + displaySuffix = " on display " + displayId; + } + if (wait && success) { + Slogf.d(TAG, "waiting %d ms", USER_OPERATION_TIMEOUT_MS); + success = waiter.waitForFinish(USER_OPERATION_TIMEOUT_MS); + } + } finally { + Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); + } + + if (success) { + pw.println("Success: user started" + displaySuffix); + } else { + getErrPrintWriter().println("Error: could not start user" + displaySuffix); + } + return 0; + } + + int runUnlockUser(PrintWriter pw) throws RemoteException { + int userId = Integer.parseInt(getNextArgRequired()); + + /* + * Originally this command required two more parameters: the hardware + * authentication token and secret needed to unlock the user. However, + * unlockUser() no longer takes a token parameter at all, and there + * isn't really any way for callers of this shell command to get the + * secret if one is needed (i.e., when the user has an LSKF), given that + * the secret must be a cryptographic key derived from the user's + * synthetic password. I.e. the secret is *not* the LSKF here, but + * rather an intermediate value several steps down the chain. + * + * As such, the only supported use for this command is unlocking a user + * who doesn't have an LSKF, with empty or unspecified token and secret. + * + * To preserve previous behavior, an exclamation mark ("!") is also + * accepted for these values; it means the same as an empty string. + */ + String token = getNextArg(); + if (!TextUtils.isEmpty(token) && !"!".equals(token)) { + getErrPrintWriter().println("Error: token parameter not supported"); + return -1; + } + String secret = getNextArg(); + if (!TextUtils.isEmpty(secret) && !"!".equals(secret)) { + getErrPrintWriter().println("Error: secret parameter not supported"); + return -1; + } + + boolean success = mInterface.unlockUser2(userId, null); + if (success) { + pw.println("Success: user unlocked"); + } else { + // TODO(b/218389026): we can reach here even if the user's storage + // was successfully unlocked. + getErrPrintWriter().println("Error: could not unlock user"); + } + return 0; + } + + static final class StopUserCallback extends IStopUserCallback.Stub { + private final @UserIdInt int mUserId; + private boolean mFinished = false; + + private StopUserCallback(@UserIdInt int userId) { + mUserId = userId; + } + + public synchronized void waitForFinish() { + try { + while (!mFinished) wait(); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + Slogf.d(TAG, "user %d finished stopping", mUserId); + } + + @Override + public synchronized void userStopped(int userId) { + Slogf.d(TAG, "StopUserCallback: userStopped(%d)", userId); + mFinished = true; + notifyAll(); + } + + @Override + public synchronized void userStopAborted(int userId) { + Slogf.d(TAG, "StopUserCallback: userStopAborted(%d)", userId); + mFinished = true; + notifyAll(); + } + + @Override + public String toString() { + return "ProgressWaiter[userId=" + mUserId + ", finished=" + mFinished + "]"; + } + } + + int runStopUser(PrintWriter pw) throws RemoteException { + boolean wait = false; + boolean force = false; + String opt; + while ((opt = getNextOption()) != null) { + if ("-w".equals(opt)) { + wait = true; + } else if ("-f".equals(opt)) { + force = true; + } else { + getErrPrintWriter().println("Error: unknown option: " + opt); + return -1; + } + } + int userId = Integer.parseInt(getNextArgRequired()); + StopUserCallback callback = wait ? new StopUserCallback(userId) : null; + + Slogf.d(TAG, "Calling stopUser(%d, %b, %s)", userId, force, callback); + Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, + "shell_runStopUser-" + userId + "-[stopUser]"); + try { + int res = mInterface.stopUser(userId, force, callback); + if (res != ActivityManager.USER_OP_SUCCESS) { + String txt = ""; + switch (res) { + case ActivityManager.USER_OP_IS_CURRENT: + txt = " (Can't stop current user)"; + break; + case ActivityManager.USER_OP_UNKNOWN_USER: + txt = " (Unknown user " + userId + ")"; + break; + case ActivityManager.USER_OP_ERROR_IS_SYSTEM: + txt = " (System user cannot be stopped)"; + break; + case ActivityManager.USER_OP_ERROR_RELATED_USERS_CANNOT_STOP: + txt = " (Can't stop user " + userId + + " - one of its related users can't be stopped)"; + break; + } + getErrPrintWriter().println("Switch failed: " + res + txt); + return -1; + } else if (callback != null) { + callback.waitForFinish(); + } + return 0; + } finally { + Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER); + } + } + + int runIsUserStopped(PrintWriter pw) { + int userId = UserHandle.parseUserArg(getNextArgRequired()); + boolean stopped = mInternal.isUserStopped(userId); + pw.println(stopped); + return 0; + } + + int runGetStartedUserState(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.DUMP, + "runGetStartedUserState()"); + final int userId = Integer.parseInt(getNextArgRequired()); + try { + pw.println(mInternal.getStartedUserState(userId)); + } catch (NullPointerException e) { + pw.println("User is not started: " + userId); + } + return 0; + } + + int runTrackAssociations(PrintWriter pw) { + mInternal.enforceCallingPermission(android.Manifest.permission.SET_ACTIVITY_WATCHER, + "runTrackAssociations()"); + synchronized (mInternal) { + if (!mInternal.mTrackingAssociations) { + mInternal.mTrackingAssociations = true; + pw.println("Association tracking started."); + } else { + pw.println("Association tracking already enabled."); + } + } + return 0; + } + + int runUntrackAssociations(PrintWriter pw) { + mInternal.enforceCallingPermission(android.Manifest.permission.SET_ACTIVITY_WATCHER, + "runUntrackAssociations()"); + synchronized (mInternal) { + if (mInternal.mTrackingAssociations) { + mInternal.mTrackingAssociations = false; + mInternal.mAssociations.clear(); + pw.println("Association tracking stopped."); + } else { + pw.println("Association tracking not running."); + } + } + return 0; + } + + int getUidState(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.DUMP, + "getUidState()"); + int state = mInternal.getUidState(Integer.parseInt(getNextArgRequired())); + pw.print(state); + pw.print(" ("); + pw.printf(DebugUtils.valueToString(ActivityManager.class, "PROCESS_STATE_", state)); + pw.println(")"); + return 0; + } + + private List getRecentConfigurations(int days) { + IUsageStatsManager usm = IUsageStatsManager.Stub.asInterface(ServiceManager.getService( + Context.USAGE_STATS_SERVICE)); + final long now = System.currentTimeMillis(); + final long nDaysAgo = now - (days * 24 * 60 * 60 * 1000); + try { + @SuppressWarnings("unchecked") + ParceledListSlice configStatsSlice = usm.queryConfigurationStats( + UsageStatsManager.INTERVAL_BEST, nDaysAgo, now, "com.android.shell"); + if (configStatsSlice == null) { + return Collections.emptyList(); + } + + final ArrayMap recentConfigs = new ArrayMap<>(); + final List configStatsList = configStatsSlice.getList(); + final int configStatsListSize = configStatsList.size(); + for (int i = 0; i < configStatsListSize; i++) { + final ConfigurationStats stats = configStatsList.get(i); + final int indexOfKey = recentConfigs.indexOfKey(stats.getConfiguration()); + if (indexOfKey < 0) { + recentConfigs.put(stats.getConfiguration(), stats.getActivationCount()); + } else { + recentConfigs.setValueAt(indexOfKey, + recentConfigs.valueAt(indexOfKey) + stats.getActivationCount()); + } + } + + final Comparator comparator = new Comparator() { + @Override + public int compare(Configuration a, Configuration b) { + return recentConfigs.get(b).compareTo(recentConfigs.get(a)); + } + }; + + ArrayList configs = new ArrayList<>(recentConfigs.size()); + configs.addAll(recentConfigs.keySet()); + Collections.sort(configs, comparator); + return configs; + + } catch (RemoteException e) { + return Collections.emptyList(); + } + } + + /** + * Adds all supported GL extensions for a provided EGLConfig to a set by creating an EGLContext + * and EGLSurface and querying extensions. + * + * @param egl An EGL API object + * @param display An EGLDisplay to create a context and surface with + * @param config The EGLConfig to get the extensions for + * @param surfaceSize eglCreatePbufferSurface generic parameters + * @param contextAttribs eglCreateContext generic parameters + * @param glExtensions A Set to add GL extensions to + */ + private static void addExtensionsForConfig( + EGL10 egl, + EGLDisplay display, + EGLConfig config, + int[] surfaceSize, + int[] contextAttribs, + Set glExtensions) { + // Create a context. + EGLContext context = + egl.eglCreateContext(display, config, EGL10.EGL_NO_CONTEXT, contextAttribs); + // No-op if we can't create a context. + if (context == EGL10.EGL_NO_CONTEXT) { + return; + } + + // Create a surface. + EGLSurface surface = egl.eglCreatePbufferSurface(display, config, surfaceSize); + if (surface == EGL10.EGL_NO_SURFACE) { + egl.eglDestroyContext(display, context); + return; + } + + // Update the current surface and context. + egl.eglMakeCurrent(display, surface, surface, context); + + // Get the list of extensions. + String extensionList = GLES10.glGetString(GLES10.GL_EXTENSIONS); + if (!TextUtils.isEmpty(extensionList)) { + // The list of extensions comes from the driver separated by spaces. + // Split them apart and add them into a Set for deduping purposes. + for (String extension : extensionList.split(" ")) { + glExtensions.add(extension); + } + } + + // Tear down the context and surface for this config. + egl.eglMakeCurrent(display, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_SURFACE, EGL10.EGL_NO_CONTEXT); + egl.eglDestroySurface(display, surface); + egl.eglDestroyContext(display, context); + } + + + Set getGlExtensionsFromDriver() { + Set glExtensions = new HashSet<>(); + + // Get the EGL implementation. + EGL10 egl = (EGL10) EGLContext.getEGL(); + if (egl == null) { + getErrPrintWriter().println("Warning: couldn't get EGL"); + return glExtensions; + } + + // Get the default display and initialize it. + EGLDisplay display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY); + int[] version = new int[2]; + egl.eglInitialize(display, version); + + // Call getConfigs() in order to find out how many there are. + int[] numConfigs = new int[1]; + if (!egl.eglGetConfigs(display, null, 0, numConfigs)) { + getErrPrintWriter().println("Warning: couldn't get EGL config count"); + return glExtensions; + } + + // Allocate space for all configs and ask again. + EGLConfig[] configs = new EGLConfig[numConfigs[0]]; + if (!egl.eglGetConfigs(display, configs, numConfigs[0], numConfigs)) { + getErrPrintWriter().println("Warning: couldn't get EGL configs"); + return glExtensions; + } + + // Allocate surface size parameters outside of the main loop to cut down + // on GC thrashing. 1x1 is enough since we are only using it to get at + // the list of extensions. + int[] surfaceSize = + new int[] { + EGL10.EGL_WIDTH, 1, + EGL10.EGL_HEIGHT, 1, + EGL10.EGL_NONE + }; + + // For when we need to create a GLES2.0 context. + final int EGL_CONTEXT_CLIENT_VERSION = 0x3098; + int[] gles2 = new int[] {EGL_CONTEXT_CLIENT_VERSION, 2, EGL10.EGL_NONE}; + + // For getting return values from eglGetConfigAttrib + int[] attrib = new int[1]; + + for (int i = 0; i < numConfigs[0]; i++) { + // Get caveat for this config in order to skip slow (i.e. software) configs. + egl.eglGetConfigAttrib(display, configs[i], EGL10.EGL_CONFIG_CAVEAT, attrib); + if (attrib[0] == EGL10.EGL_SLOW_CONFIG) { + continue; + } + + // If the config does not support pbuffers we cannot do an eglMakeCurrent + // on it in addExtensionsForConfig(), so skip it here. Attempting to make + // it current with a pbuffer will result in an EGL_BAD_MATCH error + egl.eglGetConfigAttrib(display, configs[i], EGL10.EGL_SURFACE_TYPE, attrib); + if ((attrib[0] & EGL10.EGL_PBUFFER_BIT) == 0) { + continue; + } + + final int EGL_OPENGL_ES_BIT = 0x0001; + final int EGL_OPENGL_ES2_BIT = 0x0004; + egl.eglGetConfigAttrib(display, configs[i], EGL10.EGL_RENDERABLE_TYPE, attrib); + if ((attrib[0] & EGL_OPENGL_ES_BIT) != 0) { + addExtensionsForConfig(egl, display, configs[i], surfaceSize, null, glExtensions); + } + if ((attrib[0] & EGL_OPENGL_ES2_BIT) != 0) { + addExtensionsForConfig(egl, display, configs[i], surfaceSize, gles2, glExtensions); + } + } + + // Release all EGL resources. + egl.eglTerminate(display); + + return glExtensions; + } + + private void writeDeviceConfig(ProtoOutputStream protoOutputStream, long fieldId, + PrintWriter pw, Configuration config, DisplayMetrics displayMetrics) { + long token = -1; + if (protoOutputStream != null) { + token = protoOutputStream.start(fieldId); + protoOutputStream.write(DeviceConfigurationProto.STABLE_SCREEN_WIDTH_PX, + displayMetrics.widthPixels); + protoOutputStream.write(DeviceConfigurationProto.STABLE_SCREEN_HEIGHT_PX, + displayMetrics.heightPixels); + protoOutputStream.write(DeviceConfigurationProto.STABLE_DENSITY_DPI, + DisplayMetrics.DENSITY_DEVICE_STABLE); + } + if (pw != null) { + pw.print("stable-width-px: "); pw.println(displayMetrics.widthPixels); + pw.print("stable-height-px: "); pw.println(displayMetrics.heightPixels); + pw.print("stable-density-dpi: "); pw.println(DisplayMetrics.DENSITY_DEVICE_STABLE); + } + + MemInfoReader memreader = new MemInfoReader(); + memreader.readMemInfo(); + KeyguardManager kgm = mInternal.mContext.getSystemService(KeyguardManager.class); + if (protoOutputStream != null) { + protoOutputStream.write(DeviceConfigurationProto.TOTAL_RAM, memreader.getTotalSize()); + protoOutputStream.write(DeviceConfigurationProto.LOW_RAM, + ActivityManager.isLowRamDeviceStatic()); + protoOutputStream.write(DeviceConfigurationProto.MAX_CORES, + Runtime.getRuntime().availableProcessors()); + protoOutputStream.write(DeviceConfigurationProto.HAS_SECURE_SCREEN_LOCK, + kgm.isDeviceSecure()); + } + if (pw != null) { + pw.print("total-ram: "); pw.println(memreader.getTotalSize()); + pw.print("low-ram: "); pw.println(ActivityManager.isLowRamDeviceStatic()); + pw.print("max-cores: "); pw.println(Runtime.getRuntime().availableProcessors()); + pw.print("has-secure-screen-lock: "); pw.println(kgm.isDeviceSecure()); + } + + ConfigurationInfo configInfo = null; + try { + configInfo = mTaskInterface.getDeviceConfigurationInfo(); + } catch (RemoteException e) { + throw e.rethrowFromSystemServer(); + } + if (configInfo.reqGlEsVersion != ConfigurationInfo.GL_ES_VERSION_UNDEFINED) { + if (protoOutputStream != null) { + protoOutputStream.write(DeviceConfigurationProto.OPENGL_VERSION, + configInfo.reqGlEsVersion); + } + if (pw != null) { + pw.print("opengl-version: 0x"); + pw.println(Integer.toHexString(configInfo.reqGlEsVersion)); + } + } + + Set glExtensionsSet = getGlExtensionsFromDriver(); + String[] glExtensions = new String[glExtensionsSet.size()]; + glExtensions = glExtensionsSet.toArray(glExtensions); + Arrays.sort(glExtensions); + for (int i = 0; i < glExtensions.length; i++) { + if (protoOutputStream != null) { + protoOutputStream.write(DeviceConfigurationProto.OPENGL_EXTENSIONS, + glExtensions[i]); + } + if (pw != null) { + pw.print("opengl-extensions: "); pw.println(glExtensions[i]); + } + + } + + PackageManager pm = mInternal.mContext.getPackageManager(); + List slibs = pm.getSharedLibraries(0); + Collections.sort(slibs, Comparator.comparing(SharedLibraryInfo::getName)); + for (int i = 0; i < slibs.size(); i++) { + if (protoOutputStream != null) { + protoOutputStream.write(DeviceConfigurationProto.SHARED_LIBRARIES, + slibs.get(i).getName()); + } + if (pw != null) { + pw.print("shared-libraries: "); pw.println(slibs.get(i).getName()); + } + } + + FeatureInfo[] features = pm.getSystemAvailableFeatures(); + Arrays.sort(features, (o1, o2) -> { + if (o1.name == o2.name) return 0; + if (o1.name == null) return -1; + if (o2.name == null) return 1; + return o1.name.compareTo(o2.name); + }); + + for (int i = 0; i < features.length; i++) { + if (features[i].name != null) { + if (protoOutputStream != null) { + protoOutputStream.write(DeviceConfigurationProto.FEATURES, features[i].name); + } + if (pw != null) { + pw.print("features: "); pw.println(features[i].name); + } + } + } + + if (protoOutputStream != null) { + protoOutputStream.end(token); + } + } + + private int getDisplayIdFromNextArg() { + int displayId = Integer.parseInt(getNextArgRequired()); + if (displayId < 0) { + throw new IllegalArgumentException("--display must be a non-negative integer"); + } + return displayId; + } + + int runGetConfig(PrintWriter pw) throws RemoteException { + int days = -1; + int displayId = Display.DEFAULT_DISPLAY; + boolean asProto = false; + boolean inclDevice = false; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--days")) { + days = Integer.parseInt(getNextArgRequired()); + if (days <= 0) { + throw new IllegalArgumentException("--days must be a positive integer"); + } + } else if (opt.equals("--proto")) { + asProto = true; + } else if (opt.equals("--device")) { + inclDevice = true; + } else if (opt.equals("--display")) { + displayId = getDisplayIdFromNextArg(); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + + Configuration config = mInterface.getConfiguration(); + if (config == null) { + getErrPrintWriter().println("Activity manager has no configuration"); + return -1; + } + + DisplayManager dm = mInternal.mContext.getSystemService(DisplayManager.class); + Display display = dm.getDisplay(displayId); + + if (display == null) { + getErrPrintWriter().println("Error: Display does not exist: " + displayId); + return -1; + } + + DisplayMetrics metrics = new DisplayMetrics(); + display.getMetrics(metrics); + + if (asProto) { + final ProtoOutputStream proto = new ProtoOutputStream(getOutFileDescriptor()); + config.writeResConfigToProto(proto, GlobalConfigurationProto.RESOURCES, metrics); + if (inclDevice) { + writeDeviceConfig(proto, GlobalConfigurationProto.DEVICE, null, config, metrics); + } + proto.flush(); + } else { + pw.println("config: " + Configuration.resourceQualifierString(config, metrics)); + pw.println("abi: " + TextUtils.join(",", Build.SUPPORTED_ABIS)); + if (inclDevice) { + writeDeviceConfig(null, -1, pw, config, metrics); + } + + if (days >= 0) { + final List recentConfigs = getRecentConfigurations(days); + final int recentConfigSize = recentConfigs.size(); + if (recentConfigSize > 0) { + pw.println("recentConfigs:"); + for (int i = 0; i < recentConfigSize; i++) { + pw.println(" config: " + Configuration.resourceQualifierString( + recentConfigs.get(i))); + } + } + } + + } + return 0; + } + + int runSuppressResizeConfigChanges(PrintWriter pw) throws RemoteException { + boolean suppress = Boolean.valueOf(getNextArgRequired()); + mTaskInterface.suppressResizeConfigChanges(suppress); + return 0; + } + + int runSetInactive(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + String packageName = getNextArgRequired(); + String value = getNextArgRequired(); + + IUsageStatsManager usm = IUsageStatsManager.Stub.asInterface(ServiceManager.getService( + Context.USAGE_STATS_SERVICE)); + usm.setAppInactive(packageName, Boolean.parseBoolean(value), userId); + return 0; + } + + private int bucketNameToBucketValue(String name) { + String lower = name.toLowerCase(); + if (lower.startsWith("ac")) { + return UsageStatsManager.STANDBY_BUCKET_ACTIVE; + } else if (lower.startsWith("wo")) { + return UsageStatsManager.STANDBY_BUCKET_WORKING_SET; + } else if (lower.startsWith("fr")) { + return UsageStatsManager.STANDBY_BUCKET_FREQUENT; + } else if (lower.startsWith("ra")) { + return UsageStatsManager.STANDBY_BUCKET_RARE; + } else if (lower.startsWith("re")) { + return UsageStatsManager.STANDBY_BUCKET_RESTRICTED; + } else if (lower.startsWith("ne")) { + return UsageStatsManager.STANDBY_BUCKET_NEVER; + } else { + try { + int bucket = Integer.parseInt(lower); + return bucket; + } catch (NumberFormatException nfe) { + getErrPrintWriter().println("Error: Unknown bucket: " + name); + } + } + return -1; + } + + int runSetStandbyBucket(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + String packageName = getNextArgRequired(); + String value = getNextArgRequired(); + int bucket = bucketNameToBucketValue(value); + if (bucket < 0) return -1; + boolean multiple = peekNextArg() != null; + + + IUsageStatsManager usm = IUsageStatsManager.Stub.asInterface(ServiceManager.getService( + Context.USAGE_STATS_SERVICE)); + if (!multiple) { + usm.setAppStandbyBucket(packageName, bucketNameToBucketValue(value), userId); + } else { + ArrayList bucketInfoList = new ArrayList<>(); + bucketInfoList.add(new AppStandbyInfo(packageName, bucket)); + while ((packageName = getNextArg()) != null) { + value = getNextArgRequired(); + bucket = bucketNameToBucketValue(value); + if (bucket < 0) continue; + bucketInfoList.add(new AppStandbyInfo(packageName, bucket)); + } + ParceledListSlice slice = new ParceledListSlice<>(bucketInfoList); + usm.setAppStandbyBuckets(slice, userId); + } + return 0; + } + + int runGetStandbyBucket(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + String packageName = getNextArg(); + + IUsageStatsManager usm = IUsageStatsManager.Stub.asInterface(ServiceManager.getService( + Context.USAGE_STATS_SERVICE)); + if (packageName != null) { + int bucket = usm.getAppStandbyBucket(packageName, null, userId); + pw.println(bucket); + } else { + ParceledListSlice buckets = usm.getAppStandbyBuckets( + SHELL_PACKAGE_NAME, userId); + for (AppStandbyInfo bucketInfo : buckets.getList()) { + pw.print(bucketInfo.mPackageName); pw.print(": "); + pw.println(bucketInfo.mStandbyBucket); + } + } + return 0; + } + + int runGetInactive(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt=getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + String packageName = getNextArgRequired(); + + IUsageStatsManager usm = IUsageStatsManager.Stub.asInterface(ServiceManager.getService( + Context.USAGE_STATS_SERVICE)); + boolean isIdle = usm.isAppInactive(packageName, userId, SHELL_PACKAGE_NAME); + pw.println("Idle=" + isIdle); + return 0; + } + + int runSendTrimMemory(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + if (userId == UserHandle.USER_ALL) { + getErrPrintWriter().println("Error: Can't use user 'all'"); + return -1; + } + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + + String proc = getNextArgRequired(); + String levelArg = getNextArgRequired(); + int level; + switch (levelArg) { + case "HIDDEN": + level = ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN; + break; + case "RUNNING_MODERATE": + level = ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE; + break; + case "BACKGROUND": + level = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND; + break; + case "RUNNING_LOW": + level = ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW; + break; + case "MODERATE": + level = ComponentCallbacks2.TRIM_MEMORY_MODERATE; + break; + case "RUNNING_CRITICAL": + level = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL; + break; + case "COMPLETE": + level = ComponentCallbacks2.TRIM_MEMORY_COMPLETE; + break; + default: + try { + level = Integer.parseInt(levelArg); + } catch (NumberFormatException e) { + getErrPrintWriter().println("Error: Unknown level option: " + levelArg); + return -1; + } + } + if (!mInterface.setProcessMemoryTrimLevel(proc, userId, level)) { + getErrPrintWriter().println("Unknown error: failed to set trim level"); + return -1; + } + return 0; + } + + int runDisplay(PrintWriter pw) throws RemoteException { + String op = getNextArgRequired(); + switch (op) { + case "move-stack": + return runDisplayMoveStack(pw); + default: + getErrPrintWriter().println("Error: unknown command '" + op + "'"); + return -1; + } + } + + int runStack(PrintWriter pw) throws RemoteException { + String op = getNextArgRequired(); + switch (op) { + case "move-task": + return runStackMoveTask(pw); + case "list": + return runStackList(pw); + case "info": + return runRootTaskInfo(pw); + case "remove": + return runRootTaskRemove(pw); + default: + getErrPrintWriter().println("Error: unknown command '" + op + "'"); + return -1; + } + } + + + private Rect getBounds() { + String leftStr = getNextArgRequired(); + int left = Integer.parseInt(leftStr); + String topStr = getNextArgRequired(); + int top = Integer.parseInt(topStr); + String rightStr = getNextArgRequired(); + int right = Integer.parseInt(rightStr); + String bottomStr = getNextArgRequired(); + int bottom = Integer.parseInt(bottomStr); + if (left < 0) { + getErrPrintWriter().println("Error: bad left arg: " + leftStr); + return null; + } + if (top < 0) { + getErrPrintWriter().println("Error: bad top arg: " + topStr); + return null; + } + if (right <= 0) { + getErrPrintWriter().println("Error: bad right arg: " + rightStr); + return null; + } + if (bottom <= 0) { + getErrPrintWriter().println("Error: bad bottom arg: " + bottomStr); + return null; + } + return new Rect(left, top, right, bottom); + } + + int runDisplayMoveStack(PrintWriter pw) throws RemoteException { + String rootTaskIdStr = getNextArgRequired(); + int rootTaskId = Integer.parseInt(rootTaskIdStr); + String displayIdStr = getNextArgRequired(); + int displayId = Integer.parseInt(displayIdStr); + mTaskInterface.moveRootTaskToDisplay(rootTaskId, displayId); + return 0; + } + + int runStackMoveTask(PrintWriter pw) throws RemoteException { + String taskIdStr = getNextArgRequired(); + int taskId = Integer.parseInt(taskIdStr); + String rootTaskIdStr = getNextArgRequired(); + int rootTaskId = Integer.parseInt(rootTaskIdStr); + String toTopStr = getNextArgRequired(); + final boolean toTop; + if ("true".equals(toTopStr)) { + toTop = true; + } else if ("false".equals(toTopStr)) { + toTop = false; + } else { + getErrPrintWriter().println("Error: bad toTop arg: " + toTopStr); + return -1; + } + + mTaskInterface.moveTaskToRootTask(taskId, rootTaskId, toTop); + return 0; + } + + int runStackList(PrintWriter pw) throws RemoteException { + List tasks = mTaskInterface.getAllRootTaskInfos(); + for (RootTaskInfo info : tasks) { + pw.println(info); + } + return 0; + } + + int runRootTaskInfo(PrintWriter pw) throws RemoteException { + int windowingMode = Integer.parseInt(getNextArgRequired()); + int activityType = Integer.parseInt(getNextArgRequired()); + RootTaskInfo info = mTaskInterface.getRootTaskInfo(windowingMode, activityType); + pw.println(info); + return 0; + } + + int runRootTaskRemove(PrintWriter pw) throws RemoteException { + String taskIdStr = getNextArgRequired(); + int taskId = Integer.parseInt(taskIdStr); + mTaskInterface.removeTask(taskId); + return 0; + } + + int runTask(PrintWriter pw) throws RemoteException { + String op = getNextArgRequired(); + if (op.equals("lock")) { + return runTaskLock(pw); + } else if (op.equals("resizeable")) { + return runTaskResizeable(pw); + } else if (op.equals("resize")) { + return runTaskResize(pw); + } else if (op.equals("focus")) { + return runTaskFocus(pw); + } else { + getErrPrintWriter().println("Error: unknown command '" + op + "'"); + return -1; + } + } + + int runTaskLock(PrintWriter pw) throws RemoteException { + String taskIdStr = getNextArgRequired(); + if (taskIdStr.equals("stop")) { + mTaskInterface.stopSystemLockTaskMode(); + } else { + int taskId = Integer.parseInt(taskIdStr); + mTaskInterface.startSystemLockTaskMode(taskId); + } + pw.println("Activity manager is " + (mTaskInterface.isInLockTaskMode() ? "" : "not ") + + "in lockTaskMode"); + return 0; + } + + int runTaskResizeable(PrintWriter pw) throws RemoteException { + final String taskIdStr = getNextArgRequired(); + final int taskId = Integer.parseInt(taskIdStr); + final String resizeableStr = getNextArgRequired(); + final int resizeableMode = Integer.parseInt(resizeableStr); + mTaskInterface.setTaskResizeable(taskId, resizeableMode); + return 0; + } + + int runTaskResize(PrintWriter pw) throws RemoteException { + final String taskIdStr = getNextArgRequired(); + final int taskId = Integer.parseInt(taskIdStr); + final Rect bounds = getBounds(); + if (bounds == null) { + getErrPrintWriter().println("Error: invalid input bounds"); + return -1; + } + taskResize(taskId, bounds, 0, false); + return 0; + } + + void taskResize(int taskId, Rect bounds, int delay_ms, boolean pretendUserResize) + throws RemoteException { + final int resizeMode = pretendUserResize ? RESIZE_MODE_USER : RESIZE_MODE_SYSTEM; + mTaskInterface.resizeTask(taskId, bounds, resizeMode); + try { + Thread.sleep(delay_ms); + } catch (InterruptedException e) { + } + } + + int moveTask(int taskId, Rect taskRect, Rect stackRect, int stepSize, + int maxToTravel, boolean movingForward, boolean horizontal, int delay_ms) + throws RemoteException { + int maxMove; + if (movingForward) { + while (maxToTravel > 0 + && ((horizontal && taskRect.right < stackRect.right) + ||(!horizontal && taskRect.bottom < stackRect.bottom))) { + if (horizontal) { + maxMove = Math.min(stepSize, stackRect.right - taskRect.right); + maxToTravel -= maxMove; + taskRect.right += maxMove; + taskRect.left += maxMove; + } else { + maxMove = Math.min(stepSize, stackRect.bottom - taskRect.bottom); + maxToTravel -= maxMove; + taskRect.top += maxMove; + taskRect.bottom += maxMove; + } + taskResize(taskId, taskRect, delay_ms, false); + } + } else { + while (maxToTravel < 0 + && ((horizontal && taskRect.left > stackRect.left) + ||(!horizontal && taskRect.top > stackRect.top))) { + if (horizontal) { + maxMove = Math.min(stepSize, taskRect.left - stackRect.left); + maxToTravel -= maxMove; + taskRect.right -= maxMove; + taskRect.left -= maxMove; + } else { + maxMove = Math.min(stepSize, taskRect.top - stackRect.top); + maxToTravel -= maxMove; + taskRect.top -= maxMove; + taskRect.bottom -= maxMove; + } + taskResize(taskId, taskRect, delay_ms, false); + } + } + // Return the remaining distance we didn't travel because we reached the target location. + return maxToTravel; + } + + int getStepSize(int current, int target, int inStepSize, boolean greaterThanTarget) { + int stepSize = 0; + if (greaterThanTarget && target < current) { + current -= inStepSize; + stepSize = inStepSize; + if (target > current) { + stepSize -= (target - current); + } + } + if (!greaterThanTarget && target > current) { + current += inStepSize; + stepSize = inStepSize; + if (target < current) { + stepSize += (current - target); + } + } + return stepSize; + } + + int runTaskFocus(PrintWriter pw) throws RemoteException { + final int taskId = Integer.parseInt(getNextArgRequired()); + pw.println("Setting focus to task " + taskId); + mTaskInterface.setFocusedTask(taskId); + return 0; + } + + int runWrite(PrintWriter pw) { + mInternal.enforceCallingPermission(android.Manifest.permission.SET_ACTIVITY_WATCHER, + "registerUidObserver()"); + mInternal.mAtmInternal.flushRecentTasks(); + pw.println("All tasks persisted."); + return 0; + } + + int runAttachAgent(PrintWriter pw) { + // TODO: revisit the permissions required for attaching agents + mInternal.enforceCallingPermission(android.Manifest.permission.SET_ACTIVITY_WATCHER, + "attach-agent"); + String process = getNextArgRequired(); + String agent = getNextArgRequired(); + String opt; + if ((opt = getNextArg()) != null) { + pw.println("Error: Unknown option: " + opt); + return -1; + } + mInternal.attachAgent(process, agent); + return 0; + } + + int runSupportsMultiwindow(PrintWriter pw) throws RemoteException { + final Resources res = getResources(pw); + if (res == null) { + return -1; + } + pw.println(ActivityTaskManager.supportsMultiWindow(mInternal.mContext)); + return 0; + } + + int runSupportsSplitScreenMultiwindow(PrintWriter pw) throws RemoteException { + final Resources res = getResources(pw); + if (res == null) { + return -1; + } + pw.println(ActivityTaskManager.supportsSplitScreenMultiWindow(mInternal.mContext)); + return 0; + } + + int runUpdateApplicationInfo(PrintWriter pw) throws RemoteException { + int userid = UserHandle.parseUserArg(getNextArgRequired()); + ArrayList packages = new ArrayList<>(); + packages.add(getNextArgRequired()); + String packageName; + while ((packageName = getNextArg()) != null) { + packages.add(packageName); + } + mInternal.scheduleApplicationInfoChanged(packages, userid); + pw.println("Packages updated with most recent ApplicationInfos."); + return 0; + } + + int runNoHomeScreen(PrintWriter pw) throws RemoteException { + final Resources res = getResources(pw); + if (res == null) { + return -1; + } + pw.println(res.getBoolean(com.android.internal.R.bool.config_noHomeScreen)); + return 0; + } + + int runWaitForBroadcastIdle(PrintWriter pw) throws RemoteException { + pw = new PrintWriter(new TeeWriter(LOG_WRITER_INFO, pw)); + boolean flushBroadcastLoopers = false; + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--flush-broadcast-loopers")) { + flushBroadcastLoopers = true; + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInternal.waitForBroadcastIdle(pw, flushBroadcastLoopers); + return 0; + } + + int runWaitForBroadcastBarrier(PrintWriter pw) throws RemoteException { + pw = new PrintWriter(new TeeWriter(LOG_WRITER_INFO, pw)); + boolean flushBroadcastLoopers = false; + boolean flushApplicationThreads = false; + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--flush-broadcast-loopers")) { + flushBroadcastLoopers = true; + } else if (opt.equals("--flush-application-threads")) { + flushApplicationThreads = true; + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + mInternal.waitForBroadcastBarrier(pw, flushBroadcastLoopers, flushApplicationThreads); + return 0; + } + + int runWaitForApplicationBarrier(PrintWriter pw) throws RemoteException { + pw = new PrintWriter(new TeeWriter(LOG_WRITER_INFO, pw)); + mInternal.waitForApplicationBarrier(pw); + return 0; + } + + int runWaitForBroadcastDispatch(PrintWriter pw) throws RemoteException { + pw = new PrintWriter(new TeeWriter(LOG_WRITER_INFO, pw)); + final Intent intent; + try { + intent = makeIntent(UserHandle.USER_CURRENT); + } catch (URISyntaxException e) { + throw new RuntimeException(e.getMessage(), e); + } + mInternal.waitForBroadcastDispatch(pw, intent); + return 0; + } + + int runSetIgnoreDeliveryGroupPolicy(PrintWriter pw) throws RemoteException { + final String broadcastAction = getNextArgRequired(); + mInternal.setIgnoreDeliveryGroupPolicy(broadcastAction); + return 0; + } + + int runClearIgnoreDeliveryGroupPolicy(PrintWriter pw) throws RemoteException { + final String broadcastAction = getNextArgRequired(); + mInternal.clearIgnoreDeliveryGroupPolicy(broadcastAction); + return 0; + } + + int runRefreshSettingsCache() throws RemoteException { + mInternal.refreshSettingsCache(); + return 0; + } + + private int runCompat(PrintWriter pw) throws RemoteException { + final PlatformCompat platformCompat = (PlatformCompat) + ServiceManager.getService(Context.PLATFORM_COMPAT_SERVICE); + String toggleValue = getNextArgRequired(); + boolean killPackage = !"--no-kill".equals(getNextOption()); + boolean toggleAll = false; + int targetSdkVersion = -1; + long changeId = -1; + + if (toggleValue.endsWith("-all")) { + toggleValue = toggleValue.substring(0, toggleValue.lastIndexOf("-all")); + toggleAll = true; + if (!toggleValue.equals("reset")) { + try { + targetSdkVersion = Integer.parseInt(getNextArgRequired()); + } catch (NumberFormatException e) { + pw.println("Invalid targetSdkVersion!"); + return -1; + } + } + } else { + String changeIdString = getNextArgRequired(); + try { + changeId = Long.parseLong(changeIdString); + } catch (NumberFormatException e) { + changeId = platformCompat.lookupChangeId(changeIdString); + } + if (changeId == -1) { + pw.println("Unknown or invalid change: '" + changeIdString + "'."); + return -1; + } + } + String packageName = getNextArgRequired(); + if (!toggleAll && !platformCompat.isKnownChangeId(changeId)) { + pw.println("Warning! Change " + changeId + " is not known yet. Enabling/disabling it" + + " could have no effect."); + } + ArraySet enabled = new ArraySet<>(); + ArraySet disabled = new ArraySet<>(); + try { + switch (toggleValue) { + case "enable": + if (toggleAll) { + int numChanges = platformCompat.enableTargetSdkChanges(packageName, + targetSdkVersion); + if (numChanges == 0) { + pw.println("No changes were enabled."); + return -1; + } + pw.println("Enabled " + numChanges + " changes gated by targetSdkVersion " + + targetSdkVersion + " for " + packageName + "."); + } else { + enabled.add(changeId); + CompatibilityChangeConfig overrides = + new CompatibilityChangeConfig( + new Compatibility.ChangeConfig(enabled, disabled)); + if (killPackage) { + platformCompat.setOverrides(overrides, packageName); + } else { + platformCompat.setOverridesForTest(overrides, packageName); + } + pw.println("Enabled change " + changeId + " for " + packageName + "."); + } + return 0; + case "disable": + if (toggleAll) { + int numChanges = platformCompat.disableTargetSdkChanges(packageName, + targetSdkVersion); + if (numChanges == 0) { + pw.println("No changes were disabled."); + return -1; + } + pw.println("Disabled " + numChanges + " changes gated by targetSdkVersion " + + targetSdkVersion + " for " + packageName + "."); + } else { + disabled.add(changeId); + CompatibilityChangeConfig overrides = + new CompatibilityChangeConfig( + new Compatibility.ChangeConfig(enabled, disabled)); + if (killPackage) { + platformCompat.setOverrides(overrides, packageName); + } else { + platformCompat.setOverridesForTest(overrides, packageName); + } + pw.println("Disabled change " + changeId + " for " + packageName + "."); + } + return 0; + case "reset": + if (toggleAll) { + if (killPackage) { + platformCompat.clearOverrides(packageName); + } else { + platformCompat.clearOverridesForTest(packageName); + } + pw.println("Reset all changes for " + packageName + " to default value."); + return 0; + } + boolean existed; + if (killPackage) { + existed = platformCompat.clearOverride(changeId, packageName); + } else { + existed = platformCompat.clearOverrideForTest(changeId, packageName); + } + if (existed) { + pw.println("Reset change " + changeId + " for " + packageName + + " to default value."); + } else { + pw.println("No override exists for changeId " + changeId + "."); + } + return 0; + default: + pw.println("Invalid toggle value: '" + toggleValue + "'."); + } + } catch (SecurityException e) { + pw.println(e.getMessage()); + } + return -1; + } + + private int runGetCurrentForegroundProcess(PrintWriter pw, IActivityManager iam) + throws RemoteException { + + ProcessObserver observer = new ProcessObserver(pw, iam); + iam.registerProcessObserver(observer); + + final InputStream mInput = getRawInputStream(); + InputStreamReader converter = new InputStreamReader(mInput); + BufferedReader in = new BufferedReader(converter); + String line; + try { + while ((line = in.readLine()) != null) { + boolean addNewline = true; + if (line.length() <= 0) { + addNewline = false; + } else if ("q".equals(line) || "quit".equals(line)) { + break; + } else { + pw.println("Invalid command: " + line); + } + if (addNewline) { + pw.println(""); + } + pw.flush(); + } + } catch (IOException e) { + e.printStackTrace(); + pw.flush(); + } finally { + iam.unregisterProcessObserver(observer); + } + return 0; + } + + static final class ProcessObserver extends IProcessObserver.Stub { + + private PrintWriter mPw; + private IActivityManager mIam; + + ProcessObserver(PrintWriter mPw, IActivityManager mIam) { + this.mPw = mPw; + this.mIam = mIam; + } + + @Override + public void onForegroundActivitiesChanged(int pid, int uid, boolean foregroundActivities) { + if (foregroundActivities) { + try { + int prcState = mIam.getUidProcessState(uid, "android"); + + if (prcState == ProcessStateEnum.TOP) { + mPw.println("New foreground process: " + pid); + } else { + mPw.println("No top app found"); + } + mPw.flush(); + } catch (RemoteException e) { + mPw.println("Error occurred in binder call"); + mPw.flush(); + } + } + } + + @Override + public void onProcessStarted(int pid, int processUid, int packageUid, String packageName, + String processName) { + } + + @Override + public void onForegroundServicesChanged(int pid, int uid, int serviceTypes) { + } + + @Override + public void onProcessDied(int pid, int uid) { + } + } + + private int runSetMemoryFactor(PrintWriter pw) throws RemoteException { + final String levelArg = getNextArgRequired(); + @MemFactor int level = ADJ_MEM_FACTOR_NOTHING; + switch (levelArg) { + case "NORMAL": + level = ADJ_MEM_FACTOR_NORMAL; + break; + case "MODERATE": + level = ADJ_MEM_FACTOR_MODERATE; + break; + case "LOW": + level = ADJ_MEM_FACTOR_LOW; + break; + case "CRITICAL": + level = ADJ_MEM_FACTOR_CRITICAL; + break; + default: + try { + level = Integer.parseInt(levelArg); + } catch (NumberFormatException e) { + } + if (level < ADJ_MEM_FACTOR_NORMAL || level > ADJ_MEM_FACTOR_CRITICAL) { + getErrPrintWriter().println("Error: Unknown level option: " + levelArg); + return -1; + } + } + mInternal.setMemFactorOverride(level); + return 0; + } + + private int runShowMemoryFactor(PrintWriter pw) throws RemoteException { + final @MemFactor int level = mInternal.getMemoryTrimLevel(); + switch (level) { + case ADJ_MEM_FACTOR_NOTHING: + pw.println(""); + break; + case ADJ_MEM_FACTOR_NORMAL: + pw.println("NORMAL"); + break; + case ADJ_MEM_FACTOR_MODERATE: + pw.println("MODERATE"); + break; + case ADJ_MEM_FACTOR_LOW: + pw.println("LOW"); + break; + case ADJ_MEM_FACTOR_CRITICAL: + pw.println("CRITICAL"); + break; + } + pw.flush(); + return 0; + } + + private int runResetMemoryFactor(PrintWriter pw) throws RemoteException { + mInternal.setMemFactorOverride(ADJ_MEM_FACTOR_NOTHING); + return 0; + } + + private int runMemoryFactor(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.WRITE_SECURE_SETTINGS, + "runMemoryFactor()"); + + final String op = getNextArgRequired(); + switch (op) { + case "set": + return runSetMemoryFactor(pw); + case "show": + return runShowMemoryFactor(pw); + case "reset": + return runResetMemoryFactor(pw); + default: + getErrPrintWriter().println("Error: unknown command '" + op + "'"); + return -1; + } + } + + private int runServiceRestartBackoff(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.SET_PROCESS_LIMIT, + "runServiceRestartBackoff()"); + + final String opt = getNextArgRequired(); + switch (opt) { + case "enable": + mInternal.setServiceRestartBackoffEnabled(getNextArgRequired(), true, "shell"); + return 0; + case "disable": + mInternal.setServiceRestartBackoffEnabled(getNextArgRequired(), false, "shell"); + return 0; + case "show": + pw.println(mInternal.isServiceRestartBackoffEnabled(getNextArgRequired()) + ? "enabled" : "disabled"); + return 0; + default: + getErrPrintWriter().println("Error: unknown command '" + opt + "'"); + return -1; + } + } + + private int runGetIsolatedProcesses(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.DUMP, + "getIsolatedProcesses()"); + final List result = mInternal.mInternal.getIsolatedProcesses( + Integer.parseInt(getNextArgRequired())); + pw.print("["); + if (result != null) { + for (int i = 0, size = result.size(); i < size; i++) { + if (i > 0) { + pw.print(", "); + } + pw.print(result.get(i)); + } + } + pw.println("]"); + return 0; + } + + private int runSetStopUserOnSwitch(PrintWriter pw) throws RemoteException { + mInternal.enforceCallingPermission(android.Manifest.permission.INTERACT_ACROSS_USERS_FULL, + "setStopUserOnSwitch()"); + String arg = getNextArg(); + if (arg == null) { + Slogf.i(TAG, "setStopUserOnSwitch(): resetting to default value"); + mInternal.setStopUserOnSwitch(ActivityManager.STOP_USER_ON_SWITCH_DEFAULT); + pw.println("Reset to default value"); + return 0; + } + + boolean stop = Boolean.parseBoolean(arg); + int value = stop + ? ActivityManager.STOP_USER_ON_SWITCH_TRUE + : ActivityManager.STOP_USER_ON_SWITCH_FALSE; + + Slogf.i(TAG, "runSetStopUserOnSwitch(): setting to %d (%b)", value, stop); + mInternal.setStopUserOnSwitch(value); + pw.println("Set to " + stop); + + return 0; + } + + // TODO(b/203105544) STOPSHIP - For debugging only, to be removed before shipping. + private int runSetBgAbusiveUids(PrintWriter pw) throws RemoteException { + final String arg = getNextArg(); + final AppBatteryTracker batteryTracker = + mInternal.mAppRestrictionController.getAppStateTracker(AppBatteryTracker.class); + if (batteryTracker == null) { + getErrPrintWriter().println("Unable to get bg battery tracker"); + return -1; + } + if (arg == null) { + batteryTracker.clearDebugUidPercentage(); + return 0; + } + String[] pairs = arg.split(","); + int[] uids = new int[pairs.length]; + double[][] values = new double[pairs.length][]; + try { + for (int i = 0; i < pairs.length; i++) { + String[] pair = pairs[i].split("="); + if (pair.length != 2) { + getErrPrintWriter().println("Malformed input"); + return -1; + } + uids[i] = Integer.parseInt(pair[0]); + final String[] vals = pair[1].split(":"); + if (vals.length != BATTERY_USAGE_COUNT) { + getErrPrintWriter().println("Malformed input"); + return -1; + } + values[i] = new double[vals.length]; + for (int j = 0; j < vals.length; j++) { + values[i][j] = Double.parseDouble(vals[j]); + } + } + } catch (NumberFormatException e) { + getErrPrintWriter().println("Malformed input"); + return -1; + } + batteryTracker.setDebugUidPercentage(uids, values); + return 0; + } + + private int runListBgExemptionsConfig(PrintWriter pw) throws RemoteException { + final ArraySet sysConfigs = mInternal.mAppRestrictionController + .mBgRestrictionExemptioFromSysConfig; + if (sysConfigs != null) { + for (int i = 0, size = sysConfigs.size(); i < size; i++) { + pw.print(sysConfigs.valueAt(i)); + pw.print(' '); + } + pw.println(); + } + return 0; + } + + private @ActivityManager.RestrictionLevel int restrictionNameToLevel(String name) { + String lower = name.toLowerCase(); + switch (lower) { + case "unrestricted": + return ActivityManager.RESTRICTION_LEVEL_UNRESTRICTED; + case "exempted": + return ActivityManager.RESTRICTION_LEVEL_EXEMPTED; + case "adaptive_bucket": + return ActivityManager.RESTRICTION_LEVEL_ADAPTIVE_BUCKET; + case "restricted_bucket": + return ActivityManager.RESTRICTION_LEVEL_RESTRICTED_BUCKET; + case "background_restricted": + return ActivityManager.RESTRICTION_LEVEL_BACKGROUND_RESTRICTED; + case "hibernation": + return ActivityManager.RESTRICTION_LEVEL_HIBERNATION; + default: + return ActivityManager.RESTRICTION_LEVEL_UNKNOWN; + } + } + + int runSetBgRestrictionLevel(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + String packageName = getNextArgRequired(); + final String value = getNextArgRequired(); + final int level = restrictionNameToLevel(value); + if (level == ActivityManager.RESTRICTION_LEVEL_UNKNOWN) { + pw.println("Error: invalid restriction level"); + return -1; + } + + int uid = INVALID_UID; + try { + final PackageManager pm = mInternal.mContext.getPackageManager(); + uid = pm.getPackageUidAsUser(packageName, + PackageManager.PackageInfoFlags.of(MATCH_ANY_USER), userId); + } catch (PackageManager.NameNotFoundException e) { + pw.println("Error: userId:" + userId + " package:" + packageName + " is not found"); + return -1; + } + mInternal.setBackgroundRestrictionLevel(packageName, uid, userId, level, + REASON_MAIN_FORCED_BY_USER, REASON_SUB_FORCED_SYSTEM_FLAG_UNDEFINED); + return 0; + } + + int runGetBgRestrictionLevel(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + final String packageName = getNextArgRequired(); + final @ActivityManager.RestrictionLevel int level = + mInternal.getBackgroundRestrictionLevel(packageName, userId); + pw.println(ActivityManager.restrictionLevelToName(level)); + return 0; + } + + int runSetForegroundServiceDelegate(PrintWriter pw) throws RemoteException { + int userId = UserHandle.USER_CURRENT; + + String opt; + while ((opt = getNextOption()) != null) { + if (opt.equals("--user")) { + userId = UserHandle.parseUserArg(getNextArgRequired()); + } else { + getErrPrintWriter().println("Error: Unknown option: " + opt); + return -1; + } + } + final String packageName = getNextArgRequired(); + final String action = getNextArgRequired(); + boolean isStart = true; + if ("start".equals(action)) { + isStart = true; + } else if ("stop".equals(action)) { + isStart = false; + } else { + pw.println("Error: action is either start or stop"); + return -1; + } + + int uid = INVALID_UID; + try { + final PackageManager pm = mInternal.mContext.getPackageManager(); + uid = pm.getPackageUidAsUser(packageName, + PackageManager.PackageInfoFlags.of(MATCH_ANY_USER), userId); + } catch (PackageManager.NameNotFoundException e) { + pw.println("Error: userId:" + userId + " package:" + packageName + " is not found"); + return -1; + } + mInternal.setForegroundServiceDelegate(packageName, uid, isStart, + ForegroundServiceDelegationOptions.DELEGATION_SERVICE_SPECIAL_USE, + "FgsDelegate"); + return 0; + } + + int runResetDropboxRateLimiter() throws RemoteException { + mInternal.resetDropboxRateLimiter(); + return 0; + } + + int runListDisplaysForStartingUsers(PrintWriter pw) throws RemoteException { + int[] displayIds = mInterface.getDisplayIdsForStartingVisibleBackgroundUsers(); + // NOTE: format below cannot be changed as it's used by ITestDevice + pw.println(displayIds == null || displayIds.length == 0 + ? "none" + : Arrays.toString(displayIds)); + return 0; + } + + int runUpdateConfig(PrintWriter pw) throws RemoteException { + Configuration config = mInterface.getConfiguration(); + if (config == null) { + getErrPrintWriter().println("Activity manager has no configuration"); + return -1; + } + boolean update = false; + String opt; + if ((opt = getNextOption()) != null && opt.equals("--locale")) { + String locales = getNextArgRequired(); + pw.println("update locales: " + locales); + final LocaleList preferredLocales = TextUtils.isEmpty(locales) ? + LocaleList.getDefault() : LocaleList.forLanguageTags(locales); + config.setLocales(preferredLocales); + config.userSetLocale = true; + update = true; + } + pw.println("update config: " + config); + if (update) { + mInterface.updatePersistentConfiguration(config); + } + return 0; + } + + private Resources getResources(PrintWriter pw) throws RemoteException { + // system resources does not contain all the device configuration, construct it manually. + Configuration config = mInterface.getConfiguration(); + if (config == null) { + pw.println("Error: Activity manager has no configuration"); + return null; + } + + final DisplayMetrics metrics = new DisplayMetrics(); + metrics.setToDefaults(); + + return new Resources(AssetManager.getSystem(), metrics, config); + } + + @Override + public void onHelp() { + PrintWriter pw = getOutPrintWriter(); + dumpHelp(pw, mDumping); + } + + @NeverCompile // Avoid size overhead of debugging code. + static void dumpHelp(PrintWriter pw, boolean dumping) { + if (dumping) { + pw.println("Activity manager dump options:"); + pw.println(" [-a] [-c] [-p PACKAGE] [-h] [WHAT] ..."); + pw.println(" WHAT may be one of:"); + pw.println(" a[ctivities]: activity stack state"); + pw.println(" r[recents]: recent activities state"); + pw.println(" b[roadcasts] [PACKAGE_NAME] [history [-s]]: broadcast state"); + pw.println(" broadcast-stats [PACKAGE_NAME]: aggregated broadcast statistics"); + pw.println(" i[ntents] [PACKAGE_NAME]: pending intent state"); + pw.println(" p[rocesses] [PACKAGE_NAME]: process state"); + pw.println(" o[om]: out of memory management"); + pw.println(" perm[issions]: URI permission grant state"); + pw.println(" prov[iders] [COMP_SPEC ...]: content provider state"); + pw.println(" provider [COMP_SPEC]: provider client-side state"); + pw.println(" s[ervices] [COMP_SPEC ...]: service state"); + pw.println(" allowed-associations: current package association restrictions"); + pw.println(" as[sociations]: tracked app associations"); + pw.println(" start-info [PACKAGE_NAME]: historical process start information"); + pw.println(" exit-info [PACKAGE_NAME]: historical process exit information"); + pw.println(" lmk: stats on low memory killer"); + pw.println(" lru: raw LRU process list"); + pw.println(" binder-proxies: stats on binder objects and IPCs"); + pw.println(" settings: currently applied config settings"); + pw.println(" timers: the current ANR timer state"); + pw.println(" service [COMP_SPEC]: service client-side state"); + pw.println(" package [PACKAGE_NAME]: all state related to given package"); + pw.println(" all: dump all activities"); + pw.println(" top: dump the top activity"); + pw.println(" users: user state"); + pw.println(" WHAT may also be a COMP_SPEC to dump activities."); + pw.println(" COMP_SPEC may be a component name (com.foo/.myApp),"); + pw.println(" a partial substring in a component name, a"); + pw.println(" hex object identifier."); + pw.println(" -a: include all available server state."); + pw.println(" -c: include client state."); + pw.println(" -p: limit output to given package."); + pw.println(" -d: limit output to given display."); + pw.println(" --checkin: output checkin format, resetting data."); + pw.println(" --C: output checkin format, not resetting data."); + pw.println(" --proto: output dump in protocol buffer format."); + pw.printf(" %s: dump just the DUMPABLE-related state of an activity. Use the %s " + + "option to list the supported DUMPABLEs\n", Activity.DUMP_ARG_DUMP_DUMPABLE, + Activity.DUMP_ARG_LIST_DUMPABLES); + pw.printf(" %s: show the available dumpables in an activity\n", + Activity.DUMP_ARG_LIST_DUMPABLES); + } else { + pw.println("Activity manager (activity) commands:"); + pw.println(" help"); + pw.println(" Print this help text."); + pw.println(" start-activity [-D] [-N] [-W] [-P ] [--start-profiler ]"); + pw.println(" [--sampling INTERVAL] [--clock-type ] [--streaming]"); + pw.println(" [-R COUNT] [-S] [--track-allocation]"); + pw.println(" [--user | current] [--suspend] "); + pw.println(" Start an Activity. Options are:"); + pw.println(" -D: enable debugging"); + pw.println(" --suspend: debugged app suspend threads at startup (only with -D)"); + pw.println(" -N: enable native debugging"); + pw.println(" -W: wait for launch to complete (initial display)"); + pw.println(" --start-profiler : start profiler and send results to "); + pw.println(" --sampling INTERVAL: use sample profiling with INTERVAL microseconds"); + pw.println(" between samples (use with --start-profiler)"); + pw.println(" --clock-type : type can be wall / thread-cpu / dual. Specify"); + pw.println(" the clock that is used to report the timestamps when profiling"); + pw.println(" The default value is dual. (use with --start-profiler)"); + pw.println(" --streaming: stream the profiling output to the specified file"); + pw.println(" (use with --start-profiler)"); + pw.println(" -P : like above, but profiling stops when app goes idle"); + pw.println(" --attach-agent : attach the given agent before binding"); + pw.println(" --attach-agent-bind : attach the given agent during binding"); + pw.println(" -R: repeat the activity launch times. Prior to each repeat,"); + pw.println(" the top activity will be finished."); + pw.println(" -S: force stop the target app before starting the activity"); + pw.println(" --track-allocation: enable tracking of object allocations"); + pw.println(" --user | current: Specify which user to run as; if not"); + pw.println(" specified then run as the current user."); + pw.println(" --windowingMode : The windowing mode to launch the activity into."); + pw.println(" --activityType : The activity type to launch the activity as."); + pw.println(" --display : The display to launch the activity into."); + pw.println(" --splashscreen-icon: Show the splash screen icon on launch."); + pw.println(" start-service [--user | current] "); + pw.println(" Start a Service. Options are:"); + pw.println(" --user | current: Specify which user to run as; if not"); + pw.println(" specified then run as the current user."); + pw.println(" start-foreground-service [--user | current] "); + pw.println(" Start a foreground Service. Options are:"); + pw.println(" --user | current: Specify which user to run as; if not"); + pw.println(" specified then run as the current user."); + pw.println(" stop-service [--user | current] "); + pw.println(" Stop a Service. Options are:"); + pw.println(" --user | current: Specify which user to run as; if not"); + pw.println(" specified then run as the current user."); + pw.println(" broadcast [--user | all | current]"); + pw.println(" [--receiver-permission ]"); + pw.println(" [--allow-background-activity-starts]"); + pw.println(" [--async] "); + pw.println(" Send a broadcast Intent. Options are:"); + pw.println(" --user | all | current: Specify which user to send to; if not"); + pw.println(" specified then send to all users."); + pw.println(" --receiver-permission : Require receiver to hold permission."); + pw.println(" --allow-background-activity-starts: The receiver may start activities"); + pw.println(" even if in the background."); + pw.println(" --async: Send without waiting for the completion of the receiver."); + pw.println(" compact [some|full] [--user ]"); + pw.println(" Perform a single process compaction."); + pw.println(" some: execute file compaction."); + pw.println(" full: execute anon + file compaction."); + pw.println(" system: system compaction."); + pw.println(" compact system"); + pw.println(" Perform a full system compaction."); + pw.println(" compact native [some|full] "); + pw.println(" Perform a native compaction for process with ."); + pw.println(" some: execute file compaction."); + pw.println(" full: execute anon + file compaction."); + pw.println(" freeze [--sticky] [--user ]"); + pw.println(" Freeze a process."); + pw.println(" --sticky: persists the frozen state for the process lifetime or"); + pw.println(" until an unfreeze is triggered via shell"); + pw.println(" unfreeze [--sticky] [--user ]"); + pw.println(" Unfreeze a process."); + pw.println(" --sticky: persists the unfrozen state for the process lifetime or"); + pw.println(" until a freeze is triggered via shell"); + pw.println(" instrument [-r] [-e ] [-p ] [-w]"); + pw.println(" [--user | current]"); + pw.println(" [--no-hidden-api-checks [--no-test-api-access]]"); + pw.println(" [--no-isolated-storage]"); + pw.println(" [--no-window-animation] [--abi ] "); + pw.println(" Start an Instrumentation. Typically this target is in the"); + pw.println(" form / or only if there"); + pw.println(" is only one instrumentation. Options are:"); + pw.println(" -r: print raw results (otherwise decode REPORT_KEY_STREAMRESULT). Use with"); + pw.println(" [-e perf true] to generate raw output for performance measurements."); + pw.println(" -e : set argument to . For test runners a"); + pw.println(" common form is [-e [,...]]."); + pw.println(" -p : write profiling data to "); + pw.println(" -m: Write output as protobuf to stdout (machine readable)"); + pw.println(" -f : Write output as protobuf to a file (machine"); + pw.println(" readable). If path is not specified, default directory and file name will"); + pw.println(" be used: /sdcard/instrument-logs/log-yyyyMMdd-hhmmss-SSS.instrumentation_data_proto"); + pw.println(" -w: wait for instrumentation to finish before returning. Required for"); + pw.println(" test runners."); + pw.println(" --user | current: Specify user instrumentation runs in;"); + pw.println(" current user if not specified."); + pw.println(" --no-hidden-api-checks: disable restrictions on use of hidden API."); + pw.println(" --no-test-api-access: do not allow access to test APIs, if hidden"); + pw.println(" API checks are enabled."); + pw.println(" --no-isolated-storage: don't use isolated storage sandbox and "); + pw.println(" mount full external storage"); + pw.println(" --no-window-animation: turn off window animations while running."); + pw.println(" --abi : Launch the instrumented process with the selected ABI."); + pw.println(" This assumes that the process supports the selected ABI."); + pw.println(" trace-ipc [start|stop] [--dump-file ]"); + pw.println(" Trace IPC transactions."); + pw.println(" start: start tracing IPC transactions."); + pw.println(" stop: stop tracing IPC transactions and dump the results to file."); + pw.println(" --dump-file : Specify the file the trace should be dumped to."); + pw.println(" profile start [--user current]"); + pw.println(" [--clock-type ]"); + pw.println(" [--sampling INTERVAL | --streaming] "); + pw.println(" Start profiler on a process. The given argument"); + pw.println(" may be either a process name or pid. Options are:"); + pw.println(" --user | current: When supplying a process name,"); + pw.println(" specify user of process to profile; uses current user if not"); + pw.println(" specified."); + pw.println(" --clock-type : use the specified clock to report timestamps."); + pw.println(" The type can be one of wall | thread-cpu | dual. The default"); + pw.println(" value is dual."); + pw.println(" --sampling INTERVAL: use sample profiling with INTERVAL microseconds"); + pw.println(" between samples."); + pw.println(" --streaming: stream the profiling output to the specified file."); + pw.println(" profile stop [--user current] "); + pw.println(" Stop profiler on a process. The given argument"); + pw.println(" may be either a process name or pid. Options are:"); + pw.println(" --user | current: When supplying a process name,"); + pw.println(" specify user of process to profile; uses current user if not"); + pw.println(" specified."); + pw.println(" dumpheap [--user current] [-n] [-g] "); + pw.println(" Dump the heap of a process. The given argument may"); + pw.println(" be either a process name or pid. Options are:"); + pw.println(" -n: dump native heap instead of managed heap"); + pw.println(" -g: force GC before dumping the heap"); + pw.println(" --user | current: When supplying a process name,"); + pw.println(" specify user of process to dump; uses current user if not specified."); + pw.println(" set-debug-app [-w] [--persistent] "); + pw.println(" Set application to debug. Options are:"); + pw.println(" -w: wait for debugger when application starts"); + pw.println(" --persistent: retain this value"); + pw.println(" clear-debug-app"); + pw.println(" Clear the previously set-debug-app."); + pw.println(" set-watch-heap "); + pw.println(" Start monitoring pss size of , if it is at or"); + pw.println(" above then a heap dump is collected for the user to report."); + pw.println(" clear-watch-heap"); + pw.println(" Clear the previously set-watch-heap."); + pw.println(" clear-start-info [--user | all | current] [package]"); + pw.println(" Clear the process start-info for given package"); + pw.println(" clear-exit-info [--user | all | current] [package]"); + pw.println(" Clear the process exit-info for given package"); + pw.println(" bug-report [--progress | --telephony]"); + pw.println(" Request bug report generation; will launch a notification"); + pw.println(" when done to select where it should be delivered. Options are:"); + pw.println(" --progress: will launch a notification right away to show its progress."); + pw.println(" --telephony: will dump only telephony sections."); + pw.println(" fgs-notification-rate-limit {enable | disable}"); + pw.println(" Enable/disable rate limit on FGS notification deferral policy."); + pw.println(" force-stop [--user | all | current] "); + pw.println(" Completely stop the given application package."); + pw.println(" stop-app [--user | all | current] "); + pw.println(" Stop an app and all of its services. Unlike `force-stop` this does"); + pw.println(" not cancel the app's scheduled alarms and jobs."); + pw.println(" crash [--user ] "); + pw.println(" Induce a VM crash in the specified package or process"); + pw.println(" kill [--user | all | current] "); + pw.println(" Kill all background processes associated with the given application."); + pw.println(" kill-all"); + pw.println(" Kill all processes that are safe to kill (cached, etc)."); + pw.println(" make-uid-idle [--user | all | current] "); + pw.println(" If the given application's uid is in the background and waiting to"); + pw.println(" become idle (not allowing background services), do that now."); + pw.println( + " set-deterministic-uid-idle [--user | all | current] "); + pw.println(" If true, sets the timing of making UIDs idle consistent and"); + pw.println(" deterministic. If false, the timing will be variable depending on"); + pw.println(" other activity on the device. The default is false."); + pw.println(" monitor [--gdb ] [-p ] [-s] [-c] [-k]"); + pw.println(" Start monitoring for crashes or ANRs."); + pw.println(" --gdb: start gdbserv on the given port at crash/ANR"); + pw.println(" -p: only show events related to a specific process / package"); + pw.println(" -s: simple mode, only show a summary line for each event"); + pw.println(" -c: assume the input is always [c]ontinue"); + pw.println(" -k: assume the input is always [k]ill"); + pw.println(" -c and -k are mutually exclusive."); + pw.println(" watch-uids [--oom ] [--mask ]"); + pw.println(" Start watching for and reporting uid state changes."); + pw.println(" --oom: specify a uid for which to report detailed change messages."); + pw.println(" --mask: Specify PROCESS_CAPABILITY_XXX mask to report. "); + pw.println(" By default, it only reports FOREGROUND_LOCATION (1)"); + pw.println(" FOREGROUND_CAMERA (2), FOREGROUND_MICROPHONE (4)"); + pw.println(" and NETWORK (8). New capabilities added on or after"); + pw.println(" Android UDC will not be reported by default."); + pw.println(" hang [--allow-restart]"); + pw.println(" Hang the system."); + pw.println(" --allow-restart: allow watchdog to perform normal system restart"); + pw.println(" restart"); + pw.println(" Restart the user-space system."); + pw.println(" idle-maintenance"); + pw.println(" Perform idle maintenance now."); + pw.println(" screen-compat [on|off] "); + pw.println(" Control screen compatibility mode of ."); + pw.println(" package-importance "); + pw.println(" Print current importance of ."); + pw.println(" to-uri [INTENT]"); + pw.println(" Print the given Intent specification as a URI."); + pw.println(" to-intent-uri [INTENT]"); + pw.println(" Print the given Intent specification as an intent: URI."); + pw.println(" to-app-uri [INTENT]"); + pw.println(" Print the given Intent specification as an android-app: URI."); + pw.println(" switch-user "); + pw.println(" Switch to put USER_ID in the foreground, starting"); + pw.println(" execution of that user if it is currently stopped."); + pw.println(" get-current-user"); + pw.println(" Returns id of the current foreground user."); + pw.println(" start-user [-w] [--display DISPLAY_ID] "); + pw.println(" Start USER_ID in background if it is currently stopped;"); + pw.println(" use switch-user if you want to start the user in foreground."); + pw.println(" -w: wait for start-user to complete and the user to be unlocked."); + pw.println(" --display : starts the user visible in that display, " + + "which allows the user to launch activities on it."); + pw.println(" (not supported on all devices; typically only on automotive builds " + + "where the vehicle has passenger displays)"); + pw.println(" unlock-user "); + pw.println(" Unlock the given user. This will only work if the user doesn't"); + pw.println(" have an LSKF (PIN/pattern/password)."); + pw.println(" stop-user [-w] [-f] "); + pw.println(" Stop execution of USER_ID, not allowing it to run any"); + pw.println(" code until a later explicit start or switch to it."); + pw.println(" -w: wait for stop-user to complete."); + pw.println(" -f: force stop even if there are related users that cannot be stopped."); + pw.println(" is-user-stopped "); + pw.println(" Returns whether has been stopped or not."); + pw.println(" get-started-user-state "); + pw.println(" Gets the current state of the given started user."); + pw.println(" track-associations"); + pw.println(" Enable association tracking."); + pw.println(" untrack-associations"); + pw.println(" Disable and clear association tracking."); + pw.println(" get-uid-state "); + pw.println(" Gets the process state of an app given its ."); + pw.println(" attach-agent "); + pw.println(" Attach an agent to the specified , which may be either a process name or a PID."); + pw.println(" get-config [--days N] [--device] [--proto] [--display ]"); + pw.println(" Retrieve the configuration and any recent configurations of the device."); + pw.println(" --days: also return last N days of configurations that have been seen."); + pw.println(" --device: also output global device configuration info."); + pw.println(" --proto: return result as a proto; does not include --days info."); + pw.println(" --display: Specify for which display to run the command; if not "); + pw.println(" specified then run for the default display."); + pw.println(" supports-multiwindow"); + pw.println(" Returns true if the device supports multiwindow."); + pw.println(" supports-split-screen-multi-window"); + pw.println(" Returns true if the device supports split screen multiwindow."); + pw.println(" suppress-resize-config-changes "); + pw.println(" Suppresses configuration changes due to user resizing an activity/task."); + pw.println(" set-inactive [--user ] true|false"); + pw.println(" Sets the inactive state of an app."); + pw.println(" get-inactive [--user ] "); + pw.println(" Returns the inactive state of an app."); + pw.println(" set-standby-bucket [--user ] active|working_set|frequent|rare|restricted"); + pw.println(" Puts an app in the standby bucket."); + pw.println(" get-standby-bucket [--user ] "); + pw.println(" Returns the standby bucket of an app."); + pw.println(" send-trim-memory [--user ] "); + pw.println(" [HIDDEN|RUNNING_MODERATE|BACKGROUND|RUNNING_LOW|MODERATE|RUNNING_CRITICAL|COMPLETE]"); + pw.println(" Send a memory trim event to a . May also supply a raw trim int level."); + pw.println(" display [COMMAND] [...]: sub-commands for operating on displays."); + pw.println(" move-stack "); + pw.println(" Move from its current display to ."); + pw.println(" stack [COMMAND] [...]: sub-commands for operating on activity stacks."); + pw.println(" move-task [true|false]"); + pw.println(" Move from its current stack to the top (true) or"); + pw.println(" bottom (false) of ."); + pw.println(" list"); + pw.println(" List all of the activity stacks and their sizes."); + pw.println(" info "); + pw.println(" Display the information about activity stack in and ."); + pw.println(" remove "); + pw.println(" Remove stack ."); + pw.println(" task [COMMAND] [...]: sub-commands for operating on activity tasks."); + pw.println(" lock "); + pw.println(" Bring to the front and don't allow other tasks to run."); + pw.println(" lock stop"); + pw.println(" End the current task lock."); + pw.println(" resizeable [0|1|2|3]"); + pw.println(" Change resizeable mode of to one of the following:"); + pw.println(" 0: unresizeable"); + pw.println(" 1: crop_windows"); + pw.println(" 2: resizeable"); + pw.println(" 3: resizeable_and_pipable"); + pw.println(" resize "); + pw.println(" Makes sure is in a stack with the specified bounds."); + pw.println(" Forces the task to be resizeable and creates a stack if no existing stack"); + pw.println(" has the specified bounds."); + pw.println(" update-appinfo [...]"); + pw.println(" Update the ApplicationInfo objects of the listed packages for "); + pw.println(" without restarting any processes."); + pw.println(" write"); + pw.println(" Write all pending state to storage."); + pw.println(" compat [COMMAND] [...]: sub-commands for toggling app-compat changes."); + pw.println(" enable|disable [--no-kill] "); + pw.println(" Toggles a change either by id or by name for ."); + pw.println(" It kills (to allow the toggle to take effect) unless --no-kill is provided."); + pw.println(" reset "); + pw.println(" Toggles a change either by id or by name for ."); + pw.println(" It kills (to allow the toggle to take effect)."); + pw.println(" enable-all|disable-all "); + pw.println(" Toggles all changes that are gated by ."); + pw.println(" reset-all [--no-kill] "); + pw.println(" Removes all existing overrides for all changes for "); + pw.println(" (back to default behaviour)."); + pw.println(" It kills (to allow the toggle to take effect) unless --no-kill is provided."); + pw.println(" memory-factor [command] [...]: sub-commands for overriding memory pressure factor"); + pw.println(" set "); + pw.println(" Overrides memory pressure factor. May also supply a raw int level"); + pw.println(" show"); + pw.println(" Shows the existing memory pressure factor"); + pw.println(" reset"); + pw.println(" Removes existing override for memory pressure factor"); + pw.println(" service-restart-backoff [...]: sub-commands to toggle service restart backoff policy."); + pw.println(" enable|disable "); + pw.println(" Toggles the restart backoff policy on/off for ."); + pw.println(" show "); + pw.println(" Shows the restart backoff policy state for ."); + pw.println(" get-isolated-pids "); + pw.println(" Get the PIDs of isolated processes with packages in this "); + pw.println(" set-stop-user-on-switch [true|false]"); + pw.println(" Sets whether the current user (and its profiles) should be stopped" + + " when switching to a different user."); + pw.println(" Without arguments, it resets to the value defined by platform."); + pw.println(" set-bg-abusive-uids [uid=percentage][,uid=percentage...]"); + pw.println(" Force setting the battery usage of the given UID."); + pw.println(" set-bg-restriction-level [--user ] unrestricted|exempted|adaptive_bucket|restricted_bucket|background_restricted|hibernation"); + pw.println(" Set an app's background restriction level which in turn map to a app standby bucket."); + pw.println(" get-bg-restriction-level [--user ] "); + pw.println(" Get an app's background restriction level."); + pw.println(" list-displays-for-starting-users"); + pw.println(" Lists the id of displays that can be used to start users on " + + "background."); + pw.println(" set-foreground-service-delegate [--user ] start|stop"); + pw.println(" Start/stop an app's foreground service delegate."); + pw.println(" set-ignore-delivery-group-policy "); + pw.println(" Start ignoring delivery group policy set for a broadcast action"); + pw.println(" clear-ignore-delivery-group-policy "); + pw.println(" Stop ignoring delivery group policy set for a broadcast action"); + pw.println(" capabilities [--protobuf]"); + pw.println(" Output am supported features (text format). Options are:"); + pw.println(" --protobuf: format output using protobuffer"); + pw.println(" update-config [--locale ]"); + pw.println(" upate the configuration and any recent configurations of the device."); + pw.println(" --locale ]: update system_locales settings provider and locale list by language-tags."); + pw.println(" for example:'--locale zh-CN,en-US'."); + Intent.printIntentArgsHelp(pw, ""); + } + } +} diff --git a/aosp/packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java b/aosp/packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java new file mode 100644 index 000000000..ee91d98a6 --- /dev/null +++ b/aosp/packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java @@ -0,0 +1,11680 @@ +/* + * Copyright (C) 2006 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.providers.media; + +import static android.Manifest.permission.ACCESS_MEDIA_LOCATION; +import static android.app.AppOpsManager.permissionToOp; +import static android.app.PendingIntent.FLAG_CANCEL_CURRENT; +import static android.app.PendingIntent.FLAG_IMMUTABLE; +import static android.app.PendingIntent.FLAG_ONE_SHOT; +import static android.content.ContentResolver.QUERY_ARG_SQL_GROUP_BY; +import static android.content.ContentResolver.QUERY_ARG_SQL_HAVING; +import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION; +import static android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS; +import static android.content.ContentResolver.QUERY_ARG_SQL_SORT_ORDER; +import static android.content.pm.PackageManager.PERMISSION_GRANTED; +import static android.database.Cursor.FIELD_TYPE_BLOB; +import static android.provider.CloudMediaProviderContract.EXTRA_ASYNC_CONTENT_PROVIDER; +import static android.provider.CloudMediaProviderContract.METHOD_GET_ASYNC_CONTENT_PROVIDER; +import static android.provider.MediaStore.EXTRA_IS_STABLE_URIS_ENABLED; +import static android.provider.MediaStore.Files.FileColumns.MEDIA_TYPE; +import static android.provider.MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT; +import static android.provider.MediaStore.Files.FileColumns._SPECIAL_FORMAT_NONE; +import static android.provider.MediaStore.GET_BACKUP_FILES; +import static android.provider.MediaStore.GET_OWNER_PACKAGE_NAME; +import static android.provider.MediaStore.MATCH_DEFAULT; +import static android.provider.MediaStore.MATCH_EXCLUDE; +import static android.provider.MediaStore.MATCH_INCLUDE; +import static android.provider.MediaStore.MATCH_ONLY; +import static android.provider.MediaStore.MEDIA_IGNORE_FILENAME; +import static android.provider.MediaStore.MY_UID; +import static android.provider.MediaStore.MediaColumns.OWNER_PACKAGE_NAME; +import static android.provider.MediaStore.PER_USER_RANGE; +import static android.provider.MediaStore.QUERY_ARG_DEFER_SCAN; +import static android.provider.MediaStore.QUERY_ARG_MATCH_FAVORITE; +import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING; +import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED; +import static android.provider.MediaStore.QUERY_ARG_LATEST_SELECTION_ONLY; +import static android.provider.MediaStore.QUERY_ARG_REDACTED_URI; +import static android.provider.MediaStore.QUERY_ARG_RELATED_URI; +import static android.provider.MediaStore.READ_BACKUP; +import static android.provider.MediaStore.getVolumeName; +import static android.system.OsConstants.F_GETFL; + +import static com.android.providers.media.AccessChecker.getWhereForConstrainedAccess; +import static com.android.providers.media.AccessChecker.getWhereForOwnerPackageMatch; +import static com.android.providers.media.AccessChecker.getWhereForLatestSelection; +import static com.android.providers.media.AccessChecker.getWhereForUserSelectedAccess; +import static com.android.providers.media.AccessChecker.hasAccessToCollection; +import static com.android.providers.media.AccessChecker.hasUserSelectedAccess; +import static com.android.providers.media.AccessChecker.isRedactionNeededForPickerUri; +import static com.android.providers.media.DatabaseHelper.EXTERNAL_DATABASE_NAME; +import static com.android.providers.media.DatabaseHelper.INTERNAL_DATABASE_NAME; +import static com.android.providers.media.LocalCallingIdentity.APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_ACCESS_MTP; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_INSTALL_PACKAGES; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_DELEGATOR; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_GRANTED; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_READ; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_LEGACY_WRITE; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_MANAGER; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_REDACTION_NEEDED; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SELF; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SHELL; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_IS_SYSTEM_GALLERY; +import static com.android.providers.media.LocalCallingIdentity.PERMISSION_WRITE_EXTERNAL_STORAGE; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ALBUMART; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ALBUMART_FILE_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ALBUMART_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ALBUMS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ALBUMS_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ARTISTS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ARTISTS_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_ARTISTS_ID_ALBUMS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_GENRES; +import static com.android.providers.media.LocalUriMatcher.AUDIO_GENRES_ALL_MEMBERS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_GENRES_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_GENRES_ID_MEMBERS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_MEDIA; +import static com.android.providers.media.LocalUriMatcher.AUDIO_MEDIA_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_MEDIA_ID_GENRES; +import static com.android.providers.media.LocalUriMatcher.AUDIO_MEDIA_ID_GENRES_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_PLAYLISTS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_PLAYLISTS_ID; +import static com.android.providers.media.LocalUriMatcher.AUDIO_PLAYLISTS_ID_MEMBERS; +import static com.android.providers.media.LocalUriMatcher.AUDIO_PLAYLISTS_ID_MEMBERS_ID; +import static com.android.providers.media.LocalUriMatcher.CLI; +import static com.android.providers.media.LocalUriMatcher.DOWNLOADS; +import static com.android.providers.media.LocalUriMatcher.DOWNLOADS_ID; +import static com.android.providers.media.LocalUriMatcher.FILES; +import static com.android.providers.media.LocalUriMatcher.FILES_ID; +import static com.android.providers.media.LocalUriMatcher.FS_ID; +import static com.android.providers.media.LocalUriMatcher.IMAGES_MEDIA; +import static com.android.providers.media.LocalUriMatcher.IMAGES_MEDIA_ID; +import static com.android.providers.media.LocalUriMatcher.IMAGES_MEDIA_ID_THUMBNAIL; +import static com.android.providers.media.LocalUriMatcher.IMAGES_THUMBNAILS; +import static com.android.providers.media.LocalUriMatcher.IMAGES_THUMBNAILS_ID; +import static com.android.providers.media.LocalUriMatcher.MEDIA_GRANTS; +import static com.android.providers.media.LocalUriMatcher.MEDIA_SCANNER; +import static com.android.providers.media.LocalUriMatcher.PICKER_GET_CONTENT_ID; +import static com.android.providers.media.LocalUriMatcher.PICKER_ID; +import static com.android.providers.media.LocalUriMatcher.PICKER_INTERNAL_ALBUMS_ALL; +import static com.android.providers.media.LocalUriMatcher.PICKER_INTERNAL_ALBUMS_LOCAL; +import static com.android.providers.media.LocalUriMatcher.PICKER_INTERNAL_MEDIA_ALL; +import static com.android.providers.media.LocalUriMatcher.PICKER_INTERNAL_MEDIA_LOCAL; +import static com.android.providers.media.LocalUriMatcher.VERSION; +import static com.android.providers.media.LocalUriMatcher.VIDEO_MEDIA; +import static com.android.providers.media.LocalUriMatcher.VIDEO_MEDIA_ID; +import static com.android.providers.media.LocalUriMatcher.VIDEO_MEDIA_ID_THUMBNAIL; +import static com.android.providers.media.LocalUriMatcher.VIDEO_THUMBNAILS; +import static com.android.providers.media.LocalUriMatcher.VIDEO_THUMBNAILS_ID; +import static com.android.providers.media.LocalUriMatcher.VOLUMES; +import static com.android.providers.media.LocalUriMatcher.VOLUMES_ID; +import static com.android.providers.media.PickerUriResolver.PICKER_GET_CONTENT_SEGMENT; +import static com.android.providers.media.PickerUriResolver.PICKER_SEGMENT; +import static com.android.providers.media.PickerUriResolver.getMediaUri; +import static com.android.providers.media.photopicker.data.ItemsProvider.EXTRA_MIME_TYPE_SELECTION; +import static com.android.providers.media.scan.MediaScanner.REASON_DEMAND; +import static com.android.providers.media.scan.MediaScanner.REASON_IDLE; +import static com.android.providers.media.util.DatabaseUtils.bindList; +import static com.android.providers.media.util.FileUtils.DEFAULT_FOLDER_NAMES; +import static com.android.providers.media.util.FileUtils.PATTERN_PENDING_FILEPATH_FOR_SQL; +import static com.android.providers.media.util.FileUtils.buildPrimaryVolumeFile; +import static com.android.providers.media.util.FileUtils.extractDisplayName; +import static com.android.providers.media.util.FileUtils.extractFileExtension; +import static com.android.providers.media.util.FileUtils.extractFileName; +import static com.android.providers.media.util.FileUtils.extractOwnerPackageNameFromRelativePath; +import static com.android.providers.media.util.FileUtils.extractPathOwnerPackageName; +import static com.android.providers.media.util.FileUtils.extractRelativePath; +import static com.android.providers.media.util.FileUtils.extractRelativePathWithDisplayName; +import static com.android.providers.media.util.FileUtils.extractTopLevelDir; +import static com.android.providers.media.util.FileUtils.extractVolumeName; +import static com.android.providers.media.util.FileUtils.extractVolumePath; +import static com.android.providers.media.util.FileUtils.fromFuseFile; +import static com.android.providers.media.util.FileUtils.getAbsoluteSanitizedPath; +import static com.android.providers.media.util.FileUtils.isCrossUserEnabled; +import static com.android.providers.media.util.FileUtils.isDataOrObbPath; +import static com.android.providers.media.util.FileUtils.isDataOrObbRelativePath; +import static com.android.providers.media.util.FileUtils.isDownload; +import static com.android.providers.media.util.FileUtils.isExternalMediaDirectory; +import static com.android.providers.media.util.FileUtils.isObbOrChildRelativePath; +import static com.android.providers.media.util.FileUtils.sanitizePath; +import static com.android.providers.media.util.FileUtils.toFuseFile; +import static com.android.providers.media.util.Logging.LOGV; +import static com.android.providers.media.util.Logging.TAG; +import static com.android.providers.media.util.PermissionUtils.checkPermissionSelf; +import static com.android.providers.media.util.PermissionUtils.checkPermissionShell; +import static com.android.providers.media.util.PermissionUtils.checkPermissionSystem; +import static com.android.providers.media.util.StringUtils.componentStateToString; +import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_PREFIX; +import static com.android.providers.media.util.SyntheticPathUtils.REDACTED_URI_ID_SIZE; +import static com.android.providers.media.util.SyntheticPathUtils.createSparseFile; +import static com.android.providers.media.util.SyntheticPathUtils.extractSyntheticRelativePathSegements; +import static com.android.providers.media.util.SyntheticPathUtils.getRedactedRelativePath; +import static com.android.providers.media.util.SyntheticPathUtils.isPickerPath; +import static com.android.providers.media.util.SyntheticPathUtils.isRedactedPath; +import static com.android.providers.media.util.SyntheticPathUtils.isSyntheticPath; + +import android.Manifest; +import android.annotation.IntDef; +import android.app.ActivityOptions; +import android.app.AppOpsManager; +import android.app.AppOpsManager.OnOpActiveChangedListener; +import android.app.AppOpsManager.OnOpChangedListener; +import android.app.DownloadManager; +import android.app.PendingIntent; +import android.app.RecoverableSecurityException; +import android.app.RemoteAction; +import android.app.compat.CompatChanges; +import android.compat.annotation.ChangeId; +import android.compat.annotation.EnabledAfter; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ComponentName; +import android.content.ContentProvider; +import android.content.ContentProviderClient; +import android.content.ContentProviderOperation; +import android.content.ContentProviderResult; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.OperationApplicationException; +import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInstaller.SessionInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.PermissionGroupInfo; +import android.content.pm.ProviderInfo; +import android.content.res.AssetFileDescriptor; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.sqlite.SQLiteConstraintException; +import android.database.sqlite.SQLiteDatabase; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Icon; +import android.icu.util.ULocale; +import android.media.ExifInterface; +import android.media.ThumbnailUtils; +import android.mtp.MtpConstants; +import android.net.Uri; +import android.os.Binder; +import android.os.Binder.ProxyTransactListener; +import android.os.Build; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.Environment; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.ParcelFileDescriptor.OnCloseListener; +import android.os.Parcelable; +import android.os.Process; +import android.os.RemoteException; +import android.os.SystemClock; +import android.os.Trace; +import android.os.UserHandle; +import android.os.UserManager; +import android.os.storage.StorageManager; +import android.os.storage.StorageManager.StorageVolumeCallback; +import android.os.storage.StorageVolume; +import android.preference.PreferenceManager; +import android.provider.AsyncContentProvider; +import android.provider.BaseColumns; +import android.provider.Column; +import android.provider.DocumentsContract; +import android.provider.ExportedSince; +import android.provider.IAsyncContentProvider; +import android.provider.MediaStore; +import android.provider.MediaStore.Audio; +import android.provider.MediaStore.Audio.AudioColumns; +import android.provider.MediaStore.Audio.Playlists; +import android.provider.MediaStore.Downloads; +import android.provider.MediaStore.Files; +import android.provider.MediaStore.Files.FileColumns; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Images.ImageColumns; +import android.provider.MediaStore.MediaColumns; +import android.provider.MediaStore.Video; +import android.provider.Settings; +import android.system.ErrnoException; +import android.system.Os; +import android.system.OsConstants; +import android.system.StructStat; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.LongSparseArray; +import android.util.Pair; +import android.util.Size; +import android.util.SparseArray; +import android.webkit.MimeTypeMap; + +import androidx.annotation.ChecksSdkIntAtLeast; +import androidx.annotation.GuardedBy; +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; + +import com.android.modules.utils.BackgroundThread; +import com.android.modules.utils.build.SdkLevel; +import com.android.providers.media.DatabaseHelper.OnFilesChangeListener; +import com.android.providers.media.DatabaseHelper.OnLegacyMigrationListener; +import com.android.providers.media.dao.FileRow; +import com.android.providers.media.fuse.ExternalStorageServiceImpl; +import com.android.providers.media.fuse.FuseDaemon; +import com.android.providers.media.metrics.PulledMetrics; +import com.android.providers.media.photopicker.PhotoPickerActivity; +import com.android.providers.media.photopicker.PickerDataLayer; +import com.android.providers.media.photopicker.PickerSyncController; +import com.android.providers.media.photopicker.data.ExternalDbFacade; +import com.android.providers.media.photopicker.data.PickerDbFacade; +import com.android.providers.media.photopicker.data.PickerSyncRequestExtras; +import com.android.providers.media.photopicker.sync.PickerSyncLockManager; +import com.android.providers.media.photopicker.util.exceptions.UnableToAcquireLockException; +import com.android.providers.media.playlist.Playlist; +import com.android.providers.media.scan.MediaScanner; +import com.android.providers.media.scan.MediaScanner.ScanReason; +import com.android.providers.media.scan.ModernMediaScanner; +import com.android.providers.media.stableuris.dao.BackupIdRow; +import com.android.providers.media.util.CachedSupplier; +import com.android.providers.media.util.DatabaseUtils; +import com.android.providers.media.util.FileUtils; +import com.android.providers.media.util.ForegroundThread; +import com.android.providers.media.util.IsoInterface; +import com.android.providers.media.util.Logging; +import com.android.providers.media.util.LongArray; +import com.android.providers.media.util.Metrics; +import com.android.providers.media.util.MimeUtils; +import com.android.providers.media.util.PermissionUtils; +import com.android.providers.media.util.Preconditions; +import com.android.providers.media.util.SQLiteQueryBuilder; +import com.android.providers.media.util.SpecialFormatDetector; +import com.android.providers.media.util.StringUtils; +import com.android.providers.media.util.UserCache; +import com.android.providers.media.util.XAttrUtils; +import com.android.providers.media.util.XmpInterface; +import com.android.providers.media.util.FileUtils; + +import com.google.common.hash.Hashing; + +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Media content provider. See {@link android.provider.MediaStore} for details. + * Separate databases are kept for each external storage card we see (using the + * card's ID as an index). The content visible at content://media/external/... + * changes with the card. + */ +public class MediaProvider extends ContentProvider { + /** + * Enables checks to stop apps from inserting and updating to private files via media provider. + */ + @ChangeId + @EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R) + static final long ENABLE_CHECKS_FOR_PRIVATE_FILES = 172100307L; + + /** + * Regex of a selection string that matches a specific ID. + */ + static final Pattern PATTERN_SELECTION_ID = Pattern.compile( + "(?:image_id|video_id)\\s*=\\s*(\\d+)"); + + /** File access by uid requires the transcoding transform */ + private static final int FLAG_TRANSFORM_TRANSCODING = 1 << 0; + + /** File access by uid is a synthetic path corresponding to a redacted URI */ + private static final int FLAG_TRANSFORM_REDACTION = 1 << 1; + + /** File access by uid is a synthetic path corresponding to a picker URI */ + private static final int FLAG_TRANSFORM_PICKER = 1 << 2; + + /** + * These directory names aren't declared in Environment as final variables, and so we need to + * have the same values in separate final variables in order to have them considered constant + * expressions. + * These directory names are intentionally in lower case to ease the case insensitive path + * comparison. + */ + private static final String DIRECTORY_MUSIC_LOWER_CASE = "music"; + private static final String DIRECTORY_PODCASTS_LOWER_CASE = "podcasts"; + private static final String DIRECTORY_RINGTONES_LOWER_CASE = "ringtones"; + private static final String DIRECTORY_ALARMS_LOWER_CASE = "alarms"; + private static final String DIRECTORY_NOTIFICATIONS_LOWER_CASE = "notifications"; + private static final String DIRECTORY_PICTURES_LOWER_CASE = "pictures"; + private static final String DIRECTORY_MOVIES_LOWER_CASE = "movies"; + private static final String DIRECTORY_DOWNLOADS_LOWER_CASE = "download"; + private static final String DIRECTORY_DCIM_LOWER_CASE = "dcim"; + private static final String DIRECTORY_DOCUMENTS_LOWER_CASE = "documents"; + private static final String DIRECTORY_AUDIOBOOKS_LOWER_CASE = "audiobooks"; + private static final String DIRECTORY_RECORDINGS_LOWER_CASE = "recordings"; + private static final String DIRECTORY_ANDROID_LOWER_CASE = "android"; + + private static final String DIRECTORY_MEDIA = "media"; + private static final String DIRECTORY_THUMBNAILS = ".thumbnails"; + + /** + * Hard-coded filename where the current value of + * {@link DatabaseHelper#getOrCreateUuid} is persisted on a physical SD card + * to help identify stale thumbnail collections. + */ + private static final String FILE_DATABASE_UUID = ".database_uuid"; + + /** + * Specify what default directories the caller gets full access to. By default, the caller + * shouldn't get full access to any default dirs. + * But for example, we do an exception for System Gallery apps and allow them full access to: + * DCIM, Pictures, Movies. + */ + static final String INCLUDED_DEFAULT_DIRECTORIES = + "android:included-default-directories"; + + /** + * Value indicating that operations should include database rows matching the criteria defined + * by this key only when calling package has write permission to the database row or column is + * {@column MediaColumns#IS_PENDING} and is set by FUSE. + *

+ * Note that items not matching the criteria will also be included, and as part of this + * match no additional write permission checks are carried out for those items. + */ + private static final int MATCH_VISIBLE_FOR_FILEPATH = 32; + + private static final int NON_HIDDEN_CACHE_SIZE = 50; + + /** + * This is required as idle maintenance maybe stopped anytime; we do not want to query + * and accumulate values to update for a long time, instead we want to batch query and update + * by a limited number. + */ + private static final int IDLE_MAINTENANCE_ROWS_LIMIT = 1000; + + /** + * Where clause to match pending files from FUSE. Pending files from FUSE will not have + * PATTERN_PENDING_FILEPATH_FOR_SQL pattern. + */ + private static final String MATCH_PENDING_FROM_FUSE = String.format("lower(%s) NOT REGEXP '%s'", + MediaColumns.DATA, PATTERN_PENDING_FILEPATH_FOR_SQL); + + /** + * This flag is replaced with {@link MediaStore#QUERY_ARG_DEFER_SCAN} from S onwards and only + * kept around for app compatibility in R. + */ + private static final String QUERY_ARG_DO_ASYNC_SCAN = "android:query-arg-do-async-scan"; + + /** + * Time between two polling attempts for availability of FuseDaemon thread. + */ + private static final long POLLING_TIME_IN_MILLIS = 100; + + /** + * Enable option to defer the scan triggered as part of MediaProvider#update() + */ + @ChangeId + @EnabledAfter(targetSdkVersion = android.os.Build.VERSION_CODES.R) + static final long ENABLE_DEFERRED_SCAN = 180326732L; + + /** + * Enable option to include database rows of files from recently unmounted + * volume in MediaProvider#query + */ + @ChangeId + @EnabledAfter(targetSdkVersion = Build.VERSION_CODES.R) + static final long ENABLE_INCLUDE_ALL_VOLUMES = 182734110L; + + /** + * Set of {@link Cursor} columns that refer to raw filesystem paths. + */ + private static final ArrayMap sDataColumns = new ArrayMap<>(); + + static { + sDataColumns.put(MediaStore.MediaColumns.DATA, null); + sDataColumns.put(MediaStore.Images.Thumbnails.DATA, null); + sDataColumns.put(MediaStore.Video.Thumbnails.DATA, null); + sDataColumns.put(MediaStore.Audio.PlaylistsColumns.DATA, null); + sDataColumns.put(MediaStore.Audio.AlbumColumns.ALBUM_ART, null); + } + + private static final int sUserId = UserHandle.myUserId(); + + /** + * Please use {@link getDownloadsProviderAuthority()} instead of using this directly. + */ + private static final String DOWNLOADS_PROVIDER_AUTHORITY = "downloads"; + + private static final String DEFAULT_FOLDER_CREATED_KEY_PREFIX = "created_default_folders_"; + + /** + * This value should match android.os.Trace.MAX_SECTION_NAME_LEN , not accessible from this + * class + */ + private static final int MAX_SECTION_NAME_LEN = 127; + + /** + * This string is a copy of + * {@link com.android.settingslib.drawer.TileUtils.META_DATA_PREFERENCE_SUMMARY} + */ + private static final String META_DATA_PREFERENCE_SUMMARY = "com.android.settings.summary"; + + @GuardedBy("mPendingOpenInfo") + private final Map mPendingOpenInfo = new ArrayMap<>(); + + @GuardedBy("mNonHiddenPaths") + private final LRUCache mNonHiddenPaths = new LRUCache<>(NON_HIDDEN_CACHE_SIZE); + + public void updateVolumes() { + mVolumeCache.update(); + // Update filters to reflect mounted volumes so users don't get + // confused by metadata from ejected volumes + ForegroundThread.getExecutor().execute(() -> { + mExternalDatabase.setFilterVolumeNames(mVolumeCache.getExternalVolumeNames()); + }); + } + + @NonNull + public MediaVolume getVolume(@NonNull String volumeName) throws FileNotFoundException { + return mVolumeCache.findVolume(volumeName, mCallingIdentity.get().getUser()); + } + + @NonNull + public File getVolumePath(@NonNull String volumeName) throws FileNotFoundException { + // Ugly hack to keep unit tests passing, where we don't always have a + // Context to discover volumes with + if (getContext() == null) { + return Environment.getExternalStorageDirectory(); + } + + return mVolumeCache.getVolumePath(volumeName, mCallingIdentity.get().getUser()); + } + + @NonNull + private Collection getAllowedVolumePaths(String volumeName) + throws FileNotFoundException { + // This method is used to verify whether a path belongs to a certain volume name; + // we can't always use the calling user's identity here to determine exactly which + // volume is meant, because the MediaScanner may scan paths belonging to another user, + // eg a clone user. + // So, for volumes like external_primary, just return allowed paths for all users. + List users = mUserCache.getUsersCached(); + ArrayList allowedPaths = new ArrayList<>(); + for (UserHandle user : users) { + try { + Collection volumeScanPaths = mVolumeCache.getVolumeScanPaths(volumeName, + user); + allowedPaths.addAll(volumeScanPaths); + } catch (FileNotFoundException e) { + Log.e(TAG, volumeName + " has no associated path for user: " + user); + } + } + + return allowedPaths; + } + + /** + * Frees any cache held by MediaProvider. + * + * @param bytes number of bytes which need to be freed + */ + public void freeCache(long bytes) { + mTranscodeHelper.freeCache(bytes); + } + + public void onAnrDelayStarted(@NonNull String packageName, int uid, int tid, int reason) { + mTranscodeHelper.onAnrDelayStarted(packageName, uid, tid, reason); + } + + private volatile Locale mLastLocale = Locale.getDefault(); + + private StorageManager mStorageManager; + private PackageManager mPackageManager; + private UserManager mUserManager; + private PickerUriResolver mPickerUriResolver; + + private UserCache mUserCache; + private VolumeCache mVolumeCache; + + private int mExternalStorageAuthorityAppId; + private int mDownloadsAuthorityAppId; + private Size mThumbSize; + + /** + * Map from UID to cached {@link LocalCallingIdentity}. Values are only + * maintained in this map while the UID is actively working with a + * performance-critical component, such as camera. + */ + @GuardedBy("mCachedCallingIdentity") + private final SparseArray mCachedCallingIdentity = new SparseArray<>(); + + private final OnOpActiveChangedListener mActiveListener = (code, uid, packageName, active) -> { + synchronized (mCachedCallingIdentity) { + if (active) { + // TODO moltmann: Set correct featureId + mCachedCallingIdentity.put(uid, + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid, + packageName, null)); + } else { + mCachedCallingIdentity.remove(uid); + } + } + }; + + /** + * Map from UID to cached {@link LocalCallingIdentity}. Values are only + * maintained in this map until there's any change in the appops needed or packages + * used in the {@link LocalCallingIdentity}. + */ + @GuardedBy("mCachedCallingIdentityForFuse") + private final SparseArray mCachedCallingIdentityForFuse = + new SparseArray<>(); + + private final OnOpChangedListener mModeListener = new OnOpChangedListener() { + + /** + * Callback method called as part of {@link OnOpChangedListener}. + * Calls {@link #onOpChanged(String, String, int)} with cached userId(s). + * + * @param packageName - package for which AppOp changed + * @param op - AppOp for which the mode changed. + */ + public void onOpChanged(String op, String packageName) { + // In case no userId is supplied, we drop grants for all cached users. + List userHandles = mUserCache.getUsersCached(); + for (UserHandle user : userHandles) { + onOpChanged(op, packageName, user.getIdentifier()); + } + } + + /** + * Callback method called as part of {@link OnOpChangedListener}. + * When an AppOp is written - + * 1. We invalidate saved LocalCallingIdentity object for the package. This + * is needed to ensure we read the new permission state + * 2. If the AppOp change was on the read media appOps, we clear any stale + * grants, + * + * @param packageName - package for which AppOp changed + * @param op - AppOp for which the mode changed. + * @param userId - userSpace where the package is located + */ + public void onOpChanged(String op, String packageName, int userId) { + invalidateLocalCallingIdentityCache(packageName, "op " + op /* reason */); + removeMediaGrantsOnModeChange(packageName, op, userId); + } + }; + + /** + * Removes media_grants for the given {@code packageName} and {@code userId} if the AppOp + * change resulted in a state of "Allow All" or "Deny All" for read + * permission. + */ + private void removeMediaGrantsOnModeChange(String packageName, String op, int userId) { + // b/265963379: onModeChanged is always called with op=OPSTR_READ_EXTERNAL_STORAGE even if + // the appOp mode changed for other read media app ops. Handle all read media app op changes + // until the bug is fixed. + if (!SdkLevel.isAtLeastU() || !isReadMediaAppOp(op)) { + return; + } + Context context = getContext(); + PackageManager packageManager = context.getPackageManager(); + try { + int uid = + packageManager.getPackageUidAsUser( + packageName, PackageManager.PackageInfoFlags.of(0), userId); + LocalCallingIdentity lci = LocalCallingIdentity.fromExternal(context, mUserCache, uid); + if (!lci.checkCallingPermissionUserSelected()) { + String[] packages = lci.getSharedPackageNamesArray(); + mMediaGrants.removeAllMediaGrantsForPackages( + packages, /* reason= */ "Mode changed: " + op, userId); + } + } catch (NameNotFoundException e) { + Log.d( + TAG, + "Unable to resolve uid. Ignoring the AppOp change for " + + packageName + + ", User : " + + userId); + } + } + + /** + * Returns {@code true} if the given {@code op} is one of the appOp + * related to read media appOps + */ + private boolean isReadMediaAppOp(String op) { + return AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE.equals(op) + || AppOpsManager.OPSTR_READ_MEDIA_IMAGES.equals(op) + || AppOpsManager.OPSTR_READ_MEDIA_VIDEO.equals(op) + || AppOpsManager.OPSTR_READ_MEDIA_VISUAL_USER_SELECTED.equals(op); + } + + /** + * Retrieves a cached calling identity or creates a new one. Also, always sets the app-op + * description for the calling identity. + */ + private LocalCallingIdentity getCachedCallingIdentityForFuse(int uid) { + synchronized (mCachedCallingIdentityForFuse) { + PermissionUtils.setOpDescription("via FUSE"); + LocalCallingIdentity identity = mCachedCallingIdentityForFuse.get(uid); + if (identity == null) { + identity = LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid); + if (uidToUserId(uid) == sUserId) { + mCachedCallingIdentityForFuse.put(uid, identity); + } else { + // In some app cloning designs, MediaProvider user 0 may + // serve requests for apps running as a "clone" user; in + // those cases, don't keep a cache for the clone user, since + // we don't get any invalidation events for these users. + } + } + return identity; + } + } + + /** + * Calling identity state about on the current thread. Populated on demand, + * and invalidated by {@link #onCallingPackageChanged()} when each remote + * call is finished. + */ + private final ThreadLocal mCallingIdentity = ThreadLocal + .withInitial(() -> { + PermissionUtils.setOpDescription("via MediaProvider"); + synchronized (mCachedCallingIdentity) { + final LocalCallingIdentity cached = mCachedCallingIdentity + .get(Binder.getCallingUid()); + return (cached != null) ? cached + : LocalCallingIdentity.fromBinder(getContext(), this, mUserCache); + } + }); + + /** + * We simply propagate the UID that is being tracked by + * {@link LocalCallingIdentity}, which means we accurately blame both + * incoming Binder calls and FUSE calls. + */ + private final ProxyTransactListener mTransactListener = new ProxyTransactListener() { + @Override + public Object onTransactStarted(IBinder binder, int transactionCode) { + if (LOGV) Trace.beginSection(Thread.currentThread().getStackTrace()[5].getMethodName()); + // Check if mCallindIdentity was created within a fuse or content provider transaction + if (mCallingIdentity.get().isValidProviderOrFuseCallingIdentity()) { + return Binder.setCallingWorkSourceUid(mCallingIdentity.get().uid); + } + // If mCallingIdentity was not created for a fuse or content provider transaction, + // we should reset it, the next time it is retrieved it will be created for the + // appropriate caller. + mCallingIdentity.remove(); + return Binder.setCallingWorkSourceUid(Binder.getCallingUid()); + } + + @Override + public void onTransactEnded(Object session) { + final long token = (long) session; + Binder.restoreCallingWorkSource(token); + if (LOGV) Trace.endSection(); + } + }; + + // In memory cache of path<->id mappings, to speed up inserts during media scan + @GuardedBy("mDirectoryCache") + private final ArrayMap mDirectoryCache = new ArrayMap<>(); + + private static final String[] sDataOnlyColumn = new String[] { + FileColumns.DATA + }; + + private static final String ID_NOT_PARENT_CLAUSE = + "_id NOT IN (SELECT parent FROM files WHERE parent IS NOT NULL)"; + + private static final String CANONICAL = "canonical"; + + private static final String ALL_VOLUMES = "all_volumes"; + + private final BroadcastReceiver mPackageReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case Intent.ACTION_PACKAGE_REMOVED: + case Intent.ACTION_PACKAGE_ADDED: + Uri uri = intent.getData(); + String pkg = uri != null ? uri.getSchemeSpecificPart() : null; + if (pkg != null) { + invalidateLocalCallingIdentityCache(pkg, "package " + intent.getAction()); + if (Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) { + mUserCache.invalidateWorkProfileOwnerApps(pkg); + mPickerSyncController.notifyPackageRemoval(pkg); + invalidateDentryForExternalStorage(pkg); + } + } else { + Log.w(TAG, "Failed to retrieve package from intent: " + intent.getAction()); + } + break; + } + } + }; + + private void invalidateDentryForExternalStorage(String packageName) { + for (MediaVolume vol : mVolumeCache.getExternalVolumes()) { + try { + invalidateFuseDentry(String.format(Locale.ROOT, + "%s/Android/media/%s/", getVolumePath(vol.getName()).getAbsolutePath(), + packageName)); + } catch (FileNotFoundException e) { + Log.e(TAG, "External volume path not found for " + vol.getName(), e); + } + } + } + + private final BroadcastReceiver mUserIntentReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + switch (intent.getAction()) { + case Intent.ACTION_USER_REMOVED: + /** + * Removing media files for user being deleted. This would impact if the deleted + * user have been using same MediaProvider as the current user i.e. when + * isMediaSharedWithParent is true.On removal of such user profile, + * the owner's MediaProvider would need to clean any media files stored + * by the removed user profile. + * We also remove the default folder key for the cloned user (just removed) + * from user 0's SharedPreferences. Usually, the next clone user would be + * created with a different key (as user-id would be incremented), however, if + * device is restarted, the next clone-user can use the user-id previously + * assigned, causing stale entries in user 0's SharedPreferences + */ + UserHandle userToBeRemoved = intent.getParcelableExtra(Intent.EXTRA_USER); + if(userToBeRemoved.getIdentifier() != sUserId){ + mExternalDatabase.runWithTransaction((db) -> { + db.execSQL("delete from files where _user_id=?", + new String[]{String.valueOf(userToBeRemoved.getIdentifier())}); + return null ; + }); + String userToBeRemovedVolId = null; + synchronized (mAttachedVolumes) { + for (MediaVolume volume : mAttachedVolumes) { + if (userToBeRemoved.equals(volume.getUser())) { + userToBeRemovedVolId = volume.getId(); + break; + } + } + } + //The clone user volume may be unmounted at this time (userToBeRemovedVolId + // will be null then), we construct the volId of unmounted vol from userId. + String key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + + getPrimaryVolumeId(userToBeRemovedVolId, userToBeRemoved); + final SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(getContext()); + if (prefs.getInt(key, /* default */ 0) == 1) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(key); + editor.commit(); + } + } + + boolean isDeviceInDemoMode = false; + try { + isDeviceInDemoMode = Settings.Global.getInt( + getContext().getContentResolver(), Settings.Global.DEVICE_DEMO_MODE) + > 0; + } catch (Settings.SettingNotFoundException e) { + Log.w(TAG, "Exception in reading DEVICE_DEMO_MODE setting", e); + } + + Log.i(TAG, "isDeviceInDemoMode: " + isDeviceInDemoMode); + // Only allow default system user 0 to update xattrs on /data/media/0 and + // only on retail demo devices + if (sUserId == UserHandle.SYSTEM.getIdentifier() && isDeviceInDemoMode) { + mDatabaseBackupAndRecovery.removeRecoveryDataForUserId( + userToBeRemoved.getIdentifier()); + } + break; + } + } + }; + + private void invalidateLocalCallingIdentityCache(String packageName, String reason) { + synchronized (mCachedCallingIdentityForFuse) { + try { + int packageUid = getContext().getPackageManager().getPackageUid(packageName, 0); + if (mCachedCallingIdentityForFuse.contains(packageUid)) { + mCachedCallingIdentityForFuse.get(packageUid).dump(reason); + mCachedCallingIdentityForFuse.remove(packageUid); + } + } catch (NameNotFoundException ignored) { + } + } + } + + protected void updateQuotaTypeForUri(@NonNull FileRow row) { + final String volumeName = row.getVolumeName(); + final String path = row.getPath(); + + // Quota type is only updated for external primary volume + if (!MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volumeName)) { + return; + } + + int mediaType = row.getMediaType(); + Trace.beginSection("MP.updateQuotaTypeForUri"); + File file; + try { + if (path != null) { + file = new File(path); + } else { + // This can happen in case of renames, where the path isn't + // part of the 'new' FileRow data. Fall back to querying + // the path directly. + final Uri uri = MediaStore.Files.getContentUri(row.getVolumeName(), + row.getId()); + if (uri == null) { + // Row could have been deleted + return; + } + file = queryForDataFile(uri, null); + } + if (!file.exists()) { + // This can happen if an item is inserted in MediaStore before it is created + return; + } + + if (mediaType == FileColumns.MEDIA_TYPE_NONE) { + // This might be because the file is hidden; but we still want to + // attribute its quota to the correct type, so get the type from + // the extension instead. + mediaType = MimeUtils.resolveMediaType(MimeUtils.resolveMimeType(file)); + } + + updateQuotaTypeForFileInternal(file, mediaType); + } catch (FileNotFoundException | IllegalArgumentException e) { + // Ignore + Log.w(TAG, "Failed to update quota", e); + } finally { + Trace.endSection(); + } + } + + private void updateQuotaTypeForFileInternal(File file, int mediaType) { + try { + switch (mediaType) { + case FileColumns.MEDIA_TYPE_AUDIO: + mStorageManager.updateExternalStorageFileQuotaType(file, + StorageManager.QUOTA_TYPE_MEDIA_AUDIO); + break; + case FileColumns.MEDIA_TYPE_VIDEO: + mStorageManager.updateExternalStorageFileQuotaType(file, + StorageManager.QUOTA_TYPE_MEDIA_VIDEO); + break; + case FileColumns.MEDIA_TYPE_IMAGE: + mStorageManager.updateExternalStorageFileQuotaType(file, + StorageManager.QUOTA_TYPE_MEDIA_IMAGE); + break; + default: + mStorageManager.updateExternalStorageFileQuotaType(file, + StorageManager.QUOTA_TYPE_MEDIA_NONE); + break; + } + } catch (IOException e) { + Log.w(TAG, "Failed to update quota type for " + file.getPath(), e); + } + } + + /** + * Since these operations are in the critical path of apps working with + * media, we only collect the {@link Uri} that need to be notified, and all + * other side-effect operations are delegated to {@link BackgroundThread} so + * that we return as quickly as possible. + */ + private final OnFilesChangeListener mFilesListener = new OnFilesChangeListener() { + @Override + public void onInsert(@NonNull DatabaseHelper helper, @NonNull FileRow insertedRow) { + if (helper.isDatabaseRecovering()) { + // Do not perform any trigger operation if database is recovering + return; + } + + handleInsertedRowForFuse(insertedRow.getId()); + acceptWithExpansion(helper::notifyInsert, insertedRow.getVolumeName(), + insertedRow.getId(), insertedRow.getMediaType(), insertedRow.isDownload()); + + mDatabaseBackupAndRecovery.updateNextRowIdXattr(helper, insertedRow.getId()); + + helper.postBackground(() -> { + if (helper.isExternal() && !isFuseThread()) { + // Update the quota type on the filesystem + Uri fileUri = MediaStore.Files.getContentUri(insertedRow.getVolumeName(), + insertedRow.getId()); + updateQuotaTypeForUri(insertedRow); + } + + // Tell our SAF provider so it knows when views are no longer empty + MediaDocumentsProvider.onMediaStoreInsert(getContext(), insertedRow.getVolumeName(), + insertedRow.getMediaType(), insertedRow.getId()); + + if (mExternalDbFacade.onFileInserted(insertedRow.getMediaType(), + insertedRow.isPending())) { + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); + } + + mDatabaseBackupAndRecovery.backupVolumeDbData(helper, insertedRow); + }); + } + + @Override + public void onUpdate(@NonNull DatabaseHelper helper, @NonNull FileRow oldRow, + @NonNull FileRow newRow) { + if (helper.isDatabaseRecovering()) { + // Do not perform any trigger operation if database is recovering + return; + } + + final boolean isDownload = oldRow.isDownload() || newRow.isDownload(); + final Uri fileUri = MediaStore.Files.getContentUri(oldRow.getVolumeName(), + oldRow.getId()); + handleUpdatedRowForFuse(oldRow.getPath(), oldRow.getOwnerPackageName(), oldRow.getId(), + newRow.getId()); + handleOwnerPackageNameChange(oldRow.getPath(), oldRow.getOwnerPackageName(), + newRow.getOwnerPackageName()); + acceptWithExpansion(helper::notifyUpdate, oldRow.getVolumeName(), oldRow.getId(), + oldRow.getMediaType(), isDownload); + + mDatabaseBackupAndRecovery.updateNextRowIdAndSetDirty(helper, oldRow, newRow); + + helper.postBackground(() -> { + if (helper.isExternal()) { + // Update the quota type on the filesystem + updateQuotaTypeForUri(newRow); + } + + if (mExternalDbFacade.onFileUpdated(oldRow.getId(), + oldRow.getMediaType(), newRow.getMediaType(), + oldRow.isTrashed(), newRow.isTrashed(), + oldRow.isPending(), newRow.isPending(), + oldRow.isFavorite(), newRow.isFavorite(), + oldRow.getSpecialFormat(), newRow.getSpecialFormat())) { + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); + } + + mDatabaseBackupAndRecovery.updateBackup(helper, oldRow, newRow); + }); + + if (newRow.getMediaType() != oldRow.getMediaType()) { + acceptWithExpansion(helper::notifyUpdate, oldRow.getVolumeName(), oldRow.getId(), + newRow.getMediaType(), isDownload); + + helper.postBackground(() -> { + // Invalidate any thumbnails when the media type changes + invalidateThumbnails(fileUri); + }); + } + } + + @Override + public void onDelete(@NonNull DatabaseHelper helper, @NonNull FileRow deletedRow) { + if (helper.isDatabaseRecovering()) { + // Do not perform any trigger operation if database is recovering + return; + } + + handleDeletedRowForFuse(deletedRow.getPath(), deletedRow.getOwnerPackageName(), + deletedRow.getId()); + acceptWithExpansion(helper::notifyDelete, deletedRow.getVolumeName(), + deletedRow.getId(), deletedRow.getMediaType(), deletedRow.isDownload()); + // Remove cached transcoded file if any + mTranscodeHelper.deleteCachedTranscodeFile(deletedRow.getId()); + + helper.postBackground(() -> { + // Item no longer exists, so revoke all access to it + Trace.beginSection("MP.revokeUriPermission"); + try { + acceptWithExpansion((uri) -> getContext().revokeUriPermission(uri, ~0), + deletedRow.getVolumeName(), deletedRow.getId(), + deletedRow.getMediaType(), deletedRow.isDownload()); + } finally { + Trace.endSection(); + } + + switch (deletedRow.getMediaType()) { + case FileColumns.MEDIA_TYPE_PLAYLIST: + case FileColumns.MEDIA_TYPE_AUDIO: + if (helper.isExternal()) { + removePlaylistMembers(deletedRow.getMediaType(), deletedRow.getId()); + } + } + + // Invalidate any thumbnails now that media is gone + invalidateThumbnails(MediaStore.Files.getContentUri(deletedRow.getVolumeName(), + deletedRow.getId())); + + // Tell our SAF provider so it can revoke too + MediaDocumentsProvider.onMediaStoreDelete(getContext(), deletedRow.getVolumeName(), + deletedRow.getMediaType(), deletedRow.getId()); + + if (mExternalDbFacade.onFileDeleted(deletedRow.getId(), + deletedRow.getMediaType())) { + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ true); + } + + mDatabaseBackupAndRecovery.deleteFromDbBackup(helper, deletedRow); + }); + } + }; + + private final UnaryOperator mIdGenerator = path -> { + final long rowId = mCallingIdentity.get().getDeletedRowId(path); + if (rowId != -1 && isFuseThread()) { + return String.valueOf(rowId); + } + return null; + }; + + /** {@hide} */ + public static final OnLegacyMigrationListener MIGRATION_LISTENER = + new OnLegacyMigrationListener() { + @Override + public void onStarted(ContentProviderClient client, String volumeName) { + MediaStore.startLegacyMigration(ContentResolver.wrap(client), volumeName); + } + + @Override + public void onProgress(ContentProviderClient client, String volumeName, + long progress, long total) { + // TODO: notify blocked threads of progress once we can change APIs + } + + @Override + public void onFinished(ContentProviderClient client, String volumeName) { + MediaStore.finishLegacyMigration(ContentResolver.wrap(client), volumeName); + } + }; + + /** + * Apply {@link Consumer#accept} to the given item. + *

+ * Since media items can be exposed through multiple collections or views, + * this method expands the single item being accepted to also accept all + * relevant views. + */ + private void acceptWithExpansion(@NonNull Consumer consumer, @NonNull String volumeName, + long id, int mediaType, boolean isDownload) { + switch (mediaType) { + case FileColumns.MEDIA_TYPE_AUDIO: + consumer.accept(MediaStore.Audio.Media.getContentUri(volumeName, id)); + + // Any changing audio items mean we probably need to invalidate all + // indexed views built from that media + consumer.accept(Audio.Genres.getContentUri(volumeName)); + consumer.accept(Audio.Playlists.getContentUri(volumeName)); + consumer.accept(Audio.Artists.getContentUri(volumeName)); + consumer.accept(Audio.Albums.getContentUri(volumeName)); + break; + + case FileColumns.MEDIA_TYPE_VIDEO: + consumer.accept(MediaStore.Video.Media.getContentUri(volumeName, id)); + break; + + case FileColumns.MEDIA_TYPE_IMAGE: + consumer.accept(MediaStore.Images.Media.getContentUri(volumeName, id)); + break; + + case FileColumns.MEDIA_TYPE_PLAYLIST: + consumer.accept(ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(volumeName), id)); + break; + } + + // Also notify through any generic views + consumer.accept(MediaStore.Files.getContentUri(volumeName, id)); + if (isDownload) { + consumer.accept(MediaStore.Downloads.getContentUri(volumeName, id)); + } + + // Rinse and repeat through any synthetic views + switch (volumeName) { + case MediaStore.VOLUME_INTERNAL: + case MediaStore.VOLUME_EXTERNAL: + // Already a top-level view, no need to expand + break; + default: + acceptWithExpansion(consumer, MediaStore.VOLUME_EXTERNAL, + id, mediaType, isDownload); + break; + } + } + + /** + * Ensure that default folders are created on mounted storage devices. + * We only do this once per volume so we don't annoy the user if deleted + * manually. + */ + private void ensureDefaultFolders(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) { + if (volume.shouldSkipDefaultDirCreation()) { + // Default folders should not be automatically created inside volumes managed from + // outside Android. + return; + } + final String volumeName = volume.getName(); + String key; + if (volumeName.equals(MediaStore.VOLUME_EXTERNAL_PRIMARY)) { + // For the primary volume, we use the ID, because we may be handling + // the primary volume for multiple users + key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + + getPrimaryVolumeId(volume.getId(), volume.getUser()); + } else { + // For others, like public volumes, just use the name, because the id + // might not change when re-formatted + key = DEFAULT_FOLDER_CREATED_KEY_PREFIX + volumeName; + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + if (prefs.getInt(key, 0) == 0) { + for (String folderName : DEFAULT_FOLDER_NAMES) { + final File folder = new File(volume.getPath(), folderName); + if (!folder.exists()) { + folder.mkdirs(); + insertDirectory(db, folder.getAbsolutePath()); + } + } + + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(key, 1); + editor.commit(); + } + } + + /** + * Returns the volume id for Primary External Volumes. + * If volId is supplied, it is returned as-is, in case it is not, user-id is used to + * construct the id for Primary External Volume. + * + * @param volId the id of the Volume in consideration. + * @param userId userId for which primary volume id needs to be determined. + * @return the primary volume id. + */ + private String getPrimaryVolumeId(String volId, UserHandle userId) { + if (volId == null) { + // The construction is based upon system/vold/model/EmulatedVolume.cpp + // Should be kept in sync with the same. + return "emulated;" + userId.getIdentifier(); + } + return volId; + } + + /** + * Ensure that any thumbnail collections on the given storage volume can be + * used with the given {@link DatabaseHelper}. If the + * {@link DatabaseHelper#getOrCreateUuid} doesn't match the UUID found on + * disk, then all thumbnails will be considered stable and will be deleted. + */ + private void ensureThumbnailsValid(@NonNull MediaVolume volume, @NonNull SQLiteDatabase db) { + if (volume.shouldSkipDefaultDirCreation()) { + // Default folders and thumbnail directories should not be automatically created inside + // volumes managed from outside Android, and there is no need to ensure the validity of + // their thumbnails here. + return; + } + final String uuidFromDatabase = DatabaseHelper.getOrCreateUuid(db); + try { + for (File dir : getThumbnailDirectories(volume)) { + if (!dir.exists()) { + dir.mkdirs(); + } + + final File file = new File(dir, FILE_DATABASE_UUID); + final Optional uuidFromDisk = FileUtils.readString(file); + + final boolean updateUuid; + if (!uuidFromDisk.isPresent()) { + // For newly inserted volumes or upgrading of existing volumes, + // assume that our current UUID is valid + updateUuid = true; + } else if (!Objects.equals(uuidFromDatabase, uuidFromDisk.get())) { + // The UUID of database disagrees with the one on disk, + // which means we can't trust any thumbnails + Log.d(TAG, "Invalidating all thumbnails under " + dir); + FileUtils.walkFileTreeContents(dir.toPath(), this::deleteAndInvalidate); + updateUuid = true; + } else { + updateUuid = false; + } + + if (updateUuid) { + FileUtils.writeString(file, Optional.of(uuidFromDatabase)); + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to ensure thumbnails valid for " + volume.getName(), e); + } + } + + @Override + public void attachInfo(Context context, ProviderInfo info) { + Log.v(TAG, "Attached " + info.authority + " from " + info.applicationInfo.packageName); + + mUriMatcher = new LocalUriMatcher(info.authority); + + super.attachInfo(context, info); + } + + @Nullable + private static MediaProvider sInstance; + + @Nullable + static synchronized MediaProvider getInstance() { + return sInstance; + } + + @Override + public boolean onCreate() { + synchronized (MediaProvider.class) { + sInstance = this; + } + + final Context context = getContext(); + + mUserCache = new UserCache(context); + + // Shift call statistics back to the original caller + Binder.setProxyTransactListener(mTransactListener); + + mStorageManager = context.getSystemService(StorageManager.class); + AppOpsManager appOpsManager = context.getSystemService(AppOpsManager.class); + mPackageManager = context.getPackageManager(); + mUserManager = context.getSystemService(UserManager.class); + mVolumeCache = new VolumeCache(context, mUserCache); + + // Reasonable thumbnail size is half of the smallest screen edge width + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final int thumbSize = Math.min(metrics.widthPixels, metrics.heightPixels) / 2; + mThumbSize = new Size(thumbSize, thumbSize); + + mConfigStore = createConfigStore(); + mDatabaseBackupAndRecovery = createDatabaseBackupAndRecovery(); + + mMediaScanner = new ModernMediaScanner(context); + mProjectionHelper = new ProjectionHelper(Column.class, ExportedSince.class); + mInternalDatabase = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, false, false, + mProjectionHelper, Metrics::logSchemaChange, mFilesListener, + MIGRATION_LISTENER, mIdGenerator, true, mDatabaseBackupAndRecovery); + mExternalDatabase = new DatabaseHelper(context, EXTERNAL_DATABASE_NAME, false, false, + mProjectionHelper, Metrics::logSchemaChange, mFilesListener, + MIGRATION_LISTENER, mIdGenerator, true, mDatabaseBackupAndRecovery); + mExternalDbFacade = new ExternalDbFacade(getContext(), mExternalDatabase, mVolumeCache); + + mMediaGrants = new MediaGrants(mExternalDatabase); + + PickerSyncLockManager pickerSyncLockManager = new PickerSyncLockManager(); + mPickerDbFacade = new PickerDbFacade(context, pickerSyncLockManager); + mPickerSyncController = PickerSyncController.initialize(context, mPickerDbFacade, + mConfigStore, pickerSyncLockManager); + mPickerDataLayer = PickerDataLayer.create(context, mPickerDbFacade, mPickerSyncController, + mConfigStore); + mPickerUriResolver = new PickerUriResolver(context, mPickerDbFacade, mProjectionHelper, + mUriMatcher); + + if (SdkLevel.isAtLeastS()) { + mTranscodeHelper = new TranscodeHelperImpl(context, this, mConfigStore); + } else { + mTranscodeHelper = new TranscodeHelperNoOp(); + } + + final IntentFilter packageFilter = new IntentFilter(); + packageFilter.setPriority(10); + packageFilter.addDataScheme("package"); + packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); + packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); + context.registerReceiver(mPackageReceiver, packageFilter); + + // Creating intent broadcast receiver for user actions like Intent.ACTION_USER_REMOVED, + // where we would need to remove files stored by removed user. + final IntentFilter userIntentFilter = new IntentFilter(); + userIntentFilter.addAction(Intent.ACTION_USER_REMOVED); + context.registerReceiver(mUserIntentReceiver, userIntentFilter); + + // Watch for invalidation of cached volumes + mStorageManager.registerStorageVolumeCallback(context.getMainExecutor(), + new StorageVolumeCallback() { + @Override + public void onStateChanged(@NonNull StorageVolume volume) { + updateVolumes(); + } + }); + + if (SdkLevel.isAtLeastT()) { + try { + mStorageManager.setCloudMediaProvider(mPickerSyncController.getCloudProvider()); + } catch (SecurityException e) { + // This can happen in unit tests + Log.w(TAG, "Failed to update the system_server with the latest cloud provider", e); + } + } + + updateVolumes(); + attachVolume(MediaVolume.fromInternal(), /* validate */ false, /* volumeState */ null); + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { + attachVolume(volume, /* validate */ false, /* volumeState */ null); + } + + // Watch for performance-sensitive activity + appOpsManager.startWatchingActive(new String[] { + AppOpsManager.OPSTR_CAMERA + }, context.getMainExecutor(), mActiveListener); + + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_EXTERNAL_STORAGE, + null /* all packages */, mModeListener); + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_AUDIO, + null /* all packages */, mModeListener); + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_IMAGES, + null /* all packages */, mModeListener); + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_VIDEO, + null /* all packages */, mModeListener); + if (SdkLevel.isAtLeastU()) { + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_READ_MEDIA_VISUAL_USER_SELECTED, + null /* all packages */, mModeListener); + } + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_EXTERNAL_STORAGE, + null /* all packages */, mModeListener); + appOpsManager.startWatchingMode(permissionToOp(ACCESS_MEDIA_LOCATION), + null /* all packages */, mModeListener); + // Legacy apps + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_LEGACY_STORAGE, + null /* all packages */, mModeListener); + // File managers + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_MANAGE_EXTERNAL_STORAGE, + null /* all packages */, mModeListener); + // Default gallery changes + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_IMAGES, + null /* all packages */, mModeListener); + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_WRITE_MEDIA_VIDEO, + null /* all packages */, mModeListener); + try { + // Here we are forced to depend on the non-public API of AppOpsManager. If + // OPSTR_NO_ISOLATED_STORAGE app op is not defined in AppOpsManager, then this call will + // throw an IllegalArgumentException during MediaProvider startup. In combination with + // MediaProvider's CTS tests it should give us guarantees that OPSTR_NO_ISOLATED_STORAGE + // is defined. + appOpsManager.startWatchingMode(AppOpsManager.OPSTR_NO_ISOLATED_STORAGE, + null /* all packages */, mModeListener); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed to start watching " + AppOpsManager.OPSTR_NO_ISOLATED_STORAGE, e); + } + + ProviderInfo provider = mPackageManager.resolveContentProvider( + getDownloadsProviderAuthority(), PackageManager.MATCH_DIRECT_BOOT_AWARE + | PackageManager.MATCH_DIRECT_BOOT_UNAWARE); + if (provider != null) { + mDownloadsAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid); + } + + provider = mPackageManager.resolveContentProvider(getExternalStorageProviderAuthority(), + PackageManager.MATCH_DIRECT_BOOT_AWARE | PackageManager.MATCH_DIRECT_BOOT_UNAWARE); + if (provider != null) { + mExternalStorageAuthorityAppId = UserHandle.getAppId(provider.applicationInfo.uid); + } + + storageNativeBootPropertyChangeListener(); + mConfigStore.addOnChangeListener( + BackgroundThread.getExecutor(), this::storageNativeBootPropertyChangeListener); + + PulledMetrics.initialize(context); + return true; + } + + @VisibleForTesting + protected void storageNativeBootPropertyChangeListener() { + boolean isGetContentTakeoverEnabled; + if (SdkLevel.isAtLeastT()) { + isGetContentTakeoverEnabled = true; + } else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.R) { + isGetContentTakeoverEnabled = true; + } else { + isGetContentTakeoverEnabled = mConfigStore.isGetContentTakeOverEnabled(); + } + setComponentEnabledSetting("PhotoPickerGetContentActivity", isGetContentTakeoverEnabled); + + setComponentEnabledSetting("PhotoPickerUserSelectActivity", + mConfigStore.isUserSelectForAppEnabled()); + } + + public DatabaseBackupAndRecovery getDatabaseBackupAndRecovery() { + return mDatabaseBackupAndRecovery; + } + + private void setComponentEnabledSetting(@NonNull String activityName, boolean isEnabled) { + final String activityFullName = + PhotoPickerActivity.class.getPackage().getName() + "." + activityName; + final ComponentName componentName = new ComponentName(getContext().getPackageName(), + activityFullName); + + final int expectedState = isEnabled + ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED + : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; + + Log.i(TAG, "Changed " + activityName + " component state to " + + componentStateToString(expectedState)); + + getContext().getPackageManager().setComponentEnabledSetting(componentName, expectedState, + PackageManager.DONT_KILL_APP); + } + + Optional getDatabaseHelper(String dbName) { + if (dbName.equalsIgnoreCase(INTERNAL_DATABASE_NAME)) { + return Optional.of(mInternalDatabase); + } else if (dbName.equalsIgnoreCase(EXTERNAL_DATABASE_NAME)) { + return Optional.of(mExternalDatabase); + } + + return Optional.empty(); + } + + @Override + public void onCallingPackageChanged() { + // Identity of the current thread has changed, so invalidate caches + mCallingIdentity.remove(); + } + + public LocalCallingIdentity clearLocalCallingIdentity() { + // We retain the user part of the calling identity, since we are executing + // the call on behalf of that user, and we need to maintain the user context + // to correctly resolve things like volumes + UserHandle user = mCallingIdentity.get().getUser(); + return clearLocalCallingIdentity(LocalCallingIdentity.fromSelfAsUser(getContext(), user)); + } + + public LocalCallingIdentity clearLocalCallingIdentity(LocalCallingIdentity replacement) { + final LocalCallingIdentity token = mCallingIdentity.get(); + mCallingIdentity.set(replacement); + return token; + } + + public void restoreLocalCallingIdentity(LocalCallingIdentity token) { + mCallingIdentity.set(token); + } + + private boolean isPackageKnown(@NonNull String packageName, int userId) { + final Context context = mUserCache.getContextForUser(UserHandle.of(userId)); + final PackageManager pm = context.getPackageManager(); + + // First, is the app actually installed? + try { + pm.getPackageInfo(packageName, PackageManager.MATCH_UNINSTALLED_PACKAGES); + return true; + } catch (NameNotFoundException ignored) { + } + + // Second, is the app pending, probably from a backup/restore operation? + // Cloned app installations do not have a linked install session, so skipping the check in + // case the user-id is a clone profile. + if (!isAppCloneUserForFuse(userId)) { + if (sUserId != userId) { + // Skip the package check and ensure media provider doesn't crash + // Returning true since we are unsure what caused the cross-user entries to be in + // the database and want to avoid deleting data that might be required. + Log.e(TAG, "Skip pruning cross-user entries stored in database for package: " + + packageName + " userId: " + userId + " processUserId: " + sUserId); + return true; + } + for (SessionInfo si : pm.getPackageInstaller().getAllSessions()) { + if (Objects.equals(packageName, si.getAppPackageName())) { + return true; + } + } + } else { + Log.e(TAG, "Cross-user entries found in database for package " + packageName + + " userId: " + userId + " processUserId: " + sUserId); + } + + // I've never met this package in my life + return false; + } + + public void onIdleMaintenance(@NonNull CancellationSignal signal) { + final long startTime = SystemClock.elapsedRealtime(); + + // Print # of deleted files + synchronized (mCachedCallingIdentityForFuse) { + for (int i = 0; i < mCachedCallingIdentityForFuse.size(); i++) { + mCachedCallingIdentityForFuse.valueAt(i).dump("Idle maintenance"); + } + } + + // Trim any stale log files before we emit new events below + Logging.trimPersistent(); + + // Scan all volumes to resolve any staleness + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { + // Possibly bail before digging into each volume + signal.throwIfCanceled(); + + try { + MediaService.onScanVolume(getContext(), volume, REASON_IDLE); + } catch (IOException | IllegalArgumentException e) { + Log.w(TAG, "Failure in " + volume.getName() + " volume scan", e); + } + + // Ensure that our thumbnails are valid + mExternalDatabase.runWithTransaction((db) -> { + ensureThumbnailsValid(volume, db); + return null; + }); + } + + // Delete any stale thumbnails + final int staleThumbnails = mExternalDatabase.runWithTransaction((db) -> { + return pruneThumbnails(db, signal); + }); + Log.d(TAG, "Pruned " + staleThumbnails + " unknown thumbnails"); + + // Finished orphaning any content whose package no longer exists + pruneStalePackages(signal); + + // Delete the expired items or extend them on mounted volumes + final int[] result = deleteOrExtendExpiredItems(signal); + final int deletedExpiredMedia = result[0]; + Log.d(TAG, "Deleted " + deletedExpiredMedia + " expired items"); + Log.d(TAG, "Extended " + result[1] + " expired items"); + + // Forget any stale volumes + deleteStaleVolumes(signal); + + final long itemCount = mExternalDatabase.runWithTransaction(DatabaseHelper::getItemCount); + + // Cleaning media files for users that have been removed + cleanMediaFilesForRemovedUser(signal); + + // Calculate standard_mime_type_extension column for files which have SPECIAL_FORMAT column + // value as NULL, and update the same in the picker db + detectSpecialFormat(signal); + + final long durationMillis = (SystemClock.elapsedRealtime() - startTime); + Metrics.logIdleMaintenance(MediaStore.VOLUME_EXTERNAL, itemCount, + durationMillis, staleThumbnails, deletedExpiredMedia); + } + + /** + * This function find and clean the files related to user who have been removed + */ + private void cleanMediaFilesForRemovedUser(CancellationSignal signal) { + //Finding userIds that are available in database + final List userIds = mExternalDatabase.runWithTransaction((db) -> { + final List userIdsPresent = new ArrayList<>(); + try (Cursor c = db.query(true, "files", new String[] { "_user_id" }, + null, null, null, null, null, + null, signal)) { + while (c.moveToNext()) { + final String userId = c.getString(0); + userIdsPresent.add(userId); + } + } + return userIdsPresent; + }); + + // removing calling userId + userIds.remove(String.valueOf(sUserId)); + + List validUserProfiles = mUserManager.getEnabledProfiles().stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); + // removing all the valid/existing user, remaining userIds would be users who would have + // been removed + userIds.removeAll(validUserProfiles); + + // Cleaning media files of users who have been removed + mExternalDatabase.runWithTransaction((db) -> { + userIds.stream().forEach(userId ->{ + Log.d(TAG, "Removing media files associated with user : " + userId); + db.execSQL("delete from files where _user_id=?", + new String[]{String.valueOf(userId)}); + }); + return null ; + }); + + boolean isDeviceInDemoMode = false; + try { + isDeviceInDemoMode = Settings.Global.getInt(getContext().getContentResolver(), + Settings.Global.DEVICE_DEMO_MODE) > 0; + } catch (Settings.SettingNotFoundException e) { + Log.w(TAG, "Exception in reading DEVICE_DEMO_MODE setting", e); + } + + Log.i(TAG, "isDeviceInDemoMode: " + isDeviceInDemoMode); + // Only allow default system user 0 to update xattrs on /data/media/0 and only when + // device is in retail mode + if (sUserId == UserHandle.SYSTEM.getIdentifier() && isDeviceInDemoMode) { + List validUsers = mUserManager.getUserHandles(/* excludeDying */ true).stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); + Log.i(TAG, "Active user ids are:" + validUsers); + mDatabaseBackupAndRecovery.removeRecoveryDataExceptValidUsers(validUsers); + } + } + + private void pruneStalePackages(CancellationSignal signal) { + final int stalePackages = mExternalDatabase.runWithTransaction((db) -> { + final ArraySet> unknownPackages = new ArraySet<>(); + try (Cursor c = db.query(true, "files", + new String[] { "owner_package_name", "_user_id" }, + null, null, null, null, null, null, signal)) { + while (c.moveToNext()) { + final String packageName = c.getString(0); + if (TextUtils.isEmpty(packageName)) continue; + + final int userId = c.getInt(1); + + if (!isPackageKnown(packageName, userId)) { + unknownPackages.add(Pair.create(packageName, userId)); + } + } + } + for (Pair pair : unknownPackages) { + onPackageOrphaned(db, pair.first, pair.second); + } + return unknownPackages.size(); + }); + Log.d(TAG, "Pruned " + stalePackages + " unknown packages"); + } + + private void deleteStaleVolumes(CancellationSignal signal) { + mExternalDatabase.runWithTransaction((db) -> { + final Set recentVolumeNames = MediaStore + .getRecentExternalVolumeNames(getContext()); + final Set knownVolumeNames = new ArraySet<>(); + try (Cursor c = db.query(true, "files", new String[] { MediaColumns.VOLUME_NAME }, + null, null, null, null, null, null, signal)) { + while (c.moveToNext()) { + knownVolumeNames.add(c.getString(0)); + } + } + final Set staleVolumeNames = new ArraySet<>(); + staleVolumeNames.addAll(knownVolumeNames); + staleVolumeNames.removeAll(recentVolumeNames); + for (String staleVolumeName : staleVolumeNames) { + final int num = db.delete("files", FileColumns.VOLUME_NAME + "=?", + new String[] { staleVolumeName }); + Log.d(TAG, "Forgot " + num + " stale items from " + staleVolumeName); + } + return null; + }); + + synchronized (mDirectoryCache) { + mDirectoryCache.clear(); + } + } + + @VisibleForTesting + public void setUriResolver(PickerUriResolver resolver) { + Log.w(TAG, "Changing the PickerUriResolver!!! Should only be called during test"); + mPickerUriResolver = resolver; + } + + @VisibleForTesting + void detectSpecialFormat(@NonNull CancellationSignal signal) { + // Picker sync and special format update can execute concurrently and run into a deadlock. + // Acquiring a lock before execution of each flow to avoid this. + PickerSyncController.sIdleMaintenanceSyncLock.lock(); + try { + mExternalDatabase.runWithTransaction((db) -> { + updateSpecialFormatColumn(db, signal); + return null; + }); + } finally { + PickerSyncController.sIdleMaintenanceSyncLock.unlock(); + } + } + + private void updateSpecialFormatColumn(SQLiteDatabase db, @NonNull CancellationSignal signal) { + // This is to ensure we only do a bounded iteration over the rows as updates can fail, and + // we don't want to keep running the query/update indefinitely. + final int totalRowsToUpdate = getPendingSpecialFormatRowsCount(db, signal); + for (int i = 0; i < totalRowsToUpdate; i += IDLE_MAINTENANCE_ROWS_LIMIT) { + try (PickerDbFacade.UpdateMediaOperation operation = + mPickerDbFacade.beginUpdateMediaOperation( + PickerSyncController.LOCAL_PICKER_PROVIDER_AUTHORITY)) { + updateSpecialFormatForLimitedRows(db, signal, operation); + operation.setSuccess(); + } + } + } + + private int getPendingSpecialFormatRowsCount(SQLiteDatabase db, + @NonNull CancellationSignal signal) { + try (Cursor c = queryForPendingSpecialFormatColumns(db, /* limit */ null, signal)) { + if (c == null) { + return 0; + } + return c.getCount(); + } + } + + private void updateSpecialFormatForLimitedRows(SQLiteDatabase externalDb, + @NonNull CancellationSignal signal, PickerDbFacade.UpdateMediaOperation operation) { + // Accumulate all the new SPECIAL_FORMAT updates with their ids + ArrayMap newSpecialFormatValues = new ArrayMap<>(); + final String limit = String.valueOf(IDLE_MAINTENANCE_ROWS_LIMIT); + try (Cursor c = queryForPendingSpecialFormatColumns(externalDb, limit, signal)) { + while (c.moveToNext() && !signal.isCanceled()) { + final long id = c.getLong(0); + final String path = c.getString(1); + newSpecialFormatValues.put(id, getSpecialFormatValue(path)); + } + } + + // Now, update all the new SPECIAL_FORMAT values in both external db and picker db. + final ContentValues pickerDbValues = new ContentValues(); + final ContentValues externalDbValues = new ContentValues(); + int count = 0; + for (long id : newSpecialFormatValues.keySet()) { + if (signal.isCanceled()) { + return; + } + + int specialFormat = newSpecialFormatValues.get(id); + + pickerDbValues.clear(); + pickerDbValues.put(PickerDbFacade.KEY_STANDARD_MIME_TYPE_EXTENSION, specialFormat); + boolean pickerDbWriteSuccess = operation.execute(String.valueOf(id), pickerDbValues); + + externalDbValues.clear(); + externalDbValues.put(_SPECIAL_FORMAT, specialFormat); + final String externalDbSelection = MediaColumns._ID + "=?"; + final String[] externalDbSelectionArgs = new String[]{String.valueOf(id)}; + boolean externalDbWriteSuccess = + externalDb.update("files", externalDbValues, externalDbSelection, + externalDbSelectionArgs) + == 1; + + if (pickerDbWriteSuccess && externalDbWriteSuccess) { + count++; + } + } + Log.d(TAG, "Updated standard_mime_type_extension for " + count + " items"); + } + + private int getSpecialFormatValue(String path) { + final File file = new File(path); + if (!file.exists()) { + // We always update special format to none if the file is not found or there is an + // error, this is so that we do not repeat over the same column again and again. + return _SPECIAL_FORMAT_NONE; + } + + try { + return SpecialFormatDetector.detect(file); + } catch (Exception e) { + // we tried our best, no need to run special detection again and again if it + // throws exception once, it is likely to do so everytime. + Log.d(TAG, "Failed to detect special format for file: " + file, e); + return _SPECIAL_FORMAT_NONE; + } + } + + private Cursor queryForPendingSpecialFormatColumns(SQLiteDatabase db, String limit, + @NonNull CancellationSignal signal) { + // Run special detection for images only + final String selection = _SPECIAL_FORMAT + " IS NULL AND " + + MEDIA_TYPE + "=" + MEDIA_TYPE_IMAGE; + final String[] projection = new String[] { MediaColumns._ID, MediaColumns.DATA }; + return db.query(/* distinct */ true, "files", projection, selection, null, null, null, + null, limit, signal); + } + + /** + * Delete any expired content on mounted volumes. The expired content on unmounted + * volumes will be deleted when we forget any stale volumes; we're cautious about + * wildly changing clocks, so only delete items within the last week. + * If the items are expired more than one week, extend the expired time of them + * another one week to avoid data loss with incorrect time zone data. We will + * delete it when it is expired next time. + * + * @param signal the cancellation signal + * @return the integer array includes total deleted count and total extended count + */ + @NonNull + private int[] deleteOrExtendExpiredItems(@NonNull CancellationSignal signal) { + final long expiredOneWeek = + ((System.currentTimeMillis() - DateUtils.WEEK_IN_MILLIS) / 1000); + final long now = (System.currentTimeMillis() / 1000); + final long expiredTime = now + (FileUtils.DEFAULT_DURATION_EXTENDED / 1000); + return mExternalDatabase.runWithTransaction((db) -> { + String selection = FileColumns.DATE_EXPIRES + " < " + now; + selection += " AND volume_name in " + bindList(MediaStore.getExternalVolumeNames( + getContext()).toArray()); + String[] projection = new String[]{"volume_name", "_id", + FileColumns.DATE_EXPIRES, FileColumns.DATA}; + try (Cursor c = db.query(true, "files", projection, selection, null, null, null, null, + null, signal)) { + int totalDeleteCount = 0; + int totalExtendedCount = 0; + int index = 0; + while (c.moveToNext()) { + final String volumeName = c.getString(0); + final long id = c.getLong(1); + final long dateExpires = c.getLong(2); + // we only delete the items that expire in one week + if (dateExpires > expiredOneWeek) { + totalDeleteCount += delete(Files.getContentUri(volumeName, id), null, null); + } else { + final String oriPath = c.getString(3); + + final boolean success = extendExpiredItem(db, oriPath, id, expiredTime, + expiredTime + index); + if (success) { + totalExtendedCount++; + } + index++; + } + } + return new int[]{totalDeleteCount, totalExtendedCount}; + } + }); + } + + /** + * Extend the expired items by renaming the file to new path with new timestamp and updating the + * database for {@link FileColumns#DATA} and {@link FileColumns#DATE_EXPIRES}. If there is + * UNIQUE constraint error for FileColumns.DATA, use adjustedExpiredTime and generate the new + * path by adjustedExpiredTime. + */ + private boolean extendExpiredItem(@NonNull SQLiteDatabase db, @NonNull String originalPath, + long id, long newExpiredTime, long adjustedExpiredTime) { + String newPath = FileUtils.getAbsoluteExtendedPath(originalPath, newExpiredTime); + if (newPath == null) { + Log.e(TAG, "Couldn't compute path for " + originalPath + " and expired time " + + newExpiredTime); + return false; + } + + try { + if (updateDatabaseForExpiredItem(db, newPath, id, newExpiredTime)) { + return renameInLowerFsAndInvalidateFuseDentry(originalPath, newPath); + } + return false; + } catch (SQLiteConstraintException e) { + final String errorMessage = + "Update database _data from " + originalPath + " to " + newPath + " failed."; + Log.d(TAG, errorMessage, e); + } + + // When we update the database for newPath with newExpiredTime, if the new path already + // exists in the database, it may raise SQLiteConstraintException. + // If there are two expired items that have the same display name in the same directory, + // but they have different expired time. E.g. .trashed-123-A.jpg and .trashed-456-A.jpg. + // After we rename .trashed-123-A.jpg to .trashed-newExpiredTime-A.jpg, then we rename + // .trashed-456-A.jpg to .trashed-newExpiredTime-A.jpg, it raises the exception. For + // this case, we will retry it with the adjustedExpiredTime again. + newPath = FileUtils.getAbsoluteExtendedPath(originalPath, adjustedExpiredTime); + Log.i(TAG, "Retrying to extend expired item with the new path = " + newPath); + try { + if (updateDatabaseForExpiredItem(db, newPath, id, adjustedExpiredTime)) { + return renameInLowerFsAndInvalidateFuseDentry(originalPath, newPath); + } + } catch (SQLiteConstraintException e) { + // If we want to rename one expired item E.g. .trashed-123-A.jpg., and there is another + // non-expired trashed/pending item has the same name. E.g. + // .trashed-adjustedExpiredTime-A.jpg. When we rename .trashed-123-A.jpg to + // .trashed-adjustedExpiredTime-A.jpg, it raises the SQLiteConstraintException. + // The smallest unit of the expired time we use is second. It is a very rare case. + // When this case is happened, we can handle it in next idle maintenance. + final String errorMessage = + "Update database _data from " + originalPath + " to " + newPath + " failed."; + Log.d(TAG, errorMessage, e); + } + + return false; + } + + private boolean updateDatabaseForExpiredItem(@NonNull SQLiteDatabase db, + @NonNull String path, long id, long expiredTime) { + final String table = "files"; + final String whereClause = MediaColumns._ID + "=?"; + final String[] whereArgs = new String[]{String.valueOf(id)}; + final ContentValues values = new ContentValues(); + values.put(FileColumns.DATA, path); + values.put(FileColumns.DATE_EXPIRES, expiredTime); + final int count = db.update(table, values, whereClause, whereArgs); + return count == 1; + } + + private boolean renameInLowerFsAndInvalidateFuseDentry(@NonNull String originalPath, + @NonNull String newPath) { + try { + Os.rename(originalPath, newPath); + invalidateFuseDentry(originalPath); + invalidateFuseDentry(newPath); + return true; + } catch (ErrnoException e) { + final String errorMessage = "Rename " + originalPath + " to " + newPath + + " in lower file system for extending item failed."; + Log.e(TAG, errorMessage, e); + } + return false; + } + + public void onIdleMaintenanceStopped() { + mMediaScanner.onIdleScanStopped(); + } + + /** + * Orphan any content of the given package. This will delete Android/media orphaned files from + * the database. + */ + public void onPackageOrphaned(String packageName, int uid) { + mExternalDatabase.runWithTransaction((db) -> { + final int userId = uid / PER_USER_RANGE; + onPackageOrphaned(db, packageName, userId); + + if (SdkLevel.isAtLeastU()) { + removeAllMediaGrantsForUid(uid, userId, packageName); + } + return null; + }); + } + + /** + * Orphan any content of the given package from the given database. This will delete + * Android/media files from the database if the underlying file no longer exists. + */ + public void onPackageOrphaned(@NonNull SQLiteDatabase db, + @NonNull String packageName, int userId) { + // Delete Android/media entries. + deleteAndroidMediaEntries(db, packageName, userId); + // Orphan rest of entries. + orphanEntries(db, packageName, userId); + mDatabaseBackupAndRecovery.removeOwnerIdToPackageRelation(packageName, userId); + + } + + /** + * Removes all media_grants for all packages with the given UID. (i.e. shared packages.) + * + * @param uid the package uid. (will use this to query all shared packages that use this uid) + * @param userId the user id, since packages can be installed by multiple users. + * @param additionalPackageName An optional additional package name in the event that the + * package has been removed at won't be returned by the PackageManager APIs. + */ + private void removeAllMediaGrantsForUid( + int uid, int userId, @Nullable String additionalPackageName) { + + String[] packages; + try { + LocalCallingIdentity lci = + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid); + packages = lci.getSharedPackageNamesArray(); + } catch (IllegalArgumentException notFound) { + // If there are no packages found, this means the specified UID has no packages + // remaining on the system. + packages = new String[]{}; + } + if (additionalPackageName != null) { + // Include the passed additional package in the list LocalCallingIdentity returns. + List packageList = new ArrayList<>(); + packageList.addAll(Arrays.asList(packages)); + packageList.add(additionalPackageName); + packages = packageList.toArray(new String[packageList.size()]); + } + + // TODO(b/260685885): Add e2e tests to ensure these are cleared when a package + // is removed. + mMediaGrants.removeAllMediaGrantsForPackages( + packages, /* reason */ "Package orphaned", userId); + } + + private void deleteAndroidMediaEntries(SQLiteDatabase db, String packageName, int userId) { + String relativePath = "Android/media/" + DatabaseUtils.escapeForLike(packageName) + "/%"; + try (Cursor cursor = db.query( + "files", + new String[] { MediaColumns._ID, MediaColumns.DATA }, + "relative_path LIKE ? ESCAPE '\\' AND owner_package_name=? AND _user_id=?", + new String[] { relativePath, packageName, "" + userId }, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */null, + /* limit= */ null)) { + int countDeleted = 0; + if (cursor != null) { + while (cursor.moveToNext()) { + File file = new File(cursor.getString(1)); + // We check for existence to be sure we don't delete files that still exist. + // This can happen even if the pair (package, userid) is unknown, + // since some framework implementations may rely on special userids. + if (!file.exists()) { + countDeleted += + db.delete("files", "_id=?", new String[]{cursor.getString(0)}); + } + } + } + Log.d(TAG, "Deleted " + countDeleted + " Android/media items belonging to " + + packageName + " on " + db.getPath()); + } + } + + private void orphanEntries( + @NonNull SQLiteDatabase db, @NonNull String packageName, int userId) { + final ContentValues values = new ContentValues(); + values.putNull(FileColumns.OWNER_PACKAGE_NAME); + + final int countOrphaned = db.update("files", values, + "owner_package_name=? AND _user_id=?", new String[] { packageName, "" + userId }); + if (countOrphaned > 0) { + Log.d(TAG, "Orphaned " + countOrphaned + " items belonging to " + + packageName + " on " + db.getPath()); + } + } + + public void scanDirectory(@NonNull File dir, @ScanReason int reason) { + mMediaScanner.scanDirectory(dir, reason); + } + + public Uri scanFile(@NonNull File file, @ScanReason int reason) { + try { + ContentValues values = new ContentValues(); + values.put(MediaColumns.DATA, file.getAbsolutePath()); + String volumeName = FileUtils.getVolumeName(getContext(), file); + Uri uri = MediaStore.Files.getContentUri(volumeName); + assertFileColumnsConsistent(0, uri, values); + } catch (Exception e) { + Log.e(TAG, "invalid path " + e.fillInStackTrace()); + return null; + } + return mMediaScanner.scanFile(file, reason); + } + + private Uri scanFileAsMediaProvider(File file, int reason) { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try { + return scanFile(file, REASON_DEMAND); + } finally { + restoreLocalCallingIdentity(tokenInner); + } + } + + /** + * Called when a new file is created through FUSE + * + * @param path path of the file that was created + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public void onFileCreatedForFuse(String path) { + // Make sure we update the quota type of the file + BackgroundThread.getExecutor().execute(() -> { + File file = new File(path); + int mediaType = MimeUtils.resolveMediaType(MimeUtils.resolveMimeType(file)); + updateQuotaTypeForFileInternal(file, mediaType); + }); + } + + private boolean isAppCloneUserPair(int userId1, int userId2) { + UserHandle user1 = UserHandle.of(userId1); + UserHandle user2 = UserHandle.of(userId2); + if (SdkLevel.isAtLeastS()) { + if (mUserCache.userSharesMediaWithParent(user1) + || mUserCache.userSharesMediaWithParent(user2)) { + return true; + } + if (Build.VERSION.DEVICE_INITIAL_SDK_INT >= Build.VERSION_CODES.S) { + // If we're on S or higher, and we shipped with S or higher, only allow the new + // app cloning functionality + return false; + } + // else, fall back to deprecated solution below on updating devices + } + try { + Method isAppCloneUserPair = StorageManager.class.getMethod("isAppCloneUserPair", + int.class, int.class); + return (Boolean) isAppCloneUserPair.invoke(mStorageManager, userId1, userId2); + } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + Log.w(TAG, "isAppCloneUserPair failed. Users: " + userId1 + " and " + userId2); + return false; + } + } + + /** + * Determines whether the passed in userId forms an app clone user pair with user 0. + * + * @param userId user ID to check + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean isAppCloneUserForFuse(int userId) { + if (!isCrossUserEnabled()) { + Log.d(TAG, "CrossUser not enabled."); + return false; + } + boolean result = isAppCloneUserPair(0, userId); + + Log.w(TAG, "isAppCloneUserPair for user " + userId + ": " + result); + + return result; + } + + /** + * Determines if to allow FUSE_LOOKUP for uid. Might allow uids that don't belong to the + * MediaProvider user, depending on OEM configuration. + * + * @param uid linux uid to check + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean shouldAllowLookupForFuse(int uid, int pathUserId) { + int callingUserId = uidToUserId(uid); + if (!isCrossUserEnabled()) { + Log.d(TAG, "CrossUser not enabled. Users: " + callingUserId + " and " + pathUserId); + return false; + } + + if (callingUserId != pathUserId && callingUserId != 0 && pathUserId != 0) { + Log.w(TAG, "CrossUser at least one user is 0 check failed. Users: " + callingUserId + + " and " + pathUserId); + return false; + } + + if (mUserCache.isWorkProfile(callingUserId) || mUserCache.isWorkProfile(pathUserId)) { + // Cross-user lookup not allowed if one user in the pair has a profile owner app + Log.w(TAG, "CrossUser work profile check failed. Users: " + callingUserId + " and " + + pathUserId); + return false; + } + + boolean result = isAppCloneUserPair(pathUserId, callingUserId); + if (result) { + Log.i(TAG, "CrossUser allowed. Users: " + callingUserId + " and " + pathUserId); + } else { + Log.w(TAG, "CrossUser isAppCloneUserPair check failed. Users: " + callingUserId + + " and " + pathUserId); + } + + return result; + } + + /** + * Called from FUSE to transform a file + * + * A transform can change the file contents for {@code uid} from {@code src} to {@code dst} + * depending on {@code flags}. This allows the FUSE daemon serve different file contents for + * the same file to different apps. + * + * The only supported transform for now is transcoding which re-encodes a file taken in a modern + * format like HEVC to a legacy format like AVC. + * + * @param src file path to transform + * @param dst file path to save transformed file + * @param flags determines the kind of transform + * @param readUid app that called us requesting transform + * @param openUid app that originally made the open call + * @param mediaCapabilitiesUid app for which the transform decision was made, + * 0 if decision was made with openUid + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public boolean transformForFuse(String src, String dst, int transforms, int transformsReason, + int readUid, int openUid, int mediaCapabilitiesUid) { + if ((transforms & FLAG_TRANSFORM_TRANSCODING) != 0) { + if (mTranscodeHelper.isTranscodeFileCached(src, dst)) { + Log.d(TAG, "Using transcode cache for " + src); + return true; + } + + // In general we always mark the opener as causing transcoding. + // However, if the mediaCapabilitiesUid is available then we mark the reader as causing + // transcoding. This handles the case where a malicious app might want to take + // advantage of mediaCapabilitiesUid by setting it to another app's uid and reading the + // media contents itself; in such cases we'd mark the reader (malicious app) for the + // cost of transcoding. + // + // openUid readUid mediaCapabilitiesUid + // ------------------------------------------------------------------------------------- + // using picker SAF app app + // abusive case bad app bad app victim + // modern to lega- + // -cy sharing modern legacy legacy + // + // we'd not be here in the below case. + // legacy to mode- + // -rn sharing legacy modern modern + + int transcodeUid = openUid; + if (mediaCapabilitiesUid > 0) { + Log.d(TAG, "Fix up transcodeUid to " + readUid + ". openUid " + openUid + + ", mediaCapabilitiesUid " + mediaCapabilitiesUid); + transcodeUid = readUid; + } + return mTranscodeHelper.transcode(src, dst, transcodeUid, transformsReason); + } + return true; + } + + /** + * Called from FUSE to get {@link FileLookupResult} for a {@code path} and {@code uid} + * + * {@link FileLookupResult} contains transforms, transforms completion status and ioPath + * for transform lookup query for a file and uid. + * + * @param path file path to get transforms for + * @param uid app requesting IO form kernel + * @param tid FUSE thread id handling IO request from kernel + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public FileLookupResult onFileLookupForFuse(String path, int uid, int tid) { + uid = getBinderUidForFuse(uid, tid); + // Use MediaProviders UserId as the caller might be calling cross profile. + final int userId = UserHandle.myUserId(); + + if (isSyntheticPath(path, userId)) { + if (isRedactedPath(path, userId)) { + return handleRedactedFileLookup(uid, path); + } else if (isPickerPath(path, userId)) { + return handlePickerFileLookup(userId, uid, path); + } + + throw new IllegalStateException("Unexpected synthetic path: " + path); + } + + if (mTranscodeHelper.supportsTranscode(path)) { + return handleTranscodedFileLookup(path, uid, tid); + } + + return new FileLookupResult(/* transforms */ 0, uid, /* ioPath */ ""); + } + + private FileLookupResult handleTranscodedFileLookup(String path, int uid, int tid) { + final int transformsReason; + final PendingOpenInfo info; + + synchronized (mPendingOpenInfo) { + info = mPendingOpenInfo.get(tid); + } + + if (info != null && info.uid == uid) { + transformsReason = info.transcodeReason; + } else { + transformsReason = mTranscodeHelper.shouldTranscode(path, uid, null /* bundle */); + } + + if (transformsReason > 0) { + final String ioPath = mTranscodeHelper.prepareIoPath(path, uid); + final boolean transformsComplete = mTranscodeHelper.isTranscodeFileCached(path, ioPath); + + return new FileLookupResult(FLAG_TRANSFORM_TRANSCODING, transformsReason, uid, + transformsComplete, /* transformsSupported */ true, ioPath); + } + + return new FileLookupResult(/* transforms */ 0, transformsReason, uid, + /* transformsComplete */ true, /* transformsSupported */ true, ""); + } + + private FileLookupResult handleRedactedFileLookup(int uid, @NonNull String path) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final String fileName = extractFileName(path); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(FileUtils.getContentUriForPath(path)); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Volume not found for file: " + path); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", new String[]{MediaColumns.DATA}, + FileColumns.REDACTED_URI_ID + "=?", new String[]{fileName}, null, null, + null))) { + if (c.moveToFirst()) { + return new FileLookupResult(FLAG_TRANSFORM_REDACTION, uid, c.getString(0)); + } + + throw new IllegalStateException("Failed to fetch synthetic redacted path: " + path); + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** TODO(b/242153950) :Add negative tests for permission check of file lookup of synthetic + * paths. */ + private FileLookupResult handlePickerFileLookup(int userId, int uid, @NonNull String path) { + final File file = new File(path); + final List syntheticRelativePathSegments = + extractSyntheticRelativePathSegements(path, userId); + final int segmentCount = syntheticRelativePathSegments.size(); + + if (segmentCount < 1 || segmentCount > 5) { + throw new IllegalStateException("Unexpected synthetic picker path: " + file); + } + + final String lastSegment = syntheticRelativePathSegments.get(segmentCount - 1); + + boolean result = false; + switch (segmentCount) { + case 1: + // .../picker or .../picker_get_content + if (lastSegment.equals(PICKER_SEGMENT) || lastSegment.equals( + PICKER_GET_CONTENT_SEGMENT)) { + result = file.exists() || file.mkdir(); + } + break; + case 2: + // .../picker/ or .../picker_get_content/ + try { + Integer.parseInt(lastSegment); + result = file.exists() || file.mkdir(); + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid user id for picker file lookup: " + lastSegment + + ". File: " + file); + } + break; + case 3: + // .../picker// or .../picker_get_content// + result = preparePickerAuthorityPathSegment(file, lastSegment, uid); + break; + case 4: + // .../picker///media or + // .../picker_get_content///media + if (lastSegment.equals("media")) { + result = file.exists() || file.mkdir(); + } + break; + case 5: + // .../picker///media/ or + // .../picker_get_content///media/ + final String pickerSegmentType = syntheticRelativePathSegments.get(0); + final String fileUserId = syntheticRelativePathSegments.get(1); + final String authority = syntheticRelativePathSegments.get(2); + result = preparePickerMediaIdPathSegment(file, pickerSegmentType, authority, + lastSegment, fileUserId, uid); + break; + } + + if (result) { + return new FileLookupResult(FLAG_TRANSFORM_PICKER, uid, path); + } + throw new IllegalStateException("Failed to prepare synthetic picker path: " + file); + } + + private FileOpenResult handlePickerFileOpen(String path, int uid) { + final String[] segments = path.split("/"); + if (segments.length != 11) { + Log.e(TAG, "Picker file open failed. Unexpected segments: " + path); + return new FileOpenResult(OsConstants.ENOENT /* status */, uid, /* transformsUid */ 0, + new long[0]); + } + + // ['', 'storage', 'emulated', '0', 'transforms', 'synthetic', + // 'picker' or 'picker_get_content', '', '', 'media', ''] + final String pickerSegmentType = segments[6]; + final String userId = segments[7]; + final String fileName = segments[10]; + final String host = segments[8]; + final String authority = userId + "@" + host; + final int lastDotIndex = fileName.lastIndexOf('.'); + + if (lastDotIndex == -1) { + Log.e(TAG, "Picker file open failed. No file extension: " + path); + return FileOpenResult.createError(OsConstants.ENOENT, uid); + } + + final String mediaId = fileName.substring(0, lastDotIndex); + final Uri uri = getMediaUri(authority).buildUpon().appendPath(mediaId).build(); + + IBinder binder = getContext().getContentResolver() + .call(uri, METHOD_GET_ASYNC_CONTENT_PROVIDER, null, null) + .getBinder(EXTRA_ASYNC_CONTENT_PROVIDER); + if (binder == null) { + Log.e(TAG, "Picker file open failed. No cloud media provider found."); + return FileOpenResult.createError(OsConstants.ENOENT, uid); + } + IAsyncContentProvider iAsyncContentProvider = IAsyncContentProvider.Stub.asInterface( + binder); + AsyncContentProvider asyncContentProvider = new AsyncContentProvider(iAsyncContentProvider); + final ParcelFileDescriptor pfd; + try { + pfd = asyncContentProvider.openMedia(uri, "r"); + } catch (FileNotFoundException | ExecutionException | InterruptedException + | TimeoutException | RemoteException e) { + Log.e(TAG, "Picker file open failed. Failed to open URI: " + uri, e); + return FileOpenResult.createError(OsConstants.ENOENT, uid); + } + + try (FileInputStream fis = new FileInputStream(pfd.getFileDescriptor())) { + final String mimeType = MimeUtils.resolveMimeType(new File(path)); + // Picker segment indicates we need to force redact location metadata. + // Picker_get_content indicates that we need to check A_M_L permission to decide if the + // metadata needs to be redacted + LocalCallingIdentity callingIdentityForOriginalUid = getCachedCallingIdentityForFuse( + uid); + final boolean isRedactionNeeded = pickerSegmentType.equalsIgnoreCase(PICKER_SEGMENT) + || callingIdentityForOriginalUid == null + || isRedactionNeededForPickerUri(callingIdentityForOriginalUid); + Log.v(TAG, "Redaction needed for file open: " + isRedactionNeeded); + long[] redactionRanges = new long[0]; + if (isRedactionNeeded) { + redactionRanges = getRedactionRanges(fis, mimeType).redactionRanges; + } + return new FileOpenResult(0 /* status */, uid, /* transformsUid */ 0, + /* nativeFd */ pfd.detachFd(), redactionRanges); + } catch (IOException e) { + Log.e(TAG, "Picker file open failed. No file extension: " + path, e); + return FileOpenResult.createError(OsConstants.ENOENT, uid); + } + } + + private boolean preparePickerAuthorityPathSegment(File file, String authority, int uid) { + if (mPickerSyncController.isProviderEnabled(authority)) { + return file.exists() || file.mkdir(); + } + + return false; + } + + private boolean preparePickerMediaIdPathSegment(File file, String pickerSegmentType, + String authority, String fileName, String userId, int uid) { + final String mediaId = extractFileName(fileName); + final String[] projection = new String[]{MediaStore.PickerMediaColumns.SIZE}; + + final Uri uri = Uri.parse( + "content://media/" + pickerSegmentType + "/" + userId + "/" + authority + "/media/" + + mediaId); + try (Cursor cursor = mPickerUriResolver.query(uri, projection, /* callingPid */0, uid, + mCallingIdentity.get().getPackageName())) { + if (cursor != null && cursor.moveToFirst()) { + final int sizeBytesIdx = cursor.getColumnIndex(MediaStore.PickerMediaColumns.SIZE); + + if (sizeBytesIdx != -1) { + return createSparseFile(file, cursor.getLong(sizeBytesIdx)); + } + } + } + + return false; + } + + public int getBinderUidForFuse(int uid, int tid) { + if (uid != MY_UID) { + return uid; + } + + synchronized (mPendingOpenInfo) { + PendingOpenInfo info = mPendingOpenInfo.get(tid); + if (info == null) { + return uid; + } + return info.uid; + } + } + + private static int uidToUserId(int uid) { + return uid / PER_USER_RANGE; + } + + /** + * Returns true if the app denoted by the given {@code uid} and {@code packageName} is allowed + * to clear other apps' cache directories. + */ + static boolean hasPermissionToClearCaches(Context context, ApplicationInfo ai) { + PermissionUtils.setOpDescription("clear app cache"); + try { + return PermissionUtils.checkPermissionManager(context, /* pid */ -1, ai.uid, + ai.packageName, /* attributionTag */ null); + } finally { + PermissionUtils.clearOpDescription(); + } + } + + @VisibleForTesting + void computeAudioLocalizedValues(ContentValues values) { + try { + final String title = values.getAsString(AudioColumns.TITLE); + final String titleRes = values.getAsString(AudioColumns.TITLE_RESOURCE_URI); + + if (!TextUtils.isEmpty(titleRes)) { + final String localized = getLocalizedTitle(titleRes); + if (!TextUtils.isEmpty(localized)) { + values.put(AudioColumns.TITLE, localized); + } + } else { + final String localized = getLocalizedTitle(title); + if (!TextUtils.isEmpty(localized)) { + values.put(AudioColumns.TITLE, localized); + values.put(AudioColumns.TITLE_RESOURCE_URI, title); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to localize title", e); + } + } + + @VisibleForTesting + static void computeAudioKeyValues(ContentValues values) { + computeAudioKeyValue(values, AudioColumns.TITLE, AudioColumns.TITLE_KEY, /* focusId */ + null, /* hashValue */ 0); + computeAudioKeyValue(values, AudioColumns.ARTIST, AudioColumns.ARTIST_KEY, + AudioColumns.ARTIST_ID, /* hashValue */ 0); + computeAudioKeyValue(values, AudioColumns.GENRE, AudioColumns.GENRE_KEY, + AudioColumns.GENRE_ID, /* hashValue */ 0); + computeAudioAlbumKeyValue(values); + } + + /** + * To distinguish same-named albums, we append a hash. The hash is + * based on the "album artist" tag if present, otherwise on the path of + * the parent directory of the audio file. + */ + private static void computeAudioAlbumKeyValue(ContentValues values) { + int hashCode = 0; + + final String albumArtist = values.getAsString(MediaColumns.ALBUM_ARTIST); + if (!TextUtils.isEmpty(albumArtist)) { + hashCode = albumArtist.hashCode(); + } else { + final String path = values.getAsString(MediaColumns.DATA); + if (!TextUtils.isEmpty(path)) { + hashCode = path.substring(0, path.lastIndexOf('/')).hashCode(); + } + } + + computeAudioKeyValue(values, AudioColumns.ALBUM, AudioColumns.ALBUM_KEY, + AudioColumns.ALBUM_ID, hashCode); + } + + private static void computeAudioKeyValue(@NonNull ContentValues values, @NonNull String focus, + @Nullable String focusKey, @Nullable String focusId, int hashValue) { + if (focusKey != null) values.remove(focusKey); + if (focusId != null) values.remove(focusId); + + final String value = values.getAsString(focus); + if (TextUtils.isEmpty(value)) return; + + final String key = Audio.keyFor(value); + if (key == null) return; + + if (focusKey != null) { + values.put(focusKey, key); + } + if (focusId != null) { + // Many apps break if we generate negative IDs, so trim off the + // highest bit to ensure we're always unsigned + final long id = Hashing.farmHashFingerprint64().hashString(key + hashValue, + StandardCharsets.UTF_8).asLong() & ~(1L << 63); + values.put(focusId, id); + } + } + + @Override + public Uri canonicalize(@NonNull Uri uri) { + // Skip when we have nothing to canonicalize + if ("1".equals(uri.getQueryParameter(CANONICAL))) { + return uri; + } + + final boolean allowHidden = mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF); + final int match = matchUri(uri, allowHidden); + + try (Cursor c = queryForSingleItem(uri, null, null, null, null)) { + switch (match) { + case AUDIO_MEDIA_ID: { + final String title = getDefaultTitleFromCursor(c); + if (!TextUtils.isEmpty(title)) { + final Uri.Builder builder = uri.buildUpon(); + builder.appendQueryParameter(AudioColumns.TITLE, title); + builder.appendQueryParameter(CANONICAL, "1"); + return builder.build(); + } + break; + } + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: { + final String documentId = c + .getString(c.getColumnIndexOrThrow(MediaColumns.DOCUMENT_ID)); + if (!TextUtils.isEmpty(documentId)) { + final Uri.Builder builder = uri.buildUpon(); + builder.appendQueryParameter(MediaColumns.DOCUMENT_ID, documentId); + builder.appendQueryParameter(CANONICAL, "1"); + return builder.build(); + } + break; + } + } + } catch (FileNotFoundException e) { + Log.w(TAG, e.getMessage()); + } + return null; + } + + @Override + public Uri uncanonicalize(@NonNull Uri uri) { + // Skip when we have nothing to uncanonicalize + if (!"1".equals(uri.getQueryParameter(CANONICAL))) { + return uri; + } + final boolean allowHidden = mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF); + final int match = matchUri(uri, allowHidden); + + // Extract values and then clear to avoid recursive lookups + final String title = uri.getQueryParameter(AudioColumns.TITLE); + final String documentId = uri.getQueryParameter(MediaColumns.DOCUMENT_ID); + uri = uri.buildUpon().clearQuery().build(); + + switch (match) { + case AUDIO_MEDIA_ID: { + // First check for an exact match + try (Cursor c = queryForSingleItem(uri, null, null, null, null)) { + if (Objects.equals(title, getDefaultTitleFromCursor(c))) { + return uri; + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Trouble resolving " + uri + "; falling back to search: " + e); + } + + // Otherwise fallback to searching + final Uri baseUri = ContentUris.removeId(uri); + try (Cursor c = queryForSingleItem(baseUri, + new String[] { BaseColumns._ID }, + AudioColumns.TITLE + "=?", new String[] { title }, null)) { + return ContentUris.withAppendedId(baseUri, c.getLong(0)); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to resolve " + uri + ": " + e); + return null; + } + } + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: { + // First check for an exact match + try (Cursor c = queryForSingleItem(uri, null, null, null, null)) { + if (Objects.equals(title, getDefaultTitleFromCursor(c))) { + return uri; + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Trouble resolving " + uri + "; falling back to search: " + e); + } + + // Otherwise fallback to searching + final Uri baseUri = ContentUris.removeId(uri); + try (Cursor c = queryForSingleItem(baseUri, + new String[] { BaseColumns._ID }, + MediaColumns.DOCUMENT_ID + "=?", new String[] { documentId }, null)) { + return ContentUris.withAppendedId(baseUri, c.getLong(0)); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to resolve " + uri + ": " + e); + return null; + } + } + } + + return uri; + } + + private Uri safeUncanonicalize(Uri uri) { + Uri newUri = uncanonicalize(uri); + if (newUri != null) { + return newUri; + } + return uri; + } + + private static String safeTraceSectionNameWithUri(String operation, Uri uri) { + String sectionName = "MP." + operation + " [" + uri + "]"; + if (sectionName.length() > MAX_SECTION_NAME_LEN) { + return sectionName.substring(0, MAX_SECTION_NAME_LEN); + } + return sectionName; + } + + /** + * @return where clause to exclude database rows where + *

    + *
  • {@code column} is set or + *
  • {@code column} is {@link MediaColumns#IS_PENDING} and is set by FUSE and not owned by + * calling package. + *
  • {@code column} is {@link MediaColumns#IS_PENDING}, is unset and is waiting for + * metadata update from a deferred scan. + *
+ */ + private String getWhereClauseForMatchExclude(@NonNull String column) { + if (column.equalsIgnoreCase(MediaColumns.IS_PENDING)) { + // Don't include rows that are pending for metadata + final String pendingForMetadata = FileColumns._MODIFIER + "=" + + FileColumns._MODIFIER_CR_PENDING_METADATA; + final String notPending = String.format("(%s=0 AND NOT %s)", column, + pendingForMetadata); + + // Include owned pending files from Fuse + final String pendingFromFuse = String.format("(%s=1 AND %s AND %s)", column, + MATCH_PENDING_FROM_FUSE, getWhereForOwnerPackageMatch(mCallingIdentity.get())); + + return "(" + notPending + " OR " + pendingFromFuse + ")"; + } + return column + "=0"; + } + + /** + * @return where clause to include database rows where + *
    + *
  • {@code column} is not set or + *
  • {@code column} is set and calling package has write permission to corresponding db row + * or {@code column} is {@link MediaColumns#IS_PENDING} and is set by FUSE. + *
+ * The method is used to match db rows corresponding to writable pending and trashed files. + */ + @Nullable + private String getWhereClauseForMatchableVisibleFromFilePath(@NonNull Uri uri, + @NonNull String column) { + if (checkCallingPermissionGlobal(uri, /*forWrite*/ true)) { + // No special filtering needed + return null; + } + + int uriType = matchUri(uri, isCallingPackageAllowedHidden()); + if (hasAccessToCollection(mCallingIdentity.get(), uriType, /* forWrite */ true)) { + // has direct write access to whole collection, no special filtering needed. + return null; + } + + final String writeAccessCheckSql = getWhereForConstrainedAccess(mCallingIdentity.get(), + uriType, /* forWrite */ true, Bundle.EMPTY); + + final String matchWritableRowsClause = String.format("%s=0 OR (%s=1 AND (%s OR %s))", + column, column, MATCH_PENDING_FROM_FUSE, writeAccessCheckSql); + + return matchWritableRowsClause; + } + + /** + * Gets list of files in {@code path} from media provider database. + * + * @param path path of the directory. + * @param uid UID of the calling process. + * @return a list of file names in the given directory path. + * An empty list is returned if no files are visible to the calling app or the given directory + * does not have any files. + * A list with ["/"] is returned if the path is not indexed by MediaProvider database or + * calling package is a legacy app and has appropriate storage permissions for the given path. + * In both scenarios file names should be obtained from lower file system. + * A list with empty string[""] is returned if the calling package doesn't have access to the + * given path. + * + *

Directory names are always obtained from lower file system. + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public String[] getFilesInDirectoryForFuse(String path, int uid) { + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + + try { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { + return new String[] {""}; + } + + if (shouldBypassFuseRestrictions(/*forWrite*/ false, path)) { + return new String[] {"/"}; + } + + // Do not allow apps to list Android/data or Android/obb dirs. + // On primary volumes, apps that get special access to these directories get it via + // mount views of lowerfs. On secondary volumes, such apps would return early from + // shouldBypassFuseRestrictions above. + if (isDataOrObbPath(path)) { + return new String[] {""}; + } + + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (isCallingPackageRequestingLegacy()) { + return new String[] {""}; + } + + // Get relative path for the contents of given directory. + String relativePath = extractRelativePathWithDisplayName(path); + if (relativePath == null) { + // Path is /storage/emulated/, if relativePath is null, MediaProvider doesn't + // have any details about the given directory. Use lower file system to obtain + // files and directories in the given directory. + return new String[] {"/"}; + } + // Getting UserId from the directory path, as clone user shares the MediaProvider + // of user 0. + int userIdFromPath = FileUtils.extractUserId(path); + // In some cases, like querying public volumes, userId is not available in path. We + // take userId from the user running MediaProvider process (sUserId). + if (userIdFromPath == -1) { + userIdFromPath = sUserId; + } + // For all other paths, get file names from media provider database. + // Return media and non-media files visible to the calling package. + ArrayList fileNamesList = new ArrayList<>(); + + // Only FileColumns.DATA contains actual name of the file. + String[] projection = {MediaColumns.DATA}; + + Bundle queryArgs = new Bundle(); + queryArgs.putString(QUERY_ARG_SQL_SELECTION, MediaColumns.RELATIVE_PATH + + " =? and " + FileColumns._USER_ID + " =? and mime_type not like 'null'"); + queryArgs.putStringArray(QUERY_ARG_SQL_SELECTION_ARGS, new String[] {relativePath, + String.valueOf(userIdFromPath)}); + // Get database entries for files from MediaProvider database with + // MediaColumns.RELATIVE_PATH as the given path. + try (final Cursor cursor = query(FileUtils.getContentUriForPath(path), projection, + queryArgs, null)) { + while(cursor.moveToNext()) { + fileNamesList.add(extractDisplayName(cursor.getString(0))); + } + } + return fileNamesList.toArray(new String[fileNamesList.size()]); + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** + * Scan files during directory renames for the following reasons: + *

    + *
  • Because we don't update db rows for directories, we scan the oldPath to discard stale + * directory db rows. This prevents conflicts during subsequent db operations with oldPath. + *
  • We need to scan newPath as well, because the new directory may have become hidden + * or unhidden, in which case we need to update the media types of the contained files + *
+ */ + private void scanRenamedDirectoryForFuse(@NonNull String oldPath, @NonNull String newPath) { + scanFileAsMediaProvider(new File(oldPath), REASON_DEMAND); + scanFileAsMediaProvider(new File(newPath), REASON_DEMAND); + } + + /** + * Checks if given {@code mimeType} is supported in {@code path}. + */ + private boolean isMimeTypeSupportedInPath(String path, String mimeType) { + final String supportedPrimaryMimeType; + final int match = matchUri(getContentUriForFile(path, mimeType), true); + switch (match) { + case AUDIO_MEDIA: + supportedPrimaryMimeType = "audio"; + break; + case VIDEO_MEDIA: + supportedPrimaryMimeType = "video"; + break; + case IMAGES_MEDIA: + supportedPrimaryMimeType = "image"; + break; + default: + supportedPrimaryMimeType = ClipDescription.MIMETYPE_UNKNOWN; + } + return (supportedPrimaryMimeType.equalsIgnoreCase(ClipDescription.MIMETYPE_UNKNOWN) || + StringUtils.startsWithIgnoreCase(mimeType, supportedPrimaryMimeType)); + } + + /** + * Removes owner package for the renamed path if the calling package doesn't own the db row + * + * When oldPath is renamed to newPath, if newPath exists in the database, and caller is not the + * owner of the file, owner package is set to 'null'. This prevents previous owner of newPath + * from accessing renamed file. + * @return {@code true} if + *
    + *
  • there is no corresponding database row for given {@code path} + *
  • shared calling package is the owner of the database row + *
  • owner package name is already set to 'null' + *
  • updating owner package name to 'null' was successful. + *
+ * Returns {@code false} otherwise. + */ + private boolean maybeRemoveOwnerPackageForFuseRename(@NonNull DatabaseHelper helper, + @NonNull String path) { + final Uri uri = FileUtils.getContentUriForPath(path); + final int match = matchUri(uri, isCallingPackageAllowedHidden()); + final String ownerPackageName; + final String selection = MediaColumns.DATA + " =? AND " + + MediaColumns.OWNER_PACKAGE_NAME + " != 'null'"; + final String[] selectionArgs = new String[] {path}; + + final SQLiteQueryBuilder qbForQuery = + getQueryBuilder(TYPE_QUERY, match, uri, Bundle.EMPTY, null); + try (Cursor c = qbForQuery.query(helper, new String[] {FileColumns.OWNER_PACKAGE_NAME}, + selection, selectionArgs, null, null, null, null, null)) { + if (!c.moveToFirst()) { + // We don't need to remove owner_package from db row if path doesn't exist in + // database or owner_package is already set to 'null' + return true; + } + ownerPackageName = c.getString(0); + if (isCallingIdentitySharedPackageName(ownerPackageName)) { + // We don't need to remove owner_package from db row if calling package is the owner + // of the database row + return true; + } + } + + final SQLiteQueryBuilder qbForUpdate = + getQueryBuilder(TYPE_UPDATE, match, uri, Bundle.EMPTY, null); + ContentValues values = new ContentValues(); + values.put(FileColumns.OWNER_PACKAGE_NAME, "null"); + return qbForUpdate.update(helper, values, selection, selectionArgs) == 1; + } + + private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper, + @NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values) { + return updateDatabaseForFuseRename(helper, oldPath, newPath, values, Bundle.EMPTY); + } + + private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper, + @NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values, + @NonNull Bundle qbExtras) { + return updateDatabaseForFuseRename(helper, oldPath, newPath, values, qbExtras, + FileUtils.getContentUriForPath(oldPath)); + } + + /** + * Updates database entry for given {@code path} with {@code values} + */ + private boolean updateDatabaseForFuseRename(@NonNull DatabaseHelper helper, + @NonNull String oldPath, @NonNull String newPath, @NonNull ContentValues values, + @NonNull Bundle qbExtras, Uri uriOldPath) { + boolean allowHidden = isCallingPackageAllowedHidden(); + final SQLiteQueryBuilder qbForUpdate = getQueryBuilder(TYPE_UPDATE, + matchUri(uriOldPath, allowHidden), uriOldPath, qbExtras, null); + + // uriOldPath may use Files uri which doesn't allow modifying AudioColumns. Include + // AudioColumns projection map if we are modifying any audio columns while renaming + // database rows. + if (values.containsKey(AudioColumns.IS_RINGTONE)) { + qbForUpdate.setProjectionMap(getProjectionMap(AudioColumns.class, FileColumns.class)); + } + + if (values.containsKey(FileColumns._MODIFIER)) { + qbForUpdate.allowColumn(FileColumns._MODIFIER); + } + + final String selection = MediaColumns.DATA + " =? "; + int count = 0; + boolean retryUpdateWithReplace = false; + + try { + // TODO(b/146777893): System gallery apps can rename a media directory containing + // non-media files. This update doesn't support updating non-media files that are not + // owned by system gallery app. + count = qbForUpdate.update(helper, values, selection, new String[]{oldPath}); + } catch (SQLiteConstraintException e) { + Log.w(TAG, "Database update failed while renaming " + oldPath, e); + retryUpdateWithReplace = true; + } + + if (retryUpdateWithReplace) { + if (deleteForFuseRename(helper, oldPath, newPath, qbExtras, selection, allowHidden)) { + Log.i(TAG, "Retrying database update after deleting conflicting entry"); + count = qbForUpdate.update(helper, values, selection, new String[]{oldPath}); + } else { + return false; + } + } + return count == 1; + } + + private boolean deleteForFuseRename(DatabaseHelper helper, String oldPath, + String newPath, Bundle qbExtras, String selection, boolean allowHidden) { + // We are replacing file in newPath with file in oldPath. If calling package has + // write permission for newPath, delete existing database entry and retry update. + final Uri uriNewPath = FileUtils.getContentUriForPath(oldPath); + final SQLiteQueryBuilder qbForDelete = getQueryBuilder(TYPE_DELETE, + matchUri(uriNewPath, allowHidden), uriNewPath, qbExtras, null); + if (qbForDelete.delete(helper, selection, new String[] {newPath}) == 1) { + return true; + } + // Check if delete can be done using other URI grants + final String[] projection = new String[] { + FileColumns.MEDIA_TYPE, + FileColumns.DATA, + FileColumns._ID, + FileColumns.IS_DOWNLOAD, + FileColumns.MIME_TYPE, + }; + return + deleteWithOtherUriGrants( + FileUtils.getContentUriForPath(newPath), + helper, projection, selection, new String[] {newPath}, qbExtras) == 1; + } + + /** + * Gets {@link ContentValues} for updating database entry to {@code path}. + */ + private ContentValues getContentValuesForFuseRename(String path, String newMimeType, + boolean wasHidden, boolean isHidden, boolean isSameMimeType) { + ContentValues values = new ContentValues(); + values.put(MediaColumns.MIME_TYPE, newMimeType); + values.put(MediaColumns.DATA, path); + + if (isHidden) { + values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE); + } else { + int mediaType = MimeUtils.resolveMediaType(newMimeType); + values.put(FileColumns.MEDIA_TYPE, mediaType); + } + + if ((!isHidden && wasHidden) || !isSameMimeType) { + // Set the modifier as MODIFIER_FUSE so that apps can scan the file to update the + // metadata. Otherwise, scan will skip scanning this file because rename() doesn't + // change lastModifiedTime and scan assumes there is no change in the file. + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE); + } + + if (MimeUtils.isAudioMimeType(newMimeType) && !values.containsKey(FileColumns._MODIFIER)) { + computeAudioLocalizedValues(values); + computeAudioKeyValues(values); + FileUtils.computeAudioTypeValuesFromData(path, values::put); + } + + FileUtils.computeValuesFromData(values, isFuseThread()); + return values; + } + + private ArrayList getIncludedDefaultDirectories() { + final ArrayList includedDefaultDirs = new ArrayList<>(); + if (mCallingIdentity.get().checkCallingPermissionVideo(/* forWrite */ true)) { + includedDefaultDirs.add(Environment.DIRECTORY_DCIM); + includedDefaultDirs.add(Environment.DIRECTORY_PICTURES); + includedDefaultDirs.add(Environment.DIRECTORY_MOVIES); + } else if (mCallingIdentity.get().checkCallingPermissionImages(/* forWrite */ true)) { + includedDefaultDirs.add(Environment.DIRECTORY_DCIM); + includedDefaultDirs.add(Environment.DIRECTORY_PICTURES); + } + return includedDefaultDirs; + } + + /** + * Gets all files in the given {@code path} and subdirectories of the given {@code path}. + */ + private ArrayList getAllFilesForRenameDirectory(String oldPath) { + final String selection = FileColumns.DATA + " LIKE ? ESCAPE '\\'" + + " and mime_type not like 'null'"; + final String[] selectionArgs = new String[] {DatabaseUtils.escapeForLike(oldPath) + "/%"}; + ArrayList fileList = new ArrayList<>(); + + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try (final Cursor c = query(FileUtils.getContentUriForPath(oldPath), + new String[] {MediaColumns.DATA}, selection, selectionArgs, null)) { + while (c.moveToNext()) { + String filePath = c.getString(0); + filePath = filePath.replaceFirst(Pattern.quote(oldPath + "/"), ""); + fileList.add(filePath); + } + } finally { + restoreLocalCallingIdentity(token); + } + return fileList; + } + + /** + * Gets files in the given {@code path} and subdirectories of the given {@code path} for which + * calling package has write permissions. + * + * This method throws {@code IllegalArgumentException} if the directory has one or more + * files for which calling package doesn't have write permission or if file type is not + * supported in {@code newPath} + */ + private ArrayList getWritableFilesForRenameDirectory(String oldPath, String newPath) + throws IllegalArgumentException { + // Try a simple check to see if the caller has full access to the given collections first + // before falling back to performing a query to probe for access. + final String oldRelativePath = extractRelativePathWithDisplayName(oldPath); + final String newRelativePath = extractRelativePathWithDisplayName(newPath); + boolean hasFullAccessToOldPath = false; + boolean hasFullAccessToNewPath = false; + for (String defaultDir : getIncludedDefaultDirectories()) { + if (oldRelativePath.startsWith(defaultDir)) hasFullAccessToOldPath = true; + if (newRelativePath.startsWith(defaultDir)) hasFullAccessToNewPath = true; + } + if (hasFullAccessToNewPath && hasFullAccessToOldPath) { + return getAllFilesForRenameDirectory(oldPath); + } + + final int countAllFilesInDirectory; + final String selection = FileColumns.DATA + " LIKE ? ESCAPE '\\'" + + " and mime_type not like 'null'"; + final String[] selectionArgs = new String[] {DatabaseUtils.escapeForLike(oldPath) + "/%"}; + + final Uri uriOldPath = FileUtils.getContentUriForPath(oldPath); + + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try (final Cursor c = query(uriOldPath, new String[] {MediaColumns._ID}, selection, + selectionArgs, null)) { + // get actual number of files in the given directory. + countAllFilesInDirectory = c.getCount(); + } finally { + restoreLocalCallingIdentity(token); + } + + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, + matchUri(uriOldPath, isCallingPackageAllowedHidden()), uriOldPath, Bundle.EMPTY, + null); + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(uriOldPath); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Volume not found while querying files for renaming " + + oldPath); + } + + ArrayList fileList = new ArrayList<>(); + final String[] projection = {MediaColumns.DATA, MediaColumns.MIME_TYPE}; + try (Cursor c = qb.query(helper, projection, selection, selectionArgs, null, null, null, + null, null)) { + // Check if the calling package has write permission to all files in the given + // directory. If calling package has write permission to all files in the directory, the + // query with update uri should return same number of files as previous query. + if (c.getCount() != countAllFilesInDirectory) { + throw new IllegalArgumentException("Calling package doesn't have write permission " + + " to rename one or more files in " + oldPath); + } + while(c.moveToNext()) { + String filePath = c.getString(0); + filePath = filePath.replaceFirst(Pattern.quote(oldPath + "/"), ""); + + final String mimeType = c.getString(1); + if (!isMimeTypeSupportedInPath(newPath + "/" + filePath, mimeType)) { + throw new IllegalArgumentException("Can't rename " + oldPath + "/" + filePath + + ". Mime type " + mimeType + " not supported in " + newPath); + } + fileList.add(filePath); + } + } + return fileList; + } + + private int renameInLowerFs(String oldPath, String newPath) { + try { + Os.rename(oldPath, newPath); + return 0; + } catch (ErrnoException e) { + final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed."; + Log.e(TAG, errorMessage, e); + return e.errno; + } + } + + /** + * Rename directory from {@code oldPath} to {@code newPath}. + * + * Renaming a directory is only allowed if calling package has write permission to all files in + * the given directory tree and all file types in the given directory tree are supported by the + * top level directory of new path. Renaming a directory is split into three steps: + * 1. Check calling package's permissions for all files in the given directory tree. Also check + * file type support for all files in the {@code newPath}. + * 2. Try updating database for all files in the directory. + * 3. Rename the directory in lower file system. If rename in the lower file system is + * successful, commit database update. + * + * @param oldPath path of the directory to be renamed. + * @param newPath new path of directory to be renamed. + * @return 0 on successful rename, appropriate negated errno value if the rename is not allowed. + *
    + *
  • {@link OsConstants#EPERM} Renaming a directory with file types not supported by + * {@code newPath} or renaming a directory with files for which calling package doesn't have + * write permission. + * This method can also return errno returned from {@code Os.rename} function. + */ + private int renameDirectoryCheckedForFuse(String oldPath, String newPath) { + final ArrayList fileList; + try { + fileList = getWritableFilesForRenameDirectory(oldPath, newPath); + } catch (IllegalArgumentException e) { + final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. "; + Log.e(TAG, errorMessage, e); + return OsConstants.EPERM; + } + + return renameDirectoryUncheckedForFuse(oldPath, newPath, fileList); + } + + private int renameDirectoryUncheckedForFuse(String oldPath, String newPath, + ArrayList fileList) { + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(FileUtils.getContentUriForPath(oldPath)); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Volume not found while trying to update database for " + + oldPath, e); + } + + helper.beginTransaction(); + try { + final Bundle qbExtras = new Bundle(); + qbExtras.putStringArrayList(INCLUDED_DEFAULT_DIRECTORIES, + getIncludedDefaultDirectories()); + final boolean wasHidden = FileUtils.shouldDirBeHidden(new File(oldPath)); + final boolean isHidden = FileUtils.shouldDirBeHidden(new File(newPath)); + for (String filePath : fileList) { + final String newFilePath = newPath + "/" + filePath; + final String mimeType = MimeUtils.resolveMimeType(new File(newFilePath)); + if(!updateDatabaseForFuseRename(helper, oldPath + "/" + filePath, newFilePath, + getContentValuesForFuseRename(newFilePath, mimeType, wasHidden, isHidden, + /* isSameMimeType */ true), + qbExtras)) { + Log.e(TAG, "Calling package doesn't have write permission to rename file."); + return OsConstants.EPERM; + } + } + + // Rename the directory in lower file system. + int errno = renameInLowerFs(oldPath, newPath); + if (errno == 0) { + helper.setTransactionSuccessful(); + } else { + return errno; + } + } finally { + helper.endTransaction(); + } + // Directory movement might have made new/old path hidden. + scanRenamedDirectoryForFuse(oldPath, newPath); + return 0; + } + + /** + * Rename a file from {@code oldPath} to {@code newPath}. + * + * Renaming a file is split into three parts: + * 1. Check if {@code newPath} supports new file type. + * 2. Try updating database entry from {@code oldPath} to {@code newPath}. This update may fail + * if calling package doesn't have write permission for {@code oldPath} and {@code newPath}. + * 3. Rename the file in lower file system. If Rename in lower file system succeeds, commit + * database update. + * @param oldPath path of the file to be renamed. + * @param newPath new path of the file to be renamed. + * @return 0 on successful rename, appropriate negated errno value if the rename is not allowed. + *
      + *
    • {@link OsConstants#EPERM} Calling package doesn't have write permission for + * {@code oldPath} or {@code newPath}, or file type is not supported by {@code newPath}. + * This method can also return errno returned from {@code Os.rename} function. + */ + private int renameFileCheckedForFuse(String oldPath, String newPath) { + // Check if new mime type is supported in new path. + final String newMimeType = MimeUtils.resolveMimeType(new File(newPath)); + if (!isMimeTypeSupportedInPath(newPath, newMimeType)) { + return OsConstants.EPERM; + } + return renameFileForFuse(oldPath, newPath, /* bypassRestrictions */ false) ; + } + + private int renameFileUncheckedForFuse(String oldPath, String newPath) { + return renameFileForFuse(oldPath, newPath, /* bypassRestrictions */ true) ; + } + + private int renameFileForFuse(String oldPath, String newPath, boolean bypassRestrictions) { + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(FileUtils.getContentUriForPath(oldPath)); + } catch (VolumeNotFoundException e) { + throw new IllegalStateException("Failed to update database row with " + oldPath, e); + } + + final boolean wasHidden = FileUtils.shouldFileBeHidden(new File(oldPath)); + final boolean isHidden = FileUtils.shouldFileBeHidden(new File(newPath)); + helper.beginTransaction(); + try { + final String newMimeType = MimeUtils.resolveMimeType(new File(newPath)); + final String oldMimeType = MimeUtils.resolveMimeType(new File(oldPath)); + final boolean isSameMimeType = newMimeType.equalsIgnoreCase(oldMimeType); + ContentValues contentValues = getContentValuesForFuseRename(newPath, newMimeType, + wasHidden, isHidden, isSameMimeType); + if (!updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues)) { + if (!bypassRestrictions) { + // Check for other URI format grants for oldPath only. Check right before + // returning EPERM, to leave positive case performance unaffected. + if (!renameWithOtherUriGrants(helper, oldPath, newPath, contentValues)) { + Log.e(TAG, "Calling package doesn't have write permission to rename file."); + return OsConstants.EPERM; + } + } else if (!maybeRemoveOwnerPackageForFuseRename(helper, newPath)) { + Log.wtf(TAG, "Couldn't clear owner package name for " + newPath); + return OsConstants.EPERM; + } + } + + // Try renaming oldPath to newPath in lower file system. + int errno = renameInLowerFs(oldPath, newPath); + if (errno == 0) { + helper.setTransactionSuccessful(); + } else { + return errno; + } + } finally { + helper.endTransaction(); + } + // The above code should have taken are of the mime/media type of the new file, + // even if it was moved to/from a hidden directory. + // This leaves cases where the source/dest of the move is a .nomedia file itself. Eg: + // 1) /sdcard/foo/.nomedia => /sdcard/foo/bar.mp3 + // in this case, the code above has given bar.mp3 the correct mime type, but we should + // still can /sdcard/foo, because it's now no longer hidden + // 2) /sdcard/foo/.nomedia => /sdcard/bar/.nomedia + // in this case, we need to scan both /sdcard/foo and /sdcard/bar/ + // 3) /sdcard/foo/bar.mp3 => /sdcard/foo/.nomedia + // in this case, we need to scan all of /sdcard/foo + if (extractDisplayName(oldPath).equals(".nomedia")) { + scanFileAsMediaProvider(new File(oldPath).getParentFile(), REASON_DEMAND); + } + if (extractDisplayName(newPath).equals(".nomedia")) { + scanFileAsMediaProvider(new File(newPath).getParentFile(), REASON_DEMAND); + } + + return 0; + } + + /** + * Rename file by checking for other URI grants on oldPath + * + * We don't support replace scenario by checking for other URI grants on newPath (if it exists). + */ + private boolean renameWithOtherUriGrants(DatabaseHelper helper, String oldPath, String newPath, + ContentValues contentValues) { + final Uri oldPathGrantedUri = getOtherUriGrantsForPath(oldPath, /* forWrite */ true); + if (oldPathGrantedUri == null) { + return false; + } + return updateDatabaseForFuseRename(helper, oldPath, newPath, contentValues, Bundle.EMPTY, + oldPathGrantedUri); + } + + /** + * Rename file/directory without imposing any restrictions. + * + * We don't impose any rename restrictions for apps that bypass scoped storage restrictions. + * However, we update database entries for renamed files to keep the database consistent. + */ + private int renameUncheckedForFuse(String oldPath, String newPath) { + if (new File(oldPath).isFile()) { + return renameFileUncheckedForFuse(oldPath, newPath); + } else { + return renameDirectoryUncheckedForFuse(oldPath, newPath, + getAllFilesForRenameDirectory(oldPath)); + } + } + + /** + * Rename file or directory from {@code oldPath} to {@code newPath}. + * + * @param oldPath path of the file or directory to be renamed. + * @param newPath new path of the file or directory to be renamed. + * @param uid UID of the calling package. + * @return 0 on successful rename, appropriate errno value if the rename is not allowed. + *
        + *
      • {@link OsConstants#ENOENT} Renaming a non-existing file or renaming a file from path that + * is not indexed by MediaProvider database. + *
      • {@link OsConstants#EPERM} Renaming a default directory or renaming a file to a file type + * not supported by new path. + * This method can also return errno returned from {@code Os.rename} function. + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public int renameForFuse(String oldPath, String newPath, int uid) { + final String errorMessage = "Rename " + oldPath + " to " + newPath + " failed. "; + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), oldPath); + + try { + if (isPrivatePackagePathNotAccessibleByCaller(oldPath) + || isPrivatePackagePathNotAccessibleByCaller(newPath)) { + return OsConstants.EACCES; + } + + if (!newPath.equals(getAbsoluteSanitizedPath(newPath))) { + Log.e(TAG, "New path name contains invalid characters."); + return OsConstants.EPERM; + } + + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, oldPath) + && shouldBypassDatabaseAndSetDirtyForFuse(uid, newPath)) { + return renameInLowerFs(oldPath, newPath); + } + + if (shouldBypassFuseRestrictions(/*forWrite*/ true, oldPath) + && shouldBypassFuseRestrictions(/*forWrite*/ true, newPath)) { + return renameUncheckedForFuse(oldPath, newPath); + } + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (isCallingPackageRequestingLegacy()) { + return OsConstants.EACCES; + } + + final String[] oldRelativePath = sanitizePath(extractRelativePath(oldPath)); + final String[] newRelativePath = sanitizePath(extractRelativePath(newPath)); + if (oldRelativePath.length == 0 || newRelativePath.length == 0) { + // Rename not allowed on paths that can't be translated to RELATIVE_PATH. + Log.e(TAG, errorMessage + "Invalid path."); + return OsConstants.EPERM; + } + if (oldRelativePath.length == 1 && TextUtils.isEmpty(oldRelativePath[0])) { + // Allow rename of files/folders other than default directories. + final String displayName = extractDisplayName(oldPath); + for (String defaultFolder : DEFAULT_FOLDER_NAMES) { + if (displayName.equals(defaultFolder)) { + Log.e(TAG, errorMessage + oldPath + " is a default folder." + + " Renaming a default folder is not allowed."); + return OsConstants.EPERM; + } + } + } + if (newRelativePath.length == 1 && TextUtils.isEmpty(newRelativePath[0])) { + Log.e(TAG, errorMessage + newPath + " is in root folder." + + " Renaming a file/directory to root folder is not allowed"); + return OsConstants.EPERM; + } + + final File directoryAndroid = new File( + extractVolumePath(oldPath).toLowerCase(Locale.ROOT), + DIRECTORY_ANDROID_LOWER_CASE + ); + final File directoryAndroidMedia = new File(directoryAndroid, DIRECTORY_MEDIA); + String newPathLowerCase = newPath.toLowerCase(Locale.ROOT); + if (directoryAndroidMedia.getAbsolutePath().equalsIgnoreCase(oldPath)) { + // Don't allow renaming 'Android/media' directory. + // Android/[data|obb] are bind mounted and these paths don't go through FUSE. + Log.e(TAG, errorMessage + oldPath + " is a default folder in app external " + + "directory. Renaming a default folder is not allowed."); + return OsConstants.EPERM; + } else if (FileUtils.contains(directoryAndroid, new File(newPathLowerCase))) { + if (newRelativePath.length <= 2) { + // Path is directly under Android, Android/media, Android/data, Android/obb or + // some other directory under Android. Don't allow moving files and directories + // in these paths. Files and directories are only allowed to move to path + // Android/media//* + Log.e(TAG, errorMessage + newPath + " is in app external directory. " + + "Renaming a file/directory to app external directory is not " + + "allowed."); + return OsConstants.EPERM; + } else if (!FileUtils.contains(directoryAndroidMedia, new File(newPathLowerCase))) { + // New path is not in Android/media/*. Don't allow moving of files or + // directories to app external directory other than media directory. + Log.e(TAG, errorMessage + newPath + " is not in external media directory." + + "File/directory can only be renamed to a path in external media " + + "directory. Renaming file/directory to path in other external " + + "directories is not allowed"); + return OsConstants.EPERM; + } + } + + // Continue renaming files/directories if rename of oldPath to newPath is allowed. + if (new File(oldPath).isFile()) { + return renameFileCheckedForFuse(oldPath, newPath); + } else { + return renameDirectoryCheckedForFuse(oldPath, newPath); + } + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Override + public int checkUriPermission(@NonNull Uri uri, int uid, + /* @Intent.AccessUriMode */ int modeFlags) { + final LocalCallingIdentity token = clearLocalCallingIdentity( + LocalCallingIdentity.fromExternal(getContext(), mUserCache, uid)); + + if (isRedactedUri(uri)) { + if ((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + // we don't allow write grants on redacted uris. + return PackageManager.PERMISSION_DENIED; + } + + uri = getUriForRedactedUri(uri); + } + + if (isPickerUri(uri)) { + // Do not allow implicit access (by the virtue of ownership/permission) to picker uris. + // Picker uris should have explicit permission grants. + // If the calling app A has an explicit grant on picker uri, UriGrantsManagerService + // will check the grant status and allow app A to grant the uri to app B (without + // calling into MediaProvider) + return PackageManager.PERMISSION_DENIED; + } + + try { + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int table = matchUri(uri, allowHidden); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + return PackageManager.PERMISSION_DENIED; + } + + final int type; + if ((modeFlags & Intent.FLAG_GRANT_WRITE_URI_PERMISSION) != 0) { + type = TYPE_UPDATE; + } else { + type = TYPE_QUERY; + } + + final SQLiteQueryBuilder qb = getQueryBuilder(type, table, uri, Bundle.EMPTY, null); + try (Cursor c = qb.query(helper, + new String[] { BaseColumns._ID }, null, null, null, null, null, null, null)) { + if (c.getCount() == 1) { + c.moveToFirst(); + final long cursorId = c.getLong(0); + + long uriId = -1; + try { + uriId = ContentUris.parseId(uri); + } catch (NumberFormatException ignored) { + // if the id is not a number, the uri doesn't have a valid ID at the end of + // the uri, (i.e., uri is uri of the table not of the item/row) + } + + if (uriId != -1 && cursorId == uriId) { + return PackageManager.PERMISSION_GRANTED; + } + } + } + + // For the uri with id cases, if it isn't returned in above query section, the result + // isn't as expected. Don't grant the permission. + switch (table) { + case AUDIO_MEDIA_ID: + case IMAGES_MEDIA_ID: + case VIDEO_MEDIA_ID: + case DOWNLOADS_ID: + case FILES_ID: + case AUDIO_MEDIA_ID_GENRES_ID: + case AUDIO_GENRES_ID: + case AUDIO_PLAYLISTS_ID: + case AUDIO_PLAYLISTS_ID_MEMBERS_ID: + case AUDIO_ARTISTS_ID: + case AUDIO_ALBUMS_ID: + return PackageManager.PERMISSION_DENIED; + default: + // continue below + } + + // If the uri is a valid content uri and doesn't have a valid ID at the end of the uri, + // (i.e., uri is uri of the table not of the item/row), and app doesn't request prefix + // grant, we are willing to grant this uri permission since this doesn't grant them any + // extra access. This grant will only grant permissions on given uri, it will not grant + // access to db rows of the corresponding table. + if ((modeFlags & Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) == 0) { + return PackageManager.PERMISSION_GRANTED; + } + } finally { + restoreLocalCallingIdentity(token); + } + return PackageManager.PERMISSION_DENIED; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + return query(uri, projection, + DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, sortOrder), null); + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, Bundle queryArgs, + CancellationSignal signal) { + return query(uri, projection, queryArgs, signal, /* forSelf */ false); + } + + private Cursor query(Uri uri, String[] projection, Bundle queryArgs, + CancellationSignal signal, boolean forSelf) { + Trace.beginSection(safeTraceSectionNameWithUri("query", uri)); + try { + return queryInternal(uri, projection, queryArgs, signal, forSelf); + } catch (FallbackException e) { + return e.translateForQuery(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + + private Cursor queryInternal(Uri uri, String[] projection, Bundle queryArgs, + CancellationSignal signal, boolean forSelf) throws FallbackException { + if (isPickerUri(uri)) { + return mPickerUriResolver.query(uri, projection, mCallingIdentity.get().pid, + mCallingIdentity.get().uid, mCallingIdentity.get().getPackageName()); + } + + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + queryArgs = (queryArgs != null) ? queryArgs : new Bundle(); + + // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. + queryArgs.remove(INCLUDED_DEFAULT_DIRECTORIES); + + final ArraySet honoredArgs = new ArraySet<>(); + DatabaseUtils.resolveQueryArgs(queryArgs, honoredArgs::add, this::ensureCustomCollator); + + Uri redactedUri = null; + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + queryArgs.remove(QUERY_ARG_REDACTED_URI); + if (isRedactedUri(uri)) { + redactedUri = uri; + uri = getUriForRedactedUri(uri); + queryArgs.putParcelable(QUERY_ARG_REDACTED_URI, redactedUri); + } + + uri = safeUncanonicalize(uri); + + final int targetSdkVersion = getCallingPackageTargetSdkVersion(); + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int table = matchUri(uri, allowHidden); + + if (table == MEDIA_GRANTS) { + return getReadGrantedMediaForPackage(queryArgs); + } + + // handle MEDIA_SCANNER before calling getDatabaseForUri() + if (table == MEDIA_SCANNER) { + // create a cursor to return volume currently being scanned by the media scanner + MatrixCursor c = new MatrixCursor(new String[] {MediaStore.MEDIA_SCANNER_VOLUME}); + c.addRow(new String[] {mMediaScannerVolume}); + return c; + } + + // Used temporarily (until we have unique media IDs) to get an identifier + // for the current sd card, so that the music app doesn't have to use the + // non-public getFatVolumeId method + if (table == FS_ID) { + MatrixCursor c = new MatrixCursor(new String[] {"fsid"}); + // current FAT volume ID + int volumeId = -1; + c.addRow(new Integer[] {volumeId}); + return c; + } + + if (table == VERSION) { + MatrixCursor c = new MatrixCursor(new String[] {"version"}); + c.addRow(new Integer[] {DatabaseHelper.getDatabaseVersion(getContext())}); + return c; + } + + // TODO(b/195008831): Add test to verify that apps can't access + if (table == PICKER_INTERNAL_MEDIA_ALL) { + return mPickerDataLayer.fetchAllMedia(queryArgs); + } else if (table == PICKER_INTERNAL_MEDIA_LOCAL) { + return mPickerDataLayer.fetchLocalMedia(queryArgs); + } else if (table == PICKER_INTERNAL_ALBUMS_ALL) { + return mPickerDataLayer.fetchAllAlbums(queryArgs); + } else if (table == PICKER_INTERNAL_ALBUMS_LOCAL) { + return mPickerDataLayer.fetchLocalAlbums(queryArgs); + } + + final DatabaseHelper helper = getDatabaseForUri(uri); + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, table, uri, queryArgs, + honoredArgs::add); + // Allowing hidden column _user_id for this query to support Cloned Profile use case. + if (table == FILES) { + qb.allowColumn(FileColumns._USER_ID); + } + + if (targetSdkVersion < Build.VERSION_CODES.R) { + // Some apps are abusing "ORDER BY" clauses to inject "LIMIT" + // clauses; gracefully lift them out. + DatabaseUtils.recoverAbusiveSortOrder(queryArgs); + + // Some apps are abusing the Uri query parameters to inject LIMIT + // clauses; gracefully lift them out. + DatabaseUtils.recoverAbusiveLimit(uri, queryArgs); + } + + if (targetSdkVersion < Build.VERSION_CODES.Q) { + // Some apps are abusing the "WHERE" clause by injecting "GROUP BY" + // clauses; gracefully lift them out. + DatabaseUtils.recoverAbusiveSelection(queryArgs); + + // Some apps are abusing the first column to inject "DISTINCT"; + // gracefully lift them out. + if ((projection != null) && (projection.length > 0) + && projection[0].startsWith("DISTINCT ")) { + projection[0] = projection[0].substring("DISTINCT ".length()); + qb.setDistinct(true); + } + + // Some apps are generating thumbnails with getThumbnail(), but then + // ignoring the returned Bitmap and querying the raw table; give + // them a row with enough information to find the original image. + final String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION); + if ((table == IMAGES_THUMBNAILS || table == VIDEO_THUMBNAILS) + && !TextUtils.isEmpty(selection)) { + final Matcher matcher = PATTERN_SELECTION_ID.matcher(selection); + if (matcher.matches()) { + final long id = Long.parseLong(matcher.group(1)); + + final Uri fullUri; + if (table == IMAGES_THUMBNAILS) { + fullUri = ContentUris.withAppendedId( + Images.Media.getContentUri(volumeName), id); + } else if (table == VIDEO_THUMBNAILS) { + fullUri = ContentUris.withAppendedId( + Video.Media.getContentUri(volumeName), id); + } else { + throw new IllegalArgumentException(); + } + + final MatrixCursor cursor = new MatrixCursor(projection); + final File file = ContentResolver.encodeToFile( + fullUri.buildUpon().appendPath("thumbnail").build()); + final String data = file.getAbsolutePath(); + cursor.newRow().add(MediaColumns._ID, null) + .add(Images.Thumbnails.IMAGE_ID, id) + .add(Video.Thumbnails.VIDEO_ID, id) + .add(MediaColumns.DATA, data); + return cursor; + } + } + } + + // Update locale if necessary. + if (helper.isInternal() && !Locale.getDefault().equals(mLastLocale)) { + Log.i(TAG, "Updating locale within queryInternal"); + onLocaleChanged(false); + } + + Cursor c; + + if (shouldFilterOwnerPackageNameFlag() + && shouldFilterOwnerPackageNameInProjection(qb, projection)) { + Log.i(TAG, String.format("Filtering owner package name for %s, projection: %s", + mCallingIdentity.get().getPackageName(), Arrays.toString(projection))); + + // Get a list of all owner_package_names in the result + final String[] ownerPackageNamesArr = getAllOwnerPackageNames(qb, helper, + queryArgs, signal); + + // Get a list of queryable owner_package_names out of all + final Set queryablePackages = getQueryablePackages(ownerPackageNamesArr); + + // Substitute owner_package_name column with following: + // CASE WHEN owner_package_name IN ('queryablePackageA','queryablePackageB') + // THEN owner_package_name ELSE NULL END AS owner_package_name + final String[] newProjection = prepareSubstitution(qb, projection, queryablePackages); + c = qb.query(helper, newProjection, queryArgs, signal); + } else { + c = qb.query(helper, projection, queryArgs, signal); + } + + if (c != null && !forSelf) { + // As a performance optimization, only configure notifications when + // resulting cursor will leave our process + final boolean callerIsRemote = mCallingIdentity.get().pid != android.os.Process.myPid(); + if (callerIsRemote && !isFuseThread()) { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + + final Bundle extras = new Bundle(); + extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, + honoredArgs.toArray(new String[honoredArgs.size()])); + c.setExtras(extras); + } + + // Query was on a redacted URI, update the sensitive information such as the _ID, DATA etc. + if (redactedUri != null && c != null) { + try { + return getRedactedUriCursor(redactedUri, c); + } finally { + c.close(); + } + } + + return c; + } + + /** + * Constructs the following projection string: + * CASE WHEN owner_package_name IN ("queryablePackageA","queryablePackageB") + * THEN owner_package_name ELSE NULL END AS owner_package_name + */ + private String constructOwnerPackageNameProjection(Set queryablePackages) { + final String packageNames = String.join(",", queryablePackages + .stream() + .map(name -> ("'" + name + "'")) + .collect(Collectors.toList())); + + final StringBuilder newProjection = new StringBuilder() + .append("CASE WHEN ") + .append(OWNER_PACKAGE_NAME) + .append(" IN (") + .append(packageNames) + .append(") THEN ") + .append(OWNER_PACKAGE_NAME) + .append(" ELSE NULL END AS ") + .append(OWNER_PACKAGE_NAME); + + Log.d(TAG, "Constructed owner_package_name substitution: " + newProjection); + return newProjection.toString(); + } + + private String[] getAllOwnerPackageNames(SQLiteQueryBuilder qb, DatabaseHelper helper, + Bundle queryArgs, CancellationSignal signal) { + final SQLiteQueryBuilder qbCopy = new SQLiteQueryBuilder(qb); + qbCopy.setDistinct(true); + qbCopy.appendWhereStandalone(OWNER_PACKAGE_NAME + " <> '' AND " + + OWNER_PACKAGE_NAME + " <> 'null' AND " + OWNER_PACKAGE_NAME + " IS NOT NULL"); + final Cursor ownerPackageNames = qbCopy.query(helper, new String[]{OWNER_PACKAGE_NAME}, + queryArgs, signal); + + final String[] ownerPackageNamesArr = new String[ownerPackageNames.getCount()]; + int i = 0; + while (ownerPackageNames.moveToNext()) { + ownerPackageNamesArr[i++] = ownerPackageNames.getString(0); + } + return ownerPackageNamesArr; + } + + private String[] prepareSubstitution(SQLiteQueryBuilder qb, + String[] projection, Set queryablePackages) { + projection = maybeReplaceNullProjection(projection, qb); + if (qb.getProjectionAllowlist() == null) { + qb.setProjectionAllowlist(new ArrayList<>()); + } + final String[] newProjection = new String[projection.length]; + for (int i = 0; i < projection.length; i++) { + if (!OWNER_PACKAGE_NAME.equalsIgnoreCase(projection[i])) { + newProjection[i] = projection[i]; + } else { + newProjection[i] = constructOwnerPackageNameProjection(queryablePackages); + // Allow constructed owner_package_name column in projection + final String escapedColumnCase = Pattern.quote(newProjection[i]); + qb.getProjectionAllowlist().add(Pattern.compile(escapedColumnCase)); + } + } + return newProjection; + } + + private String[] maybeReplaceNullProjection(String[] projection, SQLiteQueryBuilder qb) { + // List all columns instead of placing "*" in the SQL query + // to be able to substitute owner_package_name column + if (projection == null) { + projection = qb.getAllColumnsFromProjectionMap(); + // Allow all columns from the projection map + qb.setStrictColumns(false); + } + return projection; + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private Set getQueryablePackages(String[] packageNames) { + final boolean[] canBeQueriedInfo; + try { + canBeQueriedInfo = mPackageManager.canPackageQuery( + mCallingIdentity.get().getPackageName(), packageNames); + } catch (NameNotFoundException e) { + Log.e(TAG, "Invalid package name", e); + // If package manager throws an error, only assume calling package as queryable package + return new HashSet<>(Arrays.asList(mCallingIdentity.get().getPackageName())); + } + + final Set queryablePackages = new HashSet<>(); + for (int i = 0; i < packageNames.length; i++) { + if (canBeQueriedInfo[i]) { + queryablePackages.add(packageNames[i]); + } + } + return queryablePackages; + } + + @NotNull + private Cursor getReadGrantedMediaForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + String[] packageNames; + if (!checkPermissionSelf(caller)) { + // All other callers are unauthorized. + throw new SecurityException( + getSecurityExceptionMessage("read media grants")); + } + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + packageNames = pm.getPackagesForUid(packageUid); + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + String[] mimeTypes = extras.getStringArray(EXTRA_MIME_TYPE_SELECTION); + // Available volumes, to filter out any external storage that may be removed but the grants + // persisted. + String[] availableVolumes = mVolumeCache.getExternalVolumeNames().toArray(new String[0]); + return mMediaGrants.getMediaGrantsForPackages(packageNames, userId, mimeTypes, + availableVolumes); + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private boolean shouldFilterOwnerPackageNameInProjection(SQLiteQueryBuilder qb, + String[] projection) { + return projectionNeedsOwnerPackageFiltering(projection, qb) + && isApplicableForOwnerPackageNameFiltering(); + } + + private boolean isApplicableForOwnerPackageNameFiltering() { + return SdkLevel.isAtLeastU() + && getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + && !mCallingIdentity.get().checkCallingPermissionsOwnerPackageName(); + } + + private boolean projectionNeedsOwnerPackageFiltering(String[] proj, SQLiteQueryBuilder qb) { + return (proj != null && Arrays.asList(proj).contains(MediaColumns.OWNER_PACKAGE_NAME)) + || (proj == null && qb.getProjectionMap() != null + && qb.getProjectionMap().containsKey(OWNER_PACKAGE_NAME)); + } + + private boolean shouldFilterOwnerPackageNameFlag() { + return true; + } + + private boolean isUriSupportedForRedaction(Uri uri) { + final int match = matchUri(uri, true); + return REDACTED_URI_SUPPORTED_TYPES.contains(match); + } + + private Cursor getRedactedUriCursor(Uri redactedUri, @NonNull Cursor c) { + final HashSet columnNames = new HashSet<>(Arrays.asList(c.getColumnNames())); + final MatrixCursor redactedUriCursor = new MatrixCursor(c.getColumnNames()); + final String redactedUriId = redactedUri.getLastPathSegment(); + + if (!c.moveToFirst()) { + return redactedUriCursor; + } + + // NOTE: It is safe to assume that there will only be one entry corresponding to a + // redacted URI as it corresponds to a unique DB entry. + if (c.getCount() != 1) { + throw new AssertionError("Two rows corresponding to " + redactedUri.toString() + + " found, when only one expected"); + } + + final MatrixCursor.RowBuilder row = redactedUriCursor.newRow(); + for (String columnName : c.getColumnNames()) { + final int colIndex = c.getColumnIndex(columnName); + if (c.getType(colIndex) == FIELD_TYPE_BLOB) { + row.add(c.getBlob(colIndex)); + } else { + row.add(c.getString(colIndex)); + } + } + + String ext = getFileExtensionFromCursor(c, columnNames); + ext = ext == null ? "" : "." + ext; + final String displayName = redactedUriId + ext; + final String data = buildPrimaryVolumeFile(uidToUserId(Binder.getCallingUid()), + getRedactedRelativePath(), displayName).getAbsolutePath(); + + updateRow(columnNames, MediaColumns._ID, row, redactedUriId); + updateRow(columnNames, MediaColumns.DISPLAY_NAME, row, displayName); + updateRow(columnNames, MediaColumns.RELATIVE_PATH, row, getRedactedRelativePath()); + updateRow(columnNames, MediaColumns.BUCKET_DISPLAY_NAME, row, getRedactedRelativePath()); + updateRow(columnNames, MediaColumns.DATA, row, data); + updateRow(columnNames, MediaColumns.DOCUMENT_ID, row, null); + updateRow(columnNames, MediaColumns.INSTANCE_ID, row, null); + updateRow(columnNames, MediaColumns.BUCKET_ID, row, null); + + return redactedUriCursor; + } + + @Nullable + private static String getFileExtensionFromCursor(@NonNull Cursor c, + @NonNull HashSet columnNames) { + if (columnNames.contains(MediaColumns.DATA)) { + return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DATA))); + } + if (columnNames.contains(MediaColumns.DISPLAY_NAME)) { + return extractFileExtension(c.getString(c.getColumnIndex(MediaColumns.DISPLAY_NAME))); + } + return null; + } + + private void updateRow(HashSet columnNames, String columnName, + MatrixCursor.RowBuilder row, Object val) { + if (columnNames.contains(columnName)) { + row.add(columnName, val); + } + } + + private Uri getUriForRedactedUri(Uri redactedUri) { + final Uri.Builder builder = redactedUri.buildUpon(); + builder.path(null); + final List segments = redactedUri.getPathSegments(); + for (int i = 0; i < segments.size() - 1; i++) { + builder.appendPath(segments.get(i)); + } + + DatabaseHelper helper; + try { + helper = getDatabaseForUri(redactedUri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", new String[]{MediaColumns._ID}, + FileColumns.REDACTED_URI_ID + "=?", + new String[]{redactedUri.getLastPathSegment()}, null, null, null))) { + if (!c.moveToFirst()) { + throw new IllegalArgumentException( + "Uri: " + redactedUri.toString() + " not found."); + } + + builder.appendPath(c.getString(0)); + return builder.build(); + } + } + + private boolean isRedactedUri(Uri uri) { + String id = uri.getLastPathSegment(); + return id != null && id.startsWith(REDACTED_URI_ID_PREFIX) + && id.length() == REDACTED_URI_ID_SIZE; + } + + @Override + public String getType(Uri url) { + final int match = matchUri(url, true); + switch (match) { + case IMAGES_MEDIA_ID: + case AUDIO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: + case AUDIO_PLAYLISTS_ID_MEMBERS_ID: + case VIDEO_MEDIA_ID: + case DOWNLOADS_ID: + case FILES_ID: + if (SdkLevel.isAtLeastU()) { + // Starting Android 14, there is permission check for + // getting types requiring internal query. + return queryForTypeAsCaller(url); + } else { + return queryForTypeAsSelf(url); + } + + case IMAGES_MEDIA: + case IMAGES_THUMBNAILS: + return Images.Media.CONTENT_TYPE; + + case AUDIO_ALBUMART_ID: + case AUDIO_ALBUMART_FILE_ID: + case IMAGES_THUMBNAILS_ID: + case VIDEO_THUMBNAILS_ID: + return "image/jpeg"; + + case AUDIO_MEDIA: + case AUDIO_GENRES_ID_MEMBERS: + case AUDIO_PLAYLISTS_ID_MEMBERS: + return Audio.Media.CONTENT_TYPE; + + case AUDIO_GENRES: + case AUDIO_MEDIA_ID_GENRES: + return Audio.Genres.CONTENT_TYPE; + case AUDIO_GENRES_ID: + case AUDIO_MEDIA_ID_GENRES_ID: + return Audio.Genres.ENTRY_CONTENT_TYPE; + case AUDIO_PLAYLISTS: + return Audio.Playlists.CONTENT_TYPE; + + case VIDEO_MEDIA: + return Video.Media.CONTENT_TYPE; + case DOWNLOADS: + return Downloads.CONTENT_TYPE; + + case PICKER_ID: + case PICKER_GET_CONTENT_ID: + return mPickerUriResolver.getType(url, Binder.getCallingPid(), + Binder.getCallingUid()); + } + throw new IllegalStateException("Unknown URL : " + url); + } + + private String queryForTypeAsSelf(Uri url) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + return queryForTypeAsCaller(url); + } finally { + restoreLocalCallingIdentity(token); + } + } + + private String queryForTypeAsCaller(Uri url) { + try (Cursor cursor = queryForSingleItem(url, + new String[] { MediaColumns.MIME_TYPE }, null, null, null)) { + return cursor.getString(0); + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e.getMessage()); + } + } + + @VisibleForTesting + void ensureFileColumns(@NonNull Uri uri, @NonNull ContentValues values) + throws VolumeArgumentException, VolumeNotFoundException { + final LocalUriMatcher matcher = new LocalUriMatcher(MediaStore.AUTHORITY); + final int match = matcher.matchUri(uri, true); + ensureNonUniqueFileColumns(match, uri, Bundle.EMPTY, values, null /* currentPath */); + } + + private void ensureUniqueFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras, + @NonNull ContentValues values, @Nullable String currentPath) + throws VolumeArgumentException, VolumeNotFoundException { + ensureFileColumns(match, uri, extras, values, true, currentPath); + } + + private void ensureNonUniqueFileColumns(int match, @NonNull Uri uri, + @NonNull Bundle extras, @NonNull ContentValues values, @Nullable String currentPath) + throws VolumeArgumentException, VolumeNotFoundException { + ensureFileColumns(match, uri, extras, values, false, currentPath); + } + + /** + * Get the various file-related {@link MediaColumns} in the given + * {@link ContentValues} into a consistent condition. Also validates that defined + * columns are valid for the given {@link Uri}, such as ensuring that only + * {@code image/*} can be inserted into + * {@link android.provider.MediaStore.Images}. + */ + private void ensureFileColumns(int match, @NonNull Uri uri, @NonNull Bundle extras, + @NonNull ContentValues values, boolean makeUnique, @Nullable String currentPath) + throws VolumeArgumentException, VolumeNotFoundException { + Trace.beginSection("MP.ensureFileColumns"); + + Objects.requireNonNull(uri); + Objects.requireNonNull(extras); + Objects.requireNonNull(values); + + // Figure out defaults based on Uri being modified + String defaultMimeType = ClipDescription.MIMETYPE_UNKNOWN; + int defaultMediaType = FileColumns.MEDIA_TYPE_NONE; + String defaultPrimary = Environment.DIRECTORY_DOWNLOADS; + String defaultSecondary = null; + List allowedPrimary = Arrays.asList( + Environment.DIRECTORY_DOWNLOADS, + Environment.DIRECTORY_DOCUMENTS); + switch (match) { + case AUDIO_MEDIA: + case AUDIO_MEDIA_ID: + defaultMimeType = "audio/mpeg"; + defaultMediaType = FileColumns.MEDIA_TYPE_AUDIO; + defaultPrimary = Environment.DIRECTORY_MUSIC; + if (SdkLevel.isAtLeastS()) { + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_ALARMS, + Environment.DIRECTORY_AUDIOBOOKS, + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_PODCASTS, + Environment.DIRECTORY_RECORDINGS, + Environment.DIRECTORY_RINGTONES); + } else { + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_ALARMS, + Environment.DIRECTORY_AUDIOBOOKS, + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_NOTIFICATIONS, + Environment.DIRECTORY_PODCASTS, + FileUtils.DIRECTORY_RECORDINGS, + Environment.DIRECTORY_RINGTONES); + } + break; + case VIDEO_MEDIA: + case VIDEO_MEDIA_ID: + defaultMimeType = "video/mp4"; + defaultMediaType = FileColumns.MEDIA_TYPE_VIDEO; + defaultPrimary = Environment.DIRECTORY_MOVIES; + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_DCIM, + Environment.DIRECTORY_MOVIES, + Environment.DIRECTORY_PICTURES); + break; + case IMAGES_MEDIA: + case IMAGES_MEDIA_ID: + defaultMimeType = "image/jpeg"; + defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE; + defaultPrimary = Environment.DIRECTORY_PICTURES; + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_DCIM, + Environment.DIRECTORY_PICTURES); + break; + case AUDIO_ALBUMART: + case AUDIO_ALBUMART_ID: + defaultMimeType = "image/jpeg"; + defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE; + defaultPrimary = Environment.DIRECTORY_MUSIC; + allowedPrimary = Collections.singletonList(defaultPrimary); + defaultSecondary = DIRECTORY_THUMBNAILS; + break; + case VIDEO_THUMBNAILS: + case VIDEO_THUMBNAILS_ID: + defaultMimeType = "image/jpeg"; + defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE; + defaultPrimary = Environment.DIRECTORY_MOVIES; + allowedPrimary = Collections.singletonList(defaultPrimary); + defaultSecondary = DIRECTORY_THUMBNAILS; + break; + case IMAGES_THUMBNAILS: + case IMAGES_THUMBNAILS_ID: + defaultMimeType = "image/jpeg"; + defaultMediaType = FileColumns.MEDIA_TYPE_IMAGE; + defaultPrimary = Environment.DIRECTORY_PICTURES; + allowedPrimary = Collections.singletonList(defaultPrimary); + defaultSecondary = DIRECTORY_THUMBNAILS; + break; + case AUDIO_PLAYLISTS: + case AUDIO_PLAYLISTS_ID: + defaultMimeType = "audio/mpegurl"; + defaultMediaType = FileColumns.MEDIA_TYPE_PLAYLIST; + defaultPrimary = Environment.DIRECTORY_MUSIC; + allowedPrimary = Arrays.asList( + Environment.DIRECTORY_MUSIC, + Environment.DIRECTORY_MOVIES); + break; + case DOWNLOADS: + case DOWNLOADS_ID: + defaultPrimary = Environment.DIRECTORY_DOWNLOADS; + allowedPrimary = Collections.singletonList(defaultPrimary); + break; + case FILES: + case FILES_ID: + // Use defaults above + break; + default: + Log.w(TAG, "Unhandled location " + uri + "; assuming generic files"); + break; + } + + final String resolvedVolumeName = resolveVolumeName(uri); + + if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA)) + && MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName)) { + // TODO: promote this to top-level check + throw new UnsupportedOperationException( + "Writing to internal storage is not supported."); + } + + // Force values when raw path provided + if (!TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) { + FileUtils.computeValuesFromData(values, isFuseThread()); + } + + final boolean isTargetSdkROrHigher = + getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R; + final String displayName = values.getAsString(MediaColumns.DISPLAY_NAME); + final String mimeTypeFromExt = TextUtils.isEmpty(displayName) ? null : + MimeUtils.resolveMimeType(new File(displayName)); + + if (TextUtils.isEmpty(values.getAsString(MediaColumns.MIME_TYPE))) { + if (isTargetSdkROrHigher) { + // Extract the MIME type from the display name if we couldn't resolve it from the + // raw path + if (mimeTypeFromExt != null) { + values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt); + } else { + // We couldn't resolve mimeType, it means that both display name and MIME type + // were missing in values, so we use defaultMimeType. + values.put(MediaColumns.MIME_TYPE, defaultMimeType); + } + } else if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) { + values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt); + } else { + // We don't use mimeTypeFromExt to preserve legacy behavior. + values.put(MediaColumns.MIME_TYPE, defaultMimeType); + } + } + + String mimeType = values.getAsString(MediaColumns.MIME_TYPE); + if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) { + // We allow any mimeType for generic uri with default media type as MEDIA_TYPE_NONE. + } else if (mimeType != null && + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) == null) { + if (mimeTypeFromExt != null && + defaultMediaType == MimeUtils.resolveMediaType(mimeTypeFromExt)) { + // If mimeType from extension matches the defaultMediaType of uri, we use mimeType + // from file extension as mimeType. This is an effort to guess the mimeType when we + // get unsupported mimeType. + // Note: We can't force defaultMimeType because when we force defaultMimeType, we + // will force the file extension as well. For example, if DISPLAY_NAME=Foo.png and + // mimeType="image/*". If we force mimeType to be "image/jpeg", we append the file + // name with the new file extension i.e., "Foo.png.jpg" where as the expected file + // name was "Foo.png" + values.put(MediaColumns.MIME_TYPE, mimeTypeFromExt); + } else if (isTargetSdkROrHigher) { + // We are here because given mimeType is unsupported also we couldn't guess valid + // mimeType from file extension. + throw new IllegalArgumentException("Unsupported MIME type " + mimeType); + } else { + // We can't throw error for legacy apps, so we try to use defaultMimeType. + values.put(MediaColumns.MIME_TYPE, defaultMimeType); + } + } + + // Give ourselves reasonable defaults when missing + if (TextUtils.isEmpty(values.getAsString(MediaColumns.DISPLAY_NAME))) { + values.put(MediaColumns.DISPLAY_NAME, + String.valueOf(System.currentTimeMillis())); + } + final Integer formatObject = values.getAsInteger(FileColumns.FORMAT); + final int format = formatObject == null ? 0 : formatObject; + if (format == MtpConstants.FORMAT_ASSOCIATION) { + values.putNull(MediaColumns.MIME_TYPE); + } + + mimeType = values.getAsString(MediaColumns.MIME_TYPE); + // Quick check MIME type against table + if (mimeType != null) { + PulledMetrics.logMimeTypeAccess(getCallingUidOrSelf(), mimeType); + final int actualMediaType = MimeUtils.resolveMediaType(mimeType); + if (defaultMediaType == FileColumns.MEDIA_TYPE_NONE) { + // Give callers an opportunity to work with playlists and + // subtitles using the generic files table + switch (actualMediaType) { + case FileColumns.MEDIA_TYPE_PLAYLIST: + defaultMimeType = "audio/mpegurl"; + defaultMediaType = FileColumns.MEDIA_TYPE_PLAYLIST; + defaultPrimary = Environment.DIRECTORY_MUSIC; + allowedPrimary = new ArrayList<>(allowedPrimary); + allowedPrimary.add(Environment.DIRECTORY_MUSIC); + allowedPrimary.add(Environment.DIRECTORY_MOVIES); + break; + case FileColumns.MEDIA_TYPE_SUBTITLE: + defaultMimeType = "application/x-subrip"; + defaultMediaType = FileColumns.MEDIA_TYPE_SUBTITLE; + defaultPrimary = Environment.DIRECTORY_MOVIES; + allowedPrimary = new ArrayList<>(allowedPrimary); + allowedPrimary.add(Environment.DIRECTORY_MUSIC); + allowedPrimary.add(Environment.DIRECTORY_MOVIES); + break; + } + } else if (defaultMediaType != actualMediaType) { + final String[] split = defaultMimeType.split("/"); + throw new IllegalArgumentException( + "MIME type " + mimeType + " cannot be inserted into " + uri + + "; expected MIME type under " + split[0] + "/*"); + } + } + + // Use default directories when missing + if (TextUtils.isEmpty(values.getAsString(MediaColumns.RELATIVE_PATH))) { + if (defaultSecondary != null) { + values.put(MediaColumns.RELATIVE_PATH, + defaultPrimary + '/' + defaultSecondary + '/'); + } else { + values.put(MediaColumns.RELATIVE_PATH, + defaultPrimary + '/'); + } + } + + // Generate path when undefined + if (TextUtils.isEmpty(values.getAsString(MediaColumns.DATA))) { + // Note that just the volume name isn't enough to determine the path, + // since we can manage different volumes with the same name for + // different users. Instead, if we have a current path (which implies + // an already existing file to be renamed), use that to derive the + // user-id of the file, and in turn use that to derive the correct + // volume. Cross-user renames are not supported without a specified + // DATA column. + File volumePath; + UserHandle userHandle = mCallingIdentity.get().getUser(); + Integer userIdFromPathObject = values.getAsInteger(FileColumns._USER_ID); + int userIdFromPath = (userIdFromPathObject == null ? userHandle.getIdentifier() : + userIdFromPathObject); + // In case if the _user_id column is set, and is different from the userHandle + // determined from mCallingIdentity, we prefer the former, as it comes from the original + // path provided to MP process. + // Normally this does not create any issues, but when cloned profile is active, an app + // in root user can try to create an image file in lower file system, by specifying + // the file directory as /storage/emulated//DCIM. For such cases, we + // would want to be used to determine path in MP entry. + if (userHandle.getIdentifier() != userIdFromPath + && isAppCloneUserPair(userHandle.getIdentifier(), userIdFromPath)) { + userHandle = UserHandle.of(userIdFromPath); + } + if (currentPath != null) { + int userId = FileUtils.extractUserId(currentPath); + if (userId != -1) { + userHandle = UserHandle.of(userId); + } + } + try { + volumePath = mVolumeCache.getVolumePath(resolvedVolumeName, userHandle); + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); + } + + FileUtils.sanitizeValues(values, /*rewriteHiddenFileName*/ !isFuseThread()); + FileUtils.computeDataFromValues(values, volumePath, isFuseThread()); + assertFileColumnsConsistent(match, uri, values); + + // Create result file + File res = new File(values.getAsString(MediaColumns.DATA)); + try { + if (makeUnique) { + res = FileUtils.buildUniqueFile(res.getParentFile(), + mimeType, res.getName()); + } else { + res = FileUtils.buildNonUniqueFile(res.getParentFile(), + mimeType, res.getName()); + } + } catch (FileNotFoundException e) { + throw new IllegalStateException( + "Failed to build unique file: " + res + " " + values); + } + + // Require that content lives under well-defined directories to help + // keep the user's content organized + + // Start by saying unchanged directories are valid + final String currentDir = (currentPath != null) + ? new File(currentPath).getParent() : null; + boolean validPath = res.getParent().equals(currentDir); + + // Next, consider allowing based on allowed primary directory + final String[] relativePath = values.getAsString(MediaColumns.RELATIVE_PATH).split("/"); + final String primary = extractTopLevelDir(relativePath); + if (!validPath) { + validPath = containsIgnoreCase(allowedPrimary, primary); + } + + // Next, consider allowing paths when referencing a related item + final Uri relatedUri = extras.getParcelable(QUERY_ARG_RELATED_URI); + if (!validPath && relatedUri != null) { + try (Cursor c = queryForSingleItem(relatedUri, new String[] { + MediaColumns.MIME_TYPE, + MediaColumns.RELATIVE_PATH, + }, null, null, null)) { + // If top-level MIME type matches, and relative path + // matches, then allow caller to place things here + + final String expectedType = MimeUtils.extractPrimaryType( + c.getString(0)); + final String actualType = MimeUtils.extractPrimaryType( + values.getAsString(MediaColumns.MIME_TYPE)); + if (!Objects.equals(expectedType, actualType)) { + throw new IllegalArgumentException("Placement of " + actualType + + " item not allowed in relation to " + expectedType + " item"); + } + + final String expectedPath = c.getString(1); + final String actualPath = values.getAsString(MediaColumns.RELATIVE_PATH); + if (!Objects.equals(expectedPath, actualPath)) { + throw new IllegalArgumentException("Placement of " + actualPath + + " item not allowed in relation to " + expectedPath + " item"); + } + + // If we didn't see any trouble above, then we'll allow it + validPath = true; + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find related item " + relatedUri + ": " + e); + } + } + + // Consider allowing external media directory of calling package + if (!validPath) { + final String pathOwnerPackage = extractPathOwnerPackageName(res.getAbsolutePath()); + if (pathOwnerPackage != null) { + validPath = isExternalMediaDirectory(res.getAbsolutePath()) && + isCallingIdentitySharedPackageName(pathOwnerPackage); + } + } + + // Allow apps with MANAGE_EXTERNAL_STORAGE to create files anywhere + if (!validPath) { + validPath = isCallingPackageManager(); + } + + // Allow system gallery to create image/video files. + if (!validPath) { + // System gallery can create image/video files in any existing directory, it can + // also create subdirectories in any existing top-level directory. However, system + // gallery is not allowed to create non-default top level directory. + final boolean createNonDefaultTopLevelDir = primary != null && + !FileUtils.buildPath(volumePath, primary).exists(); + validPath = !createNonDefaultTopLevelDir && canSystemGalleryAccessTheFile( + res.getAbsolutePath()); + } + + // Nothing left to check; caller can't use this path + if (!validPath) { + throw new IllegalArgumentException( + "Primary directory " + primary + " not allowed for " + uri + + "; allowed directories are " + allowedPrimary); + } + + boolean isFuseThread = isFuseThread(); + // Check if the following are true: + // 1. Not a FUSE thread + // 2. |res| is a child of a default dir and the default dir is missing + // If true, we want to update the mTime of the volume root, after creating the dir + // on the lower filesystem. This fixes some FileManagers relying on the mTime change + // for UI updates + File defaultDirVolumePath = + isFuseThread ? null : checkDefaultDirMissing(resolvedVolumeName, res); + // Ensure all parent folders of result file exist + res.getParentFile().mkdirs(); + if (!res.getParentFile().exists()) { + throw new IllegalStateException("Failed to create directory: " + res); + } + touchFusePath(defaultDirVolumePath); + + values.put(MediaColumns.DATA, res.getAbsolutePath()); + // buildFile may have changed the file name, compute values to extract new DISPLAY_NAME. + // Note: We can't extract displayName from res.getPath() because for pending & trashed + // files DISPLAY_NAME will not be same as file name. + FileUtils.computeValuesFromData(values, isFuseThread); + } else { + assertFileColumnsConsistent(match, uri, values); + } + + assertPrivatePathNotInValues(values); + + // Drop columns that aren't relevant for special tables + switch (match) { + case AUDIO_ALBUMART: + case VIDEO_THUMBNAILS: + case IMAGES_THUMBNAILS: + final Set valid = getProjectionMap(MediaStore.Images.Thumbnails.class) + .keySet(); + for (String key : new ArraySet<>(values.keySet())) { + if (!valid.contains(key)) { + values.remove(key); + } + } + break; + } + + Trace.endSection(); + } + + /** + * For apps targetSdk >= S: Check that values does not contain any external private path. + * For all apps: Check that values does not contain any other app's external private paths. + */ + private void assertPrivatePathNotInValues(ContentValues values) + throws IllegalArgumentException { + ArrayList relativePaths = new ArrayList(); + relativePaths.add(extractRelativePath(values.getAsString(MediaColumns.DATA))); + relativePaths.add(values.getAsString(MediaColumns.RELATIVE_PATH)); + + for (final String relativePath : relativePaths) { + if (!isDataOrObbRelativePath(relativePath)) { + continue; + } + + /** + * Don't allow apps to insert/update database row to files in Android/data or + * Android/obb dirs. These are app private directories and files in these private + * directories can't be added to public media collection. + * + * Note: For backwards compatibility we allow apps with targetSdk < S to insert private + * files to MediaProvider + */ + if (CompatChanges.isChangeEnabled(ENABLE_CHECKS_FOR_PRIVATE_FILES, + Binder.getCallingUid())) { + throw new IllegalArgumentException( + "Inserting private file: " + relativePath + " is not allowed."); + } + + /** + * Restrict all (legacy and non-legacy) apps from inserting paths in other + * app's private directories. + * Allow legacy apps to insert/update files in app private directories for backward + * compatibility but don't allow them to do so in other app's private directories. + */ + if (!isCallingIdentityAllowedAccessToDataOrObbPath(relativePath)) { + throw new IllegalArgumentException( + "Inserting private file: " + relativePath + " is not allowed."); + } + } + } + + /** + * @return the default dir if {@code file} is a child of default dir and it's missing, + * {@code null} otherwise. + */ + private File checkDefaultDirMissing(String volumeName, File file) { + String topLevelDir = FileUtils.extractTopLevelDir(file.getPath()); + if (topLevelDir != null && FileUtils.isDefaultDirectoryName(topLevelDir)) { + try { + File volumePath = getVolumePath(volumeName); + if (!new File(volumePath, topLevelDir).exists()) { + return volumePath; + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to checkDefaultDirMissing for " + file, e); + } + } + return null; + } + + /** Updates mTime of {@code path} on the FUSE filesystem */ + private void touchFusePath(@Nullable File path) { + if (path != null) { + // Touch root of volume to update mTime on FUSE filesystem + // This allows FileManagers that may be relying on mTime changes to update their UI + File fusePath = toFuseFile(path); + Log.i(TAG, "Touching FUSE path " + fusePath); + fusePath.setLastModified(System.currentTimeMillis()); + } + } + + /** + * Check that any requested {@link MediaColumns#DATA} paths actually + * live on the storage volume being targeted. + */ + private void assertFileColumnsConsistent(int match, Uri uri, ContentValues values) + throws VolumeArgumentException, VolumeNotFoundException { + if (!values.containsKey(MediaColumns.DATA)) return; + + final String volumeName = resolveVolumeName(uri); + try { + // Quick check that the requested path actually lives on volume + final Collection allowed = getAllowedVolumePaths(volumeName); + final File actual = new File(values.getAsString(MediaColumns.DATA)) + .getCanonicalFile(); + if (!FileUtils.contains(allowed, actual)) { + throw new VolumeArgumentException(actual, allowed); + } + } catch (IOException e) { + throw new VolumeNotFoundException(volumeName); + } + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) { + final int targetSdkVersion = getCallingPackageTargetSdkVersion(); + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + + if (match == VOLUMES) { + return super.bulkInsert(uri, values); + } + + if (match == AUDIO_PLAYLISTS_ID || match == AUDIO_PLAYLISTS_ID_MEMBERS) { + final String resolvedVolumeName = resolveVolumeName(uri); + + final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri playlistUri = ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId); + + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + + // Require that caller has write access to underlying media + enforceCallingPermission(playlistUri, Bundle.EMPTY, true); + for (ContentValues each : values) { + final long audioId = each.getAsLong(Audio.Playlists.Members.AUDIO_ID); + final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId); + enforceCallingPermission(audioUri, Bundle.EMPTY, false); + } + + return bulkInsertPlaylist(playlistUri, values); + } + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + return e.translateForUpdateDelete(targetSdkVersion); + } + + helper.beginTransaction(); + try { + final int result = super.bulkInsert(uri, values); + helper.setTransactionSuccessful(); + return result; + } finally { + helper.endTransaction(); + } + } + + private int bulkInsertPlaylist(@NonNull Uri uri, @NonNull ContentValues[] values) { + Trace.beginSection("MP.bulkInsertPlaylist"); + try { + try { + return addPlaylistMembers(uri, values); + } catch (SQLiteConstraintException e) { + if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) { + throw e; + } else { + return 0; + } + } + } catch (FallbackException e) { + return e.translateForBulkInsert(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + + private long insertDirectory(@NonNull SQLiteDatabase db, @NonNull String path) { + if (LOGV) Log.v(TAG, "inserting directory " + path); + ContentValues values = new ContentValues(); + values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION); + values.put(FileColumns.DATA, path); + values.put(FileColumns.PARENT, getParent(db, path)); + values.put(FileColumns.OWNER_PACKAGE_NAME, extractPathOwnerPackageName(path)); + values.put(FileColumns.VOLUME_NAME, extractVolumeName(path)); + values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path)); + values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path)); + values.put(FileColumns.IS_DOWNLOAD, isDownload(path) ? 1 : 0); + + // Getting UserId from the directory path, as clone user shares the MediaProvider + // of user 0. + int userIdFromPath = FileUtils.extractUserId(path); + // In some cases, like querying public volumes, userId is not available in path. We + // take userId from the user running MediaProvider process (sUserId). + if (userIdFromPath != -1) { + if (isAppCloneUserForFuse(userIdFromPath)) { + values.put(FileColumns._USER_ID, userIdFromPath); + } else { + values.put(FileColumns._USER_ID, sUserId); + } + } + + File file = new File(path); + if (file.exists()) { + values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000); + } + return db.insert("files", FileColumns.DATE_MODIFIED, values); + } + + private long getParent(@NonNull SQLiteDatabase db, @NonNull String path) { + final String parentPath = new File(path).getParent(); + if (Objects.equals("/", parentPath)) { + return -1; + } else { + synchronized (mDirectoryCache) { + Long id = mDirectoryCache.get(parentPath); + if (id != null) { + return id; + } + } + + final long id; + try (Cursor c = db.query("files", new String[] { FileColumns._ID }, + FileColumns.DATA + "=?", new String[] { parentPath }, null, null, null)) { + if (c.moveToFirst()) { + id = c.getLong(0); + } else { + id = insertDirectory(db, parentPath); + } + } + + synchronized (mDirectoryCache) { + mDirectoryCache.put(parentPath, id); + } + return id; + } + } + + /** + * @param c the Cursor whose title to retrieve + * @return the result of {@link #getDefaultTitle(String)} if the result is valid; otherwise + * the value of the {@code MediaStore.Audio.Media.TITLE} column + */ + private String getDefaultTitleFromCursor(Cursor c) { + String title = null; + final int columnIndex = c.getColumnIndex("title_resource_uri"); + // Necessary to check for existence because we may be reading from an old DB version + if (columnIndex > -1) { + final String titleResourceUri = c.getString(columnIndex); + if (titleResourceUri != null) { + try { + title = getDefaultTitle(titleResourceUri); + } catch (Exception e) { + // Best attempt only + } + } + } + if (title == null) { + title = c.getString(c.getColumnIndex(MediaStore.Audio.Media.TITLE)); + } + return title; + } + + /** + * @param title_resource_uri The title resource for which to retrieve the default localization + * @return The title localized to {@code Locale.US}, or {@code null} if unlocalizable + * @throws Exception Thrown if the title appears to be localizable, but the localization failed + * for any reason. For example, the application from which the localized title is fetched is not + * installed, or it does not have the resource which needs to be localized + */ + private String getDefaultTitle(String title_resource_uri) throws Exception{ + try { + return getTitleFromResourceUri(title_resource_uri, false); + } catch (Exception e) { + Log.e(TAG, "Error getting default title for " + title_resource_uri, e); + throw e; + } + } + + /** + * @param title_resource_uri The title resource to localize + * @return The localized title, or {@code null} if unlocalizable + * @throws Exception Thrown if the title appears to be localizable, but the localization failed + * for any reason. For example, the application from which the localized title is fetched is not + * installed, or it does not have the resource which needs to be localized + */ + private String getLocalizedTitle(String title_resource_uri) throws Exception { + try { + return getTitleFromResourceUri(title_resource_uri, true); + } catch (Exception e) { + Log.e(TAG, "Error getting localized title for " + title_resource_uri, e); + throw e; + } + } + + /** + * Localizable titles conform to this URI pattern: + * Scheme: {@link ContentResolver.SCHEME_ANDROID_RESOURCE} + * Authority: Package Name of ringtone title provider + * First Path Segment: Type of resource (must be "string") + * Second Path Segment: Resource name of title + * + * @param title_resource_uri The title resource to retrieve + * @param localize Whether or not to localize the title + * @return The title, or {@code null} if unlocalizable + * @throws Exception Thrown if the title appears to be localizable, but the localization failed + * for any reason. For example, the application from which the localized title is fetched is not + * installed, or it does not have the resource which needs to be localized + */ + private String getTitleFromResourceUri(String title_resource_uri, boolean localize) + throws Exception { + if (TextUtils.isEmpty(title_resource_uri)) { + return null; + } + final Uri titleUri = Uri.parse(title_resource_uri); + final String scheme = titleUri.getScheme(); + if (!ContentResolver.SCHEME_ANDROID_RESOURCE.equals(scheme)) { + return null; + } + final List pathSegments = titleUri.getPathSegments(); + if (pathSegments.size() != 2) { + Log.e(TAG, "Error getting localized title for " + title_resource_uri + + ", must have 2 path segments"); + return null; + } + final String type = pathSegments.get(0); + if (!"string".equals(type)) { + Log.e(TAG, "Error getting localized title for " + title_resource_uri + + ", first path segment must be \"string\""); + return null; + } + final String packageName = titleUri.getAuthority(); + final Resources resources; + if (localize) { + resources = mPackageManager.getResourcesForApplication(packageName); + } else { + final Context packageContext = getContext().createPackageContext(packageName, 0); + final Configuration configuration = packageContext.getResources().getConfiguration(); + configuration.setLocale(Locale.US); + resources = packageContext.createConfigurationContext(configuration).getResources(); + } + final String resourceIdentifier = pathSegments.get(1); + final int id = resources.getIdentifier(resourceIdentifier, type, packageName); + return resources.getString(id); + } + + public void onLocaleChanged() { + onLocaleChanged(true); + } + + private void onLocaleChanged(boolean forceUpdate) { + mInternalDatabase.runWithTransaction((db) -> { + if (forceUpdate || !mLastLocale.equals(Locale.getDefault())) { + localizeTitles(db); + mLastLocale = Locale.getDefault(); + } + return null; + }); + } + + private void localizeTitles(@NonNull SQLiteDatabase db) { + try (Cursor c = db.query("files", new String[]{"_id", "title_resource_uri"}, + "title_resource_uri IS NOT NULL", null, null, null, null)) { + while (c.moveToNext()) { + final String id = c.getString(0); + final String titleResourceUri = c.getString(1); + final ContentValues values = new ContentValues(); + try { + values.put(AudioColumns.TITLE_RESOURCE_URI, titleResourceUri); + computeAudioLocalizedValues(values); + computeAudioKeyValues(values); + db.update("files", values, "_id=?", new String[]{id}); + } catch (Exception e) { + Log.e(TAG, "Error updating localized title for " + titleResourceUri + + ", keeping old localization"); + } + } + } + } + + private Uri insertFile(@NonNull SQLiteQueryBuilder qb, @NonNull DatabaseHelper helper, + int match, @NonNull Uri uri, @NonNull Bundle extras, @NonNull ContentValues values, + int mediaType) throws VolumeArgumentException, VolumeNotFoundException { + boolean wasPathEmpty = !values.containsKey(MediaStore.MediaColumns.DATA) + || TextUtils.isEmpty(values.getAsString(MediaStore.MediaColumns.DATA)); + + // Make sure all file-related columns are defined + ensureUniqueFileColumns(match, uri, extras, values, null); + + switch (mediaType) { + case FileColumns.MEDIA_TYPE_AUDIO: { + computeAudioLocalizedValues(values); + computeAudioKeyValues(values); + break; + } + } + + // compute bucket_id and bucket_display_name for all files + String path = values.getAsString(MediaStore.MediaColumns.DATA); + FileUtils.computeValuesFromData(values, isFuseThread()); + values.put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000); + + String title = values.getAsString(MediaStore.MediaColumns.TITLE); + if (title == null && path != null) { + title = extractFileName(path); + } + values.put(FileColumns.TITLE, title); + + String mimeType = null; + int format = MtpConstants.FORMAT_ASSOCIATION; + if (path != null && new File(path).isDirectory()) { + values.put(FileColumns.FORMAT, MtpConstants.FORMAT_ASSOCIATION); + values.putNull(MediaStore.MediaColumns.MIME_TYPE); + } else { + mimeType = values.getAsString(MediaStore.MediaColumns.MIME_TYPE); + final Integer formatObject = values.getAsInteger(FileColumns.FORMAT); + format = (formatObject == null ? 0 : formatObject); + } + + if (format == 0) { + format = MimeUtils.resolveFormatCode(mimeType); + } + if (path != null && path.endsWith("/")) { + // TODO: convert to using FallbackException once VERSION_CODES.S is defined + Log.e(TAG, "directory has trailing slash: " + path); + return null; + } + if (format != 0) { + values.put(FileColumns.FORMAT, format); + } + + if (mimeType == null && path != null && format != MtpConstants.FORMAT_ASSOCIATION) { + mimeType = MimeUtils.resolveMimeType(new File(path)); + } + + if (mimeType != null) { + values.put(FileColumns.MIME_TYPE, mimeType); + if (isCallingPackageSelf() && values.containsKey(FileColumns.MEDIA_TYPE)) { + // Leave FileColumns.MEDIA_TYPE untouched if the caller is ModernMediaScanner and + // FileColumns.MEDIA_TYPE is already populated. + } else if (isFuseThread() && path != null + && FileUtils.shouldFileBeHidden(new File(path))) { + // We should only mark MEDIA_TYPE as MEDIA_TYPE_NONE for Fuse Thread. + // MediaProvider#insert() returns the uri by appending the "rowId" to the given + // uri, hence to ensure the correct working of the returned uri, we shouldn't + // change the MEDIA_TYPE in insert operation and let scan change it for us. + values.put(FileColumns.MEDIA_TYPE, FileColumns.MEDIA_TYPE_NONE); + } else { + values.put(FileColumns.MEDIA_TYPE, MimeUtils.resolveMediaType(mimeType)); + } + } else { + values.put(FileColumns.MEDIA_TYPE, mediaType); + } + + qb.allowColumn(FileColumns._MODIFIER); + if (isCallingPackageSelf() && values.containsKey(FileColumns._MODIFIER)) { + // We can't identify if the call is coming from media scan, hence + // we let ModernMediaScanner send FileColumns._MODIFIER value. + } else if (isFuseThread()) { + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_FUSE); + } else { + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR); + } + + // There is no meaning of an owner in the internal storage. It is shared by all users. + // So we only set the user_id field in the database for external storage. + qb.allowColumn(FileColumns._USER_ID); + int ownerUserId = FileUtils.extractUserId(path); + if (helper.isExternal()) { + if (isAppCloneUserForFuse(ownerUserId)) { + values.put(FileColumns._USER_ID, ownerUserId); + } else { + values.put(FileColumns._USER_ID, sUserId); + } + } + + final long rowId; + Uri newUri = uri; + { + if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { + String name = values.getAsString(Audio.Playlists.NAME); + if (name == null && path == null) { + // MediaScanner will compute the name from the path if we have one + throw new IllegalArgumentException( + "no name was provided when inserting abstract playlist"); + } + } else { + if (path == null) { + // path might be null for playlists created on the device + // or transfered via MTP + throw new IllegalArgumentException( + "no path was provided when inserting new file"); + } + } + + // make sure modification date and size are set + if (path != null) { + File file = new File(path); + if (file.exists()) { + values.put(FileColumns.DATE_MODIFIED, file.lastModified() / 1000); + if (!values.containsKey(FileColumns.SIZE)) { + values.put(FileColumns.SIZE, file.length()); + } + } + // Checking if the file/directory is hidden can be expensive based on the depth of + // the directory tree. Call shouldFileBeHidden() only when the caller of insert() + // cares about returned uri. + if (!isCallingPackageSelf() && !isFuseThread() + && FileUtils.shouldFileBeHidden(file)) { + newUri = MediaStore.Files.getContentUri(MediaStore.getVolumeName(uri)); + } + } + + rowId = insertAllowingUpsert(qb, helper, values, path); + } + if (format == MtpConstants.FORMAT_ASSOCIATION) { + synchronized (mDirectoryCache) { + mDirectoryCache.put(path, rowId); + } + } + + return ContentUris.withAppendedId(newUri, rowId); + } + + /** + * Inserts a new row in MediaProvider database with {@code values}. Treats insert as upsert for + * double inserts from same package. + */ + private long insertAllowingUpsert(@NonNull SQLiteQueryBuilder qb, + @NonNull DatabaseHelper helper, @NonNull ContentValues values, String path) + throws SQLiteConstraintException { + return helper.runWithTransaction((db) -> { + Long parent = values.getAsLong(FileColumns.PARENT); + if (parent == null) { + if (path != null) { + final long parentId = getParent(db, path); + values.put(FileColumns.PARENT, parentId); + } + } + + try { + return qb.insert(helper, values); + } catch (SQLiteConstraintException e) { + final String packages = getAllowedPackagesForUpsert( + values.getAsString(MediaColumns.OWNER_PACKAGE_NAME)); + SQLiteQueryBuilder qbForUpsert = getQueryBuilderForUpsert(path); + final long rowId = getIdIfPathOwnedByPackages(qbForUpsert, helper, path, packages); + // Apps sometimes create a file via direct path and then insert it into + // MediaStore via ContentResolver. The former should create a database entry, + // so we have to treat the latter as an upsert. + // TODO(b/149917493) Perform all INSERT operations as UPSERT. + if (rowId != -1 && qbForUpsert.update(helper, values, "_id=?", + new String[]{Long.toString(rowId)}) == 1) { + return rowId; + } + // Rethrow SQLiteConstraintException on failed upsert. + throw e; + } + }); + } + + /** + * @return row id of the entry with path {@code path} if the owner is one of {@code packages}. + */ + private long getIdIfPathOwnedByPackages(@NonNull SQLiteQueryBuilder qb, + @NonNull DatabaseHelper helper, String path, String packages) { + final String[] projection = new String[] {FileColumns._ID}; + final String ownerPackageMatchClause = DatabaseUtils.bindSelection( + MediaColumns.OWNER_PACKAGE_NAME + " IN " + packages); + final String selection = FileColumns.DATA + " =? AND " + ownerPackageMatchClause; + + try (Cursor c = qb.query(helper, projection, selection, new String[] {path}, null, null, + null, null, null)) { + if (c.moveToFirst()) { + return c.getLong(0); + } + } + return -1; + } + + /** + * Gets packages that should match to upsert a db row. + * + * A database row can be upserted if + *
          + *
        • Calling package or one of the shared packages owns the db row. + *
        • {@code givenOwnerPackage} owns the db row. This is useful when DownloadProvider + * requests upsert on behalf of another app + *
        + */ + private String getAllowedPackagesForUpsert(@Nullable String givenOwnerPackage) { + ArrayList packages = new ArrayList<>( + Arrays.asList(mCallingIdentity.get().getSharedPackageNamesArray())); + + // If givenOwnerPackage is CallingIdentity, packages list would already have shared package + // names of givenOwnerPackage. If givenOwnerPackage is not CallingIdentity, since + // DownloadProvider can upsert a row on behalf of app, we should include all shared packages + // of givenOwnerPackage. + if (givenOwnerPackage != null && isCallingPackageDelegator() && + !isCallingIdentitySharedPackageName(givenOwnerPackage)) { + // Allow DownloadProvider to Upsert if givenOwnerPackage is owner of the db row. + packages.addAll(Arrays.asList(getSharedPackagesForPackage(givenOwnerPackage))); + } + return bindList((Object[]) packages.toArray()); + } + + /** + * @return {@link SQLiteQueryBuilder} for upsert with Files uri. This disables strict columns + * check to allow upsert to update any column with Files uri. + */ + private SQLiteQueryBuilder getQueryBuilderForUpsert(@NonNull String path) { + final boolean allowHidden = isCallingPackageAllowedHidden(); + Bundle extras = new Bundle(); + extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_INCLUDE); + extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_INCLUDE); + + // When Fuse inserts a file to database it doesn't set is_download column. When app tries + // insert with Downloads uri, upsert fails because getIdIfPathExistsForCallingPackage can't + // find a row ID with is_download=1. Use Files uri to get queryBuilder & update any existing + // row irrespective of is_download=1. + final Uri uri = FileUtils.getContentUriForPath(path); + SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, matchUri(uri, allowHidden), uri, + extras, null); + + // We won't be able to update columns that are not part of projection map of Files table. We + // have already checked strict columns in previous insert operation which failed with + // exception. Any malicious column usage would have got caught in insert operation, hence we + // can safely disable strict column check for upsert. + qb.setStrictColumns(false); + return qb; + } + + private void maybePut(@NonNull ContentValues values, @NonNull String key, + @Nullable String value) { + if (value != null) { + values.put(key, value); + } + } + + private boolean maybeMarkAsDownload(@NonNull ContentValues values) { + final String path = values.getAsString(MediaColumns.DATA); + if (path != null && isDownload(path)) { + values.put(FileColumns.IS_DOWNLOAD, 1); + return true; + } + return false; + } + + @NonNull + private static String resolveVolumeName(@NonNull Uri uri) { + final String volumeName = getVolumeName(uri); + if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { + return MediaStore.VOLUME_EXTERNAL_PRIMARY; + } else { + return volumeName; + } + } + + /** + * @deprecated all operations should be routed through the overload that + * accepts a {@link Bundle} of extras. + */ + @Override + @Deprecated + public Uri insert(Uri uri, ContentValues values) { + return insert(uri, values, null); + } + + @Override + @Nullable + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values, + @Nullable Bundle extras) { + Trace.beginSection(safeTraceSectionNameWithUri("insert", uri)); + try { + try { + return insertInternal(uri, values, extras); + } catch (SQLiteConstraintException e) { + if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) { + throw e; + } else { + return null; + } + } + } catch (FallbackException e) { + return e.translateForInsert(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + + @Nullable + private Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues, + @Nullable Bundle extras) throws FallbackException { + final String originalVolumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), originalVolumeName); + + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); + + // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. + extras.remove(INCLUDED_DEFAULT_DIRECTORIES); + + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + + final String resolvedVolumeName = resolveVolumeName(uri); + + // handle MEDIA_SCANNER before calling getDatabaseForUri() + if (match == MEDIA_SCANNER) { + mMediaScannerVolume = initialValues.getAsString(MediaStore.MEDIA_SCANNER_VOLUME); + + final DatabaseHelper helper = getDatabaseForUri( + MediaStore.Files.getContentUri(mMediaScannerVolume)); + + helper.mScanStartTime = SystemClock.elapsedRealtime(); + return MediaStore.getMediaScannerUri(); + } + + if (match == VOLUMES) { + String name = initialValues.getAsString("name"); + MediaVolume volume = null; + try { + volume = getVolume(name); + Uri attachedVolume = attachVolume(volume, /* validate */ true, /* volumeState */ + null); + if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) { + final DatabaseHelper helper = getDatabaseForUri( + MediaStore.Files.getContentUri(mMediaScannerVolume)); + helper.mScanStartTime = SystemClock.elapsedRealtime(); + } + return attachedVolume; + } catch (FileNotFoundException e) { + Log.w(TAG, "Couldn't find volume with name " + volume.getName()); + return null; + } + } + + final DatabaseHelper helper = getDatabaseForUri(uri); + switch (match) { + case AUDIO_PLAYLISTS_ID: + case AUDIO_PLAYLISTS_ID_MEMBERS: { + final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri playlistUri = ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(resolvedVolumeName), playlistId); + + final long audioId = initialValues + .getAsLong(MediaStore.Audio.Playlists.Members.AUDIO_ID); + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(resolvedVolumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + final Uri audioUri = ContentUris.withAppendedId( + MediaStore.Audio.Media.getContentUri(audioVolumeName), audioId); + + // Require that caller has write access to underlying media + enforceCallingPermission(playlistUri, Bundle.EMPTY, true); + enforceCallingPermission(audioUri, Bundle.EMPTY, false); + + // Playlist contents are always persisted directly into playlist + // files on disk to ensure that we can reliably migrate between + // devices and recover from database corruption + final long id = addPlaylistMembers(playlistUri, initialValues); + acceptWithExpansion(helper::notifyInsert, resolvedVolumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); + return ContentUris.withAppendedId(MediaStore.Audio.Playlists.Members + .getContentUri(originalVolumeName, playlistId), id); + } + } + + String path = null; + String ownerPackageName = null; + if (initialValues != null) { + // IDs are forever; nobody should be editing them + initialValues.remove(MediaColumns._ID); + + // Expiration times are hard-coded; let's derive them + FileUtils.computeDateExpires(initialValues); + + // Ignore or augment incoming raw filesystem paths + for (String column : sDataColumns.keySet()) { + if (!initialValues.containsKey(column)) continue; + + if (isCallingPackageSelf() || isCallingPackageLegacyWrite()) { + // Mutation allowed + } else if (isCallingPackageManager()) { + // Apps with MANAGE_EXTERNAL_STORAGE have all files access, hence they are + // allowed to insert files anywhere. + } else if (getCallingPackageTargetSdkVersion() >= + Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Throwing an exception so that it doesn't result in some unexpected + // behavior for apps and make them aware of what is happening. + throw new IllegalArgumentException("Mutation of " + column + + " is not allowed."); + } else { + Log.w(TAG, "Ignoring mutation of " + column + " from " + + getCallingPackageOrSelf()); + initialValues.remove(column); + } + } + + path = initialValues.getAsString(MediaStore.MediaColumns.DATA); + + if (!isCallingPackageSelf()) { + initialValues.remove(FileColumns.IS_DOWNLOAD); + } + + // We no longer track location metadata + if (initialValues.containsKey(ImageColumns.LATITUDE)) { + initialValues.putNull(ImageColumns.LATITUDE); + } + if (initialValues.containsKey(ImageColumns.LONGITUDE)) { + initialValues.putNull(ImageColumns.LONGITUDE); + } + if (getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) { + // These columns are removed in R. + if (initialValues.containsKey("primary_directory")) { + initialValues.remove("primary_directory"); + } + if (initialValues.containsKey("secondary_directory")) { + initialValues.remove("secondary_directory"); + } + } + + if (isCallingPackageSelf() || isCallingPackageShell()) { + // When media inserted by ourselves during a scan, or by the + // shell, the best we can do is guess ownership based on path + // when it's not explicitly provided + ownerPackageName = initialValues.getAsString(FileColumns.OWNER_PACKAGE_NAME); + if (TextUtils.isEmpty(ownerPackageName)) { + ownerPackageName = extractPathOwnerPackageName(path); + } + } else if (isCallingPackageDelegator()) { + // When caller is a delegator, we handle ownership as a hybrid + // of the two other cases: we're willing to accept any ownership + // transfer attempted during insert, but we fall back to using + // the Binder identity if they don't request a specific owner + ownerPackageName = initialValues.getAsString(FileColumns.OWNER_PACKAGE_NAME); + if (TextUtils.isEmpty(ownerPackageName)) { + ownerPackageName = getCallingPackageOrSelf(); + } + } else { + // Remote callers have no direct control over owner column; we force + // it be whoever is creating the content. + initialValues.remove(FileColumns.OWNER_PACKAGE_NAME); + ownerPackageName = getCallingPackageOrSelf(); + } + } + + long rowId = -1; + Uri newUri = null; + + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_INSERT, match, uri, extras, null); + + switch (match) { + case IMAGES_MEDIA: { + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + final boolean isDownload = maybeMarkAsDownload(initialValues); + newUri = insertFile(qb, helper, match, uri, extras, initialValues, + FileColumns.MEDIA_TYPE_IMAGE); + break; + } + + case IMAGES_THUMBNAILS: { + if (helper.isInternal()) { + throw new UnsupportedOperationException( + "Writing to internal storage is not supported."); + } + + // Require that caller has write access to underlying media + final long imageId = initialValues.getAsLong(MediaStore.Images.Thumbnails.IMAGE_ID); + enforceCallingPermission(ContentUris.withAppendedId( + MediaStore.Images.Media.getContentUri(resolvedVolumeName), imageId), + extras, true); + + ensureUniqueFileColumns(match, uri, extras, initialValues, null); + + rowId = qb.insert(helper, initialValues); + if (rowId > 0) { + newUri = ContentUris.withAppendedId(Images.Thumbnails. + getContentUri(originalVolumeName), rowId); + } + break; + } + + case VIDEO_THUMBNAILS: { + if (helper.isInternal()) { + throw new UnsupportedOperationException( + "Writing to internal storage is not supported."); + } + + // Require that caller has write access to underlying media + final long videoId = initialValues.getAsLong(MediaStore.Video.Thumbnails.VIDEO_ID); + enforceCallingPermission(ContentUris.withAppendedId( + MediaStore.Video.Media.getContentUri(resolvedVolumeName), videoId), + Bundle.EMPTY, true); + + ensureUniqueFileColumns(match, uri, extras, initialValues, null); + + rowId = qb.insert(helper, initialValues); + if (rowId > 0) { + newUri = ContentUris.withAppendedId(Video.Thumbnails. + getContentUri(originalVolumeName), rowId); + } + break; + } + + case AUDIO_MEDIA: { + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + final boolean isDownload = maybeMarkAsDownload(initialValues); + newUri = insertFile(qb, helper, match, uri, extras, initialValues, + FileColumns.MEDIA_TYPE_AUDIO); + break; + } + + case AUDIO_MEDIA_ID_GENRES: { + throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R); + } + + case AUDIO_GENRES: { + throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R); + } + + case AUDIO_GENRES_ID_MEMBERS: { + throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R); + } + + case AUDIO_PLAYLISTS: { + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + final boolean isDownload = maybeMarkAsDownload(initialValues); + ContentValues values = new ContentValues(initialValues); + values.put(MediaStore.Audio.Playlists.DATE_ADDED, System.currentTimeMillis() / 1000); + // Playlist names are stored as display names, but leave + // values untouched if the caller is ModernMediaScanner + if (!isCallingPackageSelf()) { + if (values.containsKey(Playlists.NAME)) { + values.put(MediaColumns.DISPLAY_NAME, values.getAsString(Playlists.NAME)); + } + if (!values.containsKey(MediaColumns.MIME_TYPE)) { + values.put(MediaColumns.MIME_TYPE, "audio/mpegurl"); + } + } + newUri = insertFile(qb, helper, match, uri, extras, values, + FileColumns.MEDIA_TYPE_PLAYLIST); + if (newUri != null) { + // Touch empty playlist file on disk so its ready for renames + if (Binder.getCallingUid() != android.os.Process.myUid()) { + try (OutputStream out = ContentResolver.wrap(this) + .openOutputStream(newUri)) { + } catch (IOException ignored) { + } + } + } + break; + } + + case VIDEO_MEDIA: { + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + final boolean isDownload = maybeMarkAsDownload(initialValues); + newUri = insertFile(qb, helper, match, uri, extras, initialValues, + FileColumns.MEDIA_TYPE_VIDEO); + break; + } + + case AUDIO_ALBUMART: { + if (helper.isInternal()) { + throw new UnsupportedOperationException("no internal album art allowed"); + } + + ensureUniqueFileColumns(match, uri, extras, initialValues, null); + + rowId = qb.insert(helper, initialValues); + if (rowId > 0) { + newUri = ContentUris.withAppendedId(uri, rowId); + } + break; + } + + case FILES: { + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + final boolean isDownload = maybeMarkAsDownload(initialValues); + final String mimeType = initialValues.getAsString(MediaColumns.MIME_TYPE); + final int mediaType = MimeUtils.resolveMediaType(mimeType); + newUri = insertFile(qb, helper, match, uri, extras, initialValues, + mediaType); + break; + } + + case DOWNLOADS: + maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName); + initialValues.put(FileColumns.IS_DOWNLOAD, 1); + newUri = insertFile(qb, helper, match, uri, extras, initialValues, + FileColumns.MEDIA_TYPE_NONE); + break; + + default: + throw new UnsupportedOperationException("Invalid URI " + uri); + } + + // Remember that caller is owner of this item, to speed up future + // permission checks for this caller + mCallingIdentity.get().setOwned(rowId, true); + + if (path != null && path.toLowerCase(Locale.ROOT).endsWith("/.nomedia")) { + scanFileAsMediaProvider(new File(path).getParentFile(), REASON_DEMAND); + } + + return newUri; + } + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) + throws OperationApplicationException { + // Open transactions on databases for requested volumes + final Set transactions = new ArraySet<>(); + try { + for (ContentProviderOperation op : operations) { + final DatabaseHelper helper = getDatabaseForUri(op.getUri()); + if (transactions.contains(helper)) continue; + + if (!helper.isTransactionActive()) { + helper.beginTransaction(); + transactions.add(helper); + } else { + // We normally don't allow nested transactions (since we + // don't have a good way to selectively roll them back) but + // if the incoming operation is ignoring exceptions, then we + // don't need to worry about partial rollback and can + // piggyback on the larger active transaction + if (!op.isExceptionAllowed()) { + throw new IllegalStateException("Nested transactions not supported"); + } + } + } + + final ContentProviderResult[] result = super.applyBatch(operations); + for (DatabaseHelper helper : transactions) { + helper.setTransactionSuccessful(); + } + return result; + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } finally { + for (DatabaseHelper helper : transactions) { + helper.endTransaction(); + } + } + } + + private void appendWhereStandaloneMatch(@NonNull SQLiteQueryBuilder qb, + @NonNull String column, /* @Match */ int match, Uri uri) { + switch (match) { + case MATCH_INCLUDE: + // No special filtering needed + break; + case MATCH_EXCLUDE: + appendWhereStandalone(qb, getWhereClauseForMatchExclude(column)); + break; + case MATCH_ONLY: + appendWhereStandalone(qb, column + "=?", 1); + break; + case MATCH_VISIBLE_FOR_FILEPATH: + final String whereClause = + getWhereClauseForMatchableVisibleFromFilePath(uri, column); + if (whereClause != null) { + appendWhereStandalone(qb, whereClause); + } + break; + default: + throw new IllegalArgumentException(); + } + } + + private static void appendWhereStandalone(@NonNull SQLiteQueryBuilder qb, + @Nullable String selection, @Nullable Object... selectionArgs) { + qb.appendWhereStandalone(DatabaseUtils.bindSelection(selection, selectionArgs)); + } + + private static void appendWhereStandaloneFilter(@NonNull SQLiteQueryBuilder qb, + @NonNull String[] columns, @Nullable String filter) { + if (TextUtils.isEmpty(filter)) return; + for (String filterWord : filter.split("\\s+")) { + appendWhereStandalone(qb, String.join("||", columns) + " LIKE ? ESCAPE '\\'", + "%" + DatabaseUtils.escapeForLike(Audio.keyFor(filterWord)) + "%"); + } + } + + /** + * Gets {@link LocalCallingIdentity} for the calling package + * TODO(b/170465810) Change the method name after refactoring. + */ + LocalCallingIdentity getCachedCallingIdentityForTranscoding(int uid) { + return getCachedCallingIdentityForFuse(uid); + } + + /** + * Gets shared packages names for given {@code packageName} + */ + private String[] getSharedPackagesForPackage(String packageName) { + try { + final int packageUid = getContext().getPackageManager() + .getPackageUid(packageName, 0); + return getContext().getPackageManager().getPackagesForUid(packageUid); + } catch (NameNotFoundException ignored) { + return new String[] {packageName}; + } + } + + private static final int TYPE_QUERY = 0; + private static final int TYPE_INSERT = 1; + private static final int TYPE_UPDATE = 2; + private static final int TYPE_DELETE = 3; + + /** + * Creating a new method for Transcoding to avoid any merge conflicts. + * TODO(b/170465810): Remove this when getQueryBuilder code is refactored. + */ + @NonNull SQLiteQueryBuilder getQueryBuilderForTranscoding(int type, int match, + @NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer honored) { + // Force MediaProvider calling identity when accessing the db from transcoding to avoid + // generating 'strict' SQL e.g forcing owner_package_name matches + // We already handle the required permission checks for the app before we get here + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + return getQueryBuilder(type, match, uri, extras, honored); + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** + * Generate a {@link SQLiteQueryBuilder} that is filtered based on the + * runtime permissions and/or {@link Uri} grants held by the caller. + *
          + *
        • If caller holds a {@link Uri} grant, access is allowed according to + * that grant. + *
        • If caller holds the write permission for a collection, they can + * read/write all contents of that collection. + *
        • If caller holds the read permission for a collection, they can read + * all contents of that collection, but writes are limited to content they + * own. + *
        • If caller holds no permissions for a collection, all reads/write are + * limited to content they own. + *
        + */ + private @NonNull SQLiteQueryBuilder getQueryBuilder(int type, int match, + @NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer honored) { + Trace.beginSection("MP.getQueryBuilder"); + try { + return getQueryBuilderInternal(type, match, uri, extras, honored); + } finally { + Trace.endSection(); + } + } + + private @NonNull SQLiteQueryBuilder getQueryBuilderInternal(int type, int match, + @NonNull Uri uri, @NonNull Bundle extras, @Nullable Consumer honored) { + final boolean forWrite; + switch (type) { + case TYPE_QUERY: forWrite = false; break; + case TYPE_INSERT: forWrite = true; break; + case TYPE_UPDATE: forWrite = true; break; + case TYPE_DELETE: forWrite = true; break; + default: throw new IllegalStateException(); + } + + if (forWrite) { + final Uri redactedUri = extras.getParcelable(QUERY_ARG_REDACTED_URI); + if (redactedUri != null) { + throw new UnsupportedOperationException( + "Writes on: " + redactedUri.toString() + " are not supported"); + } + } + + final SQLiteQueryBuilder qb = new SQLiteQueryBuilder(); + if (uri.getBooleanQueryParameter("distinct", false)) { + qb.setDistinct(true); + } + qb.setStrict(true); + if (isCallingPackageSelf()) { + // When caller is system, such as the media scanner, we're willing + // to let them access any columns they want + } else { + qb.setTargetSdkVersion(getCallingPackageTargetSdkVersion()); + qb.setStrictColumns(true); + qb.setStrictGrammar(true); + } + + // TODO: throw when requesting a currently unmounted volume + final String volumeName = MediaStore.getVolumeName(uri); + final String includeVolumes; + if (MediaStore.VOLUME_EXTERNAL.equals(volumeName)) { + includeVolumes = bindList(mVolumeCache.getExternalVolumeNames().toArray()); + } else { + includeVolumes = bindList(volumeName); + } + + int matchPending = extras.getInt(QUERY_ARG_MATCH_PENDING, MATCH_DEFAULT); + int matchTrashed = extras.getInt(QUERY_ARG_MATCH_TRASHED, MATCH_DEFAULT); + int matchFavorite = extras.getInt(QUERY_ARG_MATCH_FAVORITE, MATCH_DEFAULT); + + + // Handle callers using legacy arguments + if (MediaStore.getIncludePending(uri)) matchPending = MATCH_INCLUDE; + + // Resolve any remaining default options + final int defaultMatchForPendingAndTrashed; + if (isFuseThread()) { + // Write operations always check for file ownership, we don't need additional write + // permission check for is_pending and is_trashed. + defaultMatchForPendingAndTrashed = + forWrite ? MATCH_INCLUDE : MATCH_VISIBLE_FOR_FILEPATH; + } else { + defaultMatchForPendingAndTrashed = MATCH_EXCLUDE; + } + if (matchPending == MATCH_DEFAULT) matchPending = defaultMatchForPendingAndTrashed; + if (matchTrashed == MATCH_DEFAULT) matchTrashed = defaultMatchForPendingAndTrashed; + if (matchFavorite == MATCH_DEFAULT) matchFavorite = MATCH_INCLUDE; + + // Handle callers using legacy filtering + final String filter = uri.getQueryParameter("filter"); + + // Only accept ALL_VOLUMES parameter up until R, because we're not convinced we want + // to commit to this as an API. + final boolean includeAllVolumes = shouldIncludeRecentlyUnmountedVolumes(uri, extras); + + appendAccessCheckQuery(qb, forWrite, uri, match, extras, volumeName); + + switch (match) { + case IMAGES_MEDIA_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case IMAGES_MEDIA: { + if (type == TYPE_QUERY) { + qb.setTables("images"); + qb.setProjectionMap( + getProjectionMap(Images.Media.class)); + } else { + qb.setTables("files"); + qb.setProjectionMap( + getProjectionMap(Images.Media.class, Files.FileColumns.class)); + appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", + FileColumns.MEDIA_TYPE_IMAGE); + } + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case IMAGES_THUMBNAILS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + // fall-through + case IMAGES_THUMBNAILS: { + qb.setTables("thumbnails"); + + final ArrayMap projectionMap = new ArrayMap<>( + getProjectionMap(Images.Thumbnails.class)); + projectionMap.put(Images.Thumbnails.THUMB_DATA, + "NULL AS " + Images.Thumbnails.THUMB_DATA); + qb.setProjectionMap(projectionMap); + + break; + } + case AUDIO_MEDIA_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case AUDIO_MEDIA: { + if (type == TYPE_QUERY) { + qb.setTables("audio"); + qb.setProjectionMap( + getProjectionMap(Audio.Media.class)); + } else { + qb.setTables("files"); + qb.setProjectionMap( + getProjectionMap(Audio.Media.class, Files.FileColumns.class)); + appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", + FileColumns.MEDIA_TYPE_AUDIO); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY + }, filter); + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case AUDIO_MEDIA_ID_GENRES_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(5)); + // fall-through + case AUDIO_MEDIA_ID_GENRES: { + if (type == TYPE_QUERY) { + qb.setTables("audio_genres"); + qb.setProjectionMap(getProjectionMap(Audio.Genres.class)); + } else { + throw new UnsupportedOperationException("Genres cannot be directly modified"); + } + appendWhereStandalone(qb, "_id IN (SELECT genre_id FROM " + + "audio WHERE _id=?)", uri.getPathSegments().get(3)); + break; + } + case AUDIO_GENRES_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + // fall-through + case AUDIO_GENRES: { + qb.setTables("audio_genres"); + qb.setProjectionMap(getProjectionMap(Audio.Genres.class)); + break; + } + case AUDIO_GENRES_ID_MEMBERS: + appendWhereStandalone(qb, "genre_id=?", uri.getPathSegments().get(3)); + // fall-through + case AUDIO_GENRES_ALL_MEMBERS: { + if (type == TYPE_QUERY) { + qb.setTables("audio"); + + final ArrayMap projectionMap = new ArrayMap<>( + getProjectionMap(Audio.Genres.Members.class)); + projectionMap.put(Audio.Genres.Members.AUDIO_ID, + "_id AS " + Audio.Genres.Members.AUDIO_ID); + qb.setProjectionMap(projectionMap); + } else { + throw new UnsupportedOperationException("Genres cannot be directly modified"); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY + }, filter); + // In order to be consistent with other audio views like audio_artist, audio_albums, + // and audio_genres, exclude pending and trashed item + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, MATCH_EXCLUDE, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, MATCH_EXCLUDE, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case AUDIO_PLAYLISTS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case AUDIO_PLAYLISTS: { + if (type == TYPE_QUERY) { + qb.setTables("audio_playlists"); + qb.setProjectionMap( + getProjectionMap(Audio.Playlists.class)); + } else { + qb.setTables("files"); + qb.setProjectionMap( + getProjectionMap(Audio.Playlists.class, Files.FileColumns.class)); + appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", + FileColumns.MEDIA_TYPE_PLAYLIST); + } + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case AUDIO_PLAYLISTS_ID_MEMBERS_ID: + appendWhereStandalone(qb, "audio_playlists_map._id=?", + uri.getPathSegments().get(5)); + // fall-through + case AUDIO_PLAYLISTS_ID_MEMBERS: { + appendWhereStandalone(qb, "playlist_id=?", uri.getPathSegments().get(3)); + if (type == TYPE_QUERY) { + qb.setTables("audio_playlists_map, audio"); + + final ArrayMap projectionMap = new ArrayMap<>( + getProjectionMap(Audio.Playlists.Members.class)); + projectionMap.put(Audio.Playlists.Members._ID, + "audio_playlists_map._id AS " + Audio.Playlists.Members._ID); + qb.setProjectionMap(projectionMap); + + appendWhereStandalone(qb, "audio._id = audio_id"); + // Since we use audio table along with audio_playlists_map + // for querying, we should only include database rows of + // the attached volumes. + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + + includeVolumes); + } + } else { + qb.setTables("audio_playlists_map"); + qb.setProjectionMap(getProjectionMap(Audio.Playlists.Members.class)); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY + }, filter); + break; + } + case AUDIO_ALBUMART_ID: + appendWhereStandalone(qb, "album_id=?", uri.getPathSegments().get(3)); + // fall-through + case AUDIO_ALBUMART: { + qb.setTables("album_art"); + + final ArrayMap projectionMap = new ArrayMap<>( + getProjectionMap(Audio.Thumbnails.class)); + projectionMap.put(Audio.Thumbnails._ID, + "album_id AS " + Audio.Thumbnails._ID); + qb.setProjectionMap(projectionMap); + + break; + } + case AUDIO_ARTISTS_ID_ALBUMS: { + if (type == TYPE_QUERY) { + qb.setTables("audio_artists_albums"); + qb.setProjectionMap(getProjectionMap(Audio.Artists.Albums.class)); + + final String artistId = uri.getPathSegments().get(3); + appendWhereStandalone(qb, "artist_id=?", artistId); + } else { + throw new UnsupportedOperationException("Albums cannot be directly modified"); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ALBUM_KEY + }, filter); + break; + } + case AUDIO_ARTISTS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + // fall-through + case AUDIO_ARTISTS: { + if (type == TYPE_QUERY) { + qb.setTables("audio_artists"); + qb.setProjectionMap(getProjectionMap(Audio.Artists.class)); + } else { + throw new UnsupportedOperationException("Artists cannot be directly modified"); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY + }, filter); + break; + } + case AUDIO_ALBUMS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + // fall-through + case AUDIO_ALBUMS: { + if (type == TYPE_QUERY) { + qb.setTables("audio_albums"); + qb.setProjectionMap(getProjectionMap(Audio.Albums.class)); + } else { + throw new UnsupportedOperationException("Albums cannot be directly modified"); + } + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY + }, filter); + break; + } + case VIDEO_MEDIA_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case VIDEO_MEDIA: { + if (type == TYPE_QUERY) { + qb.setTables("video"); + qb.setProjectionMap( + getProjectionMap(Video.Media.class)); + } else { + qb.setTables("files"); + qb.setProjectionMap( + getProjectionMap(Video.Media.class, Files.FileColumns.class)); + appendWhereStandalone(qb, FileColumns.MEDIA_TYPE + "=?", + FileColumns.MEDIA_TYPE_VIDEO); + } + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case VIDEO_THUMBNAILS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(3)); + // fall-through + case VIDEO_THUMBNAILS: { + qb.setTables("videothumbnails"); + qb.setProjectionMap(getProjectionMap(Video.Thumbnails.class)); + break; + } + case FILES_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case FILES: { + qb.setTables("files"); + qb.setProjectionMap(getProjectionMap(Files.FileColumns.class)); + + appendWhereStandaloneFilter(qb, new String[] { + AudioColumns.ARTIST_KEY, AudioColumns.ALBUM_KEY, AudioColumns.TITLE_KEY + }, filter); + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + case DOWNLOADS_ID: + appendWhereStandalone(qb, "_id=?", uri.getPathSegments().get(2)); + matchPending = MATCH_INCLUDE; + matchTrashed = MATCH_INCLUDE; + // fall-through + case DOWNLOADS: { + if (type == TYPE_QUERY) { + qb.setTables("downloads"); + qb.setProjectionMap( + getProjectionMap(Downloads.class)); + } else { + qb.setTables("files"); + qb.setProjectionMap( + getProjectionMap(Downloads.class, Files.FileColumns.class)); + appendWhereStandalone(qb, FileColumns.IS_DOWNLOAD + "=1"); + } + + appendWhereStandaloneMatch(qb, FileColumns.IS_PENDING, matchPending, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_TRASHED, matchTrashed, uri); + appendWhereStandaloneMatch(qb, FileColumns.IS_FAVORITE, matchFavorite, uri); + if (honored != null) { + honored.accept(QUERY_ARG_MATCH_PENDING); + honored.accept(QUERY_ARG_MATCH_TRASHED); + honored.accept(QUERY_ARG_MATCH_FAVORITE); + } + if (!includeAllVolumes) { + appendWhereStandalone(qb, FileColumns.VOLUME_NAME + " IN " + includeVolumes); + } + break; + } + default: + throw new UnsupportedOperationException( + "Unknown or unsupported URL: " + uri.toString()); + } + + // To ensure we're enforcing our security model, all operations must + // have a projection map configured + if (qb.getProjectionMap() == null) { + throw new IllegalStateException("All queries must have a projection map"); + } + + // If caller is an older app, we're willing to let through a + // allowlist of technically invalid columns + if (getCallingPackageTargetSdkVersion() < Build.VERSION_CODES.Q) { + qb.setProjectionAllowlist(sAllowlist); + } + + // Starting U, if owner package name is used in query arguments, + // we are restricting result set to only self-owned packages. + if (shouldFilterOwnerPackageNameFlag() + && shouldFilterOwnerPackageNameInSelection(extras, type)) { + Log.d(TAG, "Restricting result set to only packages owned by calling package: " + + mCallingIdentity.get().getSharedPackagesAsString()); + final String ownerPackageMatchClause = getWhereForOwnerPackageMatch( + mCallingIdentity.get()); + appendWhereStandalone(qb, ownerPackageMatchClause); + } + + return qb; + } + + private boolean shouldFilterOwnerPackageNameInSelection(Bundle queryArgs, int type) { + return type == TYPE_QUERY && containsOwnerPackageName(queryArgs) + && isApplicableForOwnerPackageNameFiltering(); + } + + private boolean containsOwnerPackageName(Bundle queryArgs) { + final String selection = queryArgs.getString(QUERY_ARG_SQL_SELECTION, "") + .toLowerCase(Locale.ROOT); + final String groupBy = queryArgs.getString(QUERY_ARG_SQL_GROUP_BY, "") + .toLowerCase(Locale.ROOT); + final String sort = queryArgs.getString(QUERY_ARG_SQL_SORT_ORDER, "") + .toLowerCase(Locale.ROOT); + final String having = queryArgs.getString(QUERY_ARG_SQL_HAVING, "") + .toLowerCase(Locale.ROOT); + + return selection.contains(OWNER_PACKAGE_NAME) || groupBy.contains(OWNER_PACKAGE_NAME) + || sort.contains(OWNER_PACKAGE_NAME) || having.contains(OWNER_PACKAGE_NAME); + } + + private void appendAccessCheckQuery(@NonNull SQLiteQueryBuilder qb, boolean forWrite, + @NonNull Uri uri, int uriType, @NonNull Bundle extras, @NonNull String volumeName) { + Objects.requireNonNull(extras); + final Uri redactedUri = extras.getParcelable(QUERY_ARG_REDACTED_URI); + + final boolean allowGlobal; + if (redactedUri != null) { + allowGlobal = checkCallingPermissionGlobal(redactedUri, false); + } else { + allowGlobal = checkCallingPermissionGlobal(uri, forWrite); + } + + if (allowGlobal) { + return; + } + + if (hasAccessToCollection(mCallingIdentity.get(), uriType, forWrite)) { + // has direct access to whole collection, no special filtering needed. + return; + } + + final ArrayList options = new ArrayList<>(); + boolean isLatestSelectionOnlyRequired = extras.getBoolean(QUERY_ARG_LATEST_SELECTION_ONLY, + false); + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName) + && hasUserSelectedAccess(mCallingIdentity.get(), uriType, forWrite)) { + // If app has READ_MEDIA_VISUAL_USER_SELECTED permission, allow access on files granted + // via PhotoPicker launched for Permission. These grants are defined in media_grants + // table. + // We exclude volume internal from the query because media_grants are not supported. + if (isLatestSelectionOnlyRequired) { + // If the query arg to include only recent selection has been received then include + // this as filter while doing the access check for grants from the media_grants + // table. This reduces the clauses needed in the query and makes it more efficient. + Log.d(TAG, "In user_select mode, recent selection only is required."); + options.add(getWhereForLatestSelection(mCallingIdentity.get(), uriType)); + } else { + Log.d(TAG, "In user_select mode, recent selection only is not required."); + options.add(getWhereForUserSelectedAccess(mCallingIdentity.get(), uriType)); + // Allow access to files which are owned by the caller. Or allow access to files + // based on legacy or any other special access permissions. + options.add(getWhereForConstrainedAccess(mCallingIdentity.get(), uriType, forWrite, + extras)); + } + } else { + if (isLatestSelectionOnlyRequired) { + Log.w(TAG, "Latest selection request cannot be honored in the current" + + " access mode."); + } + // Allow access to files which are owned by the caller. Or allow access to files + // based on legacy or any other special access permissions. + options.add(getWhereForConstrainedAccess(mCallingIdentity.get(), uriType, forWrite, + extras)); + } + + appendWhereStandalone(qb, TextUtils.join(" OR ", options)); + } + + /** + * @return {@code true} if app requests to include database rows from + * recently unmounted volume. + * {@code false} otherwise. + */ + private boolean shouldIncludeRecentlyUnmountedVolumes(Uri uri, Bundle extras) { + if (isFuseThread()) { + // File path requests don't require to query from unmounted volumes. + return false; + } + + boolean isIncludeVolumesChangeEnabled = SdkLevel.isAtLeastS() && + CompatChanges.isChangeEnabled(ENABLE_INCLUDE_ALL_VOLUMES, Binder.getCallingUid()); + if ("1".equals(uri.getQueryParameter(ALL_VOLUMES))) { + // Support uri parameter only in R OS and below. Apps should use + // MediaStore#QUERY_ARG_RECENTLY_UNMOUNTED_VOLUMES on S OS onwards. + if (!isIncludeVolumesChangeEnabled) { + return true; + } + throw new IllegalArgumentException("Unsupported uri parameter \"all_volumes\""); + } + if (isIncludeVolumesChangeEnabled) { + // MediaStore#QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES is only supported on S OS and + // for app targeting targetSdk>=S. + return extras.getBoolean(MediaStore.QUERY_ARG_INCLUDE_RECENTLY_UNMOUNTED_VOLUMES, + false); + } + return false; + } + + /** + * Determine if given {@link Uri} has a + * {@link MediaColumns#OWNER_PACKAGE_NAME} column. + */ + private boolean hasOwnerPackageName(Uri uri) { + // It's easier to maintain this as an inverted list + final int table = matchUri(uri, true); + switch (table) { + case IMAGES_THUMBNAILS_ID: + case IMAGES_THUMBNAILS: + case VIDEO_THUMBNAILS_ID: + case VIDEO_THUMBNAILS: + case AUDIO_ALBUMART: + case AUDIO_ALBUMART_ID: + case AUDIO_ALBUMART_FILE_ID: + return false; + default: + return true; + } + } + + /** + * @deprecated all operations should be routed through the overload that + * accepts a {@link Bundle} of extras. + */ + @Override + @Deprecated + public int delete(Uri uri, String selection, String[] selectionArgs) { + return delete(uri, + DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null)); + } + + @Override + public int delete(@NonNull Uri uri, @Nullable Bundle extras) { + Trace.beginSection(safeTraceSectionNameWithUri("delete", uri)); + try { + return deleteInternal(uri, extras); + } catch (FallbackException e) { + return e.translateForUpdateDelete(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + + private int deleteInternal(@NonNull Uri uri, @Nullable Bundle extras) + throws FallbackException { + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); + + if (isRedactedUri(uri)) { + // we don't support deletion on redacted uris. + return 0; + } + + // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. + extras.remove(INCLUDED_DEFAULT_DIRECTORIES); + + uri = safeUncanonicalize(uri); + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + + switch (match) { + case AUDIO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + case DOWNLOADS_ID: + case FILES_ID: { + if (!isFuseThread() && getCachedCallingIdentityForFuse(Binder.getCallingUid()). + removeDeletedRowId(Long.parseLong(uri.getLastPathSegment()))) { + // Apps sometimes delete the file via filePath and then try to delete the db row + // using MediaProvider#delete. Since we would have already deleted the db row + // during the filePath operation, the latter will result in a security + // exception. Apps which don't expect an exception will break here. Since we + // have already deleted the db row, silently return zero as deleted count. + return 0; + } + } + break; + default: + // For other match types, given uri will not correspond to a valid file. + break; + } + + final String userWhere = extras.getString(QUERY_ARG_SQL_SELECTION); + final String[] userWhereArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS); + + int count = 0; + + // handle MEDIA_SCANNER before calling getDatabaseForUri() + if (match == MEDIA_SCANNER) { + if (mMediaScannerVolume == null) { + return 0; + } + + final DatabaseHelper helper = getDatabaseForUri( + MediaStore.Files.getContentUri(mMediaScannerVolume)); + + helper.mScanStopTime = SystemClock.elapsedRealtime(); + + mMediaScannerVolume = null; + return 1; + } + + if (match == VOLUMES_ID) { + detachVolume(uri); + count = 1; + } + + final DatabaseHelper helper = getDatabaseForUri(uri); + switch (match) { + case AUDIO_PLAYLISTS_ID_MEMBERS_ID: + extras.putString(QUERY_ARG_SQL_SELECTION, + BaseColumns._ID + "=" + uri.getPathSegments().get(5)); + // fall-through + case AUDIO_PLAYLISTS_ID_MEMBERS: { + final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri playlistUri = ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId); + + // Playlist contents are always persisted directly into playlist + // files on disk to ensure that we can reliably migrate between + // devices and recover from database corruption + int numOfRemovedPlaylistMembers = removePlaylistMembers(playlistUri, extras); + if (numOfRemovedPlaylistMembers > 0) { + acceptWithExpansion(helper::notifyDelete, volumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); + } + return numOfRemovedPlaylistMembers; + } + } + + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, match, uri, extras, null); + + { + // Give callers interacting with a specific media item a chance to + // escalate access if they don't already have it + switch (match) { + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + enforceCallingPermission(uri, extras, true); + } + + final String[] projection = new String[] { + FileColumns.MEDIA_TYPE, + FileColumns.DATA, + FileColumns._ID, + FileColumns.IS_DOWNLOAD, + FileColumns.MIME_TYPE, + }; + final boolean isFilesTable = qb.getTables().equals("files"); + final LongSparseArray deletedDownloadIds = new LongSparseArray<>(); + final int[] countPerMediaType = new int[FileColumns.MEDIA_TYPE_COUNT]; + if (isFilesTable) { + String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA); + if (deleteparam == null || ! deleteparam.equals("false")) { + Cursor c = qb.query(helper, projection, userWhere, userWhereArgs, + null, null, null, null, null); + try { + while (c.moveToNext()) { + final int mediaType = c.getInt(0); + final String data = c.getString(1); + final long id = c.getLong(2); + final int isDownload = c.getInt(3); + final String mimeType = c.getString(4); + + // TODO(b/188782594) Consider logging mime type access on delete too. + + // Forget that caller is owner of this item + mCallingIdentity.get().setOwned(id, false); + + deleteIfAllowed(uri, extras, data); + int res = qb.delete(helper, BaseColumns._ID + "=" + id, null); + count += res; + // Avoid ArrayIndexOutOfBounds if more mediaTypes are added, + // but mediaTypeSize is not updated + if (res > 0 && mediaType < countPerMediaType.length) { + countPerMediaType[mediaType] += res; + } + + if (isDownload == 1) { + deletedDownloadIds.put(id, mimeType); + } + } + } finally { + FileUtils.closeQuietly(c); + } + // Do not allow deletion if the file/object is referenced as parent + // by some other entries. It could cause database corruption. + appendWhereStandalone(qb, ID_NOT_PARENT_CLAUSE); + } + } + + switch (match) { + case AUDIO_GENRES_ID_MEMBERS: + throw new FallbackException("Genres are read-only", Build.VERSION_CODES.R); + + case IMAGES_THUMBNAILS_ID: + case IMAGES_THUMBNAILS: + case VIDEO_THUMBNAILS_ID: + case VIDEO_THUMBNAILS: + // Delete the referenced files first. + Cursor c = qb.query(helper, sDataOnlyColumn, userWhere, userWhereArgs, null, + null, null, null, null); + if (c != null) { + try { + while (c.moveToNext()) { + deleteIfAllowed(uri, extras, c.getString(0)); + } + } finally { + FileUtils.closeQuietly(c); + } + } + count += deleteRecursive(qb, helper, userWhere, userWhereArgs); + break; + + default: + count += deleteRecursive(qb, helper, userWhere, userWhereArgs); + break; + } + + if (deletedDownloadIds.size() > 0) { + notifyDownloadManagerOnDelete(helper, deletedDownloadIds); + } + + // Check for other URI format grants for File API call only. Check right before + // returning count = 0, to leave positive cases performance unaffected. + if (count == 0 && isFuseThread()) { + count += deleteWithOtherUriGrants(uri, helper, projection, userWhere, userWhereArgs, + extras); + } + + if (isFilesTable && !isCallingPackageSelf()) { + Metrics.logDeletion(volumeName, mCallingIdentity.get().uid, + getCallingPackageOrSelf(), count, countPerMediaType); + } + } + + return count; + } + + private int deleteWithOtherUriGrants(@NonNull Uri uri, DatabaseHelper helper, + String[] projection, String userWhere, String[] userWhereArgs, + @Nullable Bundle extras) { + try (Cursor c = queryForSingleItemAsMediaProvider(uri, projection, userWhere, userWhereArgs, + null)) { + final int mediaType = c.getInt(0); + final String data = c.getString(1); + final long id = c.getLong(2); + final int isDownload = c.getInt(3); + final String mimeType = c.getString(4); + + final Uri uriGranted = getOtherUriGrantsForPath(data, mediaType, Long.toString(id), + /* forWrite */ true); + if (uriGranted != null) { + // 1. delete file + deleteIfAllowed(uriGranted, extras, data); + // 2. delete file row from the db + final boolean allowHidden = isCallingPackageAllowedHidden(); + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_DELETE, + matchUri(uriGranted, allowHidden), uriGranted, extras, null); + int count = qb.delete(helper, BaseColumns._ID + "=" + id, null); + + if (isDownload == 1) { + final LongSparseArray deletedDownloadIds = new LongSparseArray<>(); + deletedDownloadIds.put(id, mimeType); + notifyDownloadManagerOnDelete(helper, deletedDownloadIds); + } + return count; + } + } catch (FileNotFoundException ignored) { + // Do nothing. Returns 0 files deleted. + } + return 0; + } + + private void notifyDownloadManagerOnDelete(DatabaseHelper helper, + LongSparseArray deletedDownloadIds) { + // Do this on a background thread, since we don't want to make binder + // calls as part of a FUSE call. + helper.postBackground(() -> { + DownloadManager dm = getContext().getSystemService(DownloadManager.class); + if (dm != null) { + dm.onMediaStoreDownloadsDeleted(deletedDownloadIds); + } + }); + } + + /** + * Executes identical delete repeatedly within a single transaction until + * stability is reached. Combined with {@link #ID_NOT_PARENT_CLAUSE}, this + * can be used to recursively delete all matching entries, since it only + * deletes parents when no references remaining. + */ + private int deleteRecursive(SQLiteQueryBuilder qb, DatabaseHelper helper, String userWhere, + String[] userWhereArgs) { + return helper.runWithTransaction((db) -> { + synchronized (mDirectoryCache) { + mDirectoryCache.clear(); + } + + int n = 0; + int total = 0; + do { + n = qb.delete(helper, userWhere, userWhereArgs); + total += n; + } while (n > 0); + return total; + }); + } + + @Nullable + @VisibleForTesting + Uri getRedactedUri(@NonNull Uri uri) { + if (!isUriSupportedForRedaction(uri)) { + return null; + } + + DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + try (final Cursor c = helper.runWithoutTransaction( + (db) -> db.query("files", + new String[]{FileColumns.REDACTED_URI_ID}, FileColumns._ID + "=?", + new String[]{uri.getLastPathSegment()}, null, null, null))) { + // Database entry for uri not found. + if (!c.moveToFirst()) return null; + + String redactedUriID = c.getString(c.getColumnIndex(FileColumns.REDACTED_URI_ID)); + if (redactedUriID == null) { + // No redacted has even been created for this uri. Create a new redacted URI ID for + // the uri and store it in the DB. + redactedUriID = REDACTED_URI_ID_PREFIX + UUID.randomUUID().toString().replace("-", + ""); + + ContentValues cv = new ContentValues(); + cv.put(FileColumns.REDACTED_URI_ID, redactedUriID); + int rowsAffected = helper.runWithTransaction( + (db) -> db.update("files", cv, FileColumns._ID + "=?", + new String[]{uri.getLastPathSegment()})); + if (rowsAffected == 0) { + // this shouldn't happen ideally, only reason this might happen is if the db + // entry got deleted in b/w in which case we should return null. + return null; + } + } + + // Create and return a uri with ID = redactedUriID. + final Uri.Builder builder = ContentUris.removeId(uri).buildUpon(); + builder.appendPath(redactedUriID); + + return builder.build(); + } + } + + @NonNull + @VisibleForTesting + List getRedactedUri(@NonNull List uris) { + ArrayList redactedUris = new ArrayList<>(); + for (Uri uri : uris) { + redactedUris.add(getRedactedUri(uri)); + } + + return redactedUris; + } + + @Override + public Bundle call(String method, String arg, Bundle extras) { + Trace.beginSection("MP.call [" + method + ']'); + try { + return callInternal(method, arg, extras); + } finally { + Trace.endSection(); + } + } + + private Bundle callInternal(String method, String arg, Bundle extras) { + switch (method) { + case MediaStore.RESOLVE_PLAYLIST_MEMBERS_CALL: { + return getResultForResolvePlaylistMembers(extras); + } + case MediaStore.SET_STABLE_URIS_FLAG: { + return getResultForSetStableUrisFlag(arg, extras); + } + case MediaStore.RUN_IDLE_MAINTENANCE_CALL: { + return getResultForRunIdleMaintenance(); + } + case MediaStore.WAIT_FOR_IDLE_CALL: { + return getResultForWaitForIdle(); + } + case MediaStore.SCAN_FILE_CALL: { + return getResultForScanFile(arg); + } + case MediaStore.SCAN_VOLUME_CALL: { + return getResultForScanVolume(arg); + } + case MediaStore.GET_VERSION_CALL: { + return getResultForGetVersion(extras); + } + case MediaStore.GET_GENERATION_CALL: { + return getResultForGetGeneration(extras); + } + case MediaStore.GET_DOCUMENT_URI_CALL: { + return getResultForGetDocumentUri(method, extras); + } + case MediaStore.GET_MEDIA_URI_CALL: { + return getResultForGetMediaUri(method, extras); + } + case MediaStore.GET_REDACTED_MEDIA_URI_CALL: { + return getResultForGetRedactedMediaUri(extras); + } + case MediaStore.GET_REDACTED_MEDIA_URI_LIST_CALL: { + return getResultForGetRedactedMediaUriList(extras); + } + case MediaStore.GRANT_MEDIA_READ_FOR_PACKAGE_CALL: { + return getResultForGrantMediaReadForPackage(extras); + } + case MediaStore.REVOKE_READ_GRANT_FOR_PACKAGE_CALL: { + return getResultForRevokeReadGrantForPackage(extras); + } + case MediaStore.CREATE_WRITE_REQUEST_CALL: + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: + case MediaStore.CREATE_TRASH_REQUEST_CALL: + case MediaStore.CREATE_DELETE_REQUEST_CALL: { + return getResultForCreateOperationsRequest(method, extras); + } + case MediaStore.IS_SYSTEM_GALLERY_CALL: + return getResultForIsSystemGallery(arg, extras); + case MediaStore.PICKER_MEDIA_INIT_CALL: { + return getResultForPickerMediaInit(extras); + } + case MediaStore.GET_CLOUD_PROVIDER_CALL: { + return getResultForGetCloudProvider(); + } + case MediaStore.GET_CLOUD_PROVIDER_LABEL_CALL: { + return getResultForGetCloudProviderLabel(); + } + case MediaStore.SET_CLOUD_PROVIDER_CALL: { + return getResultForSetCloudProvider(extras); + } + case MediaStore.SYNC_PROVIDERS_CALL: { + return getResultForSyncProviders(); + } + case MediaStore.IS_SUPPORTED_CLOUD_PROVIDER_CALL: { + return getResultForIsSupportedCloudProvider(arg); + } + case MediaStore.IS_CURRENT_CLOUD_PROVIDER_CALL: { + return getResultForIsCurrentCloudProviderCall(arg); + } + case MediaStore.NOTIFY_CLOUD_MEDIA_CHANGED_EVENT_CALL: { + return getResultForNotifyCloudMediaChangedEvent(arg); + } + case MediaStore.USES_FUSE_PASSTHROUGH: { + return getResultForUsesFusePassThrough(arg); + } + case MediaStore.RUN_IDLE_MAINTENANCE_FOR_STABLE_URIS: { + return getResultForIdleMaintenanceForStableUris(); + } + case READ_BACKUP: { + return getResultForReadBackup(arg, extras); + } + case GET_OWNER_PACKAGE_NAME: { + return getResultForGetOwnerPackageName(arg); + } + case MediaStore.DELETE_BACKED_UP_FILE_PATHS: { + return getResultForDeleteBackedUpFilePaths(arg); + } + case MediaStore.GET_BACKUP_FILES: { + return getResultForGetBackupFiles(); + } + case MediaStore.GET_RECOVERY_DATA: { + return getResultForGetRecoveryData(); + } + case MediaStore.REMOVE_RECOVERY_DATA: { + removeRecoveryData(); + return new Bundle(); + } + default: + throw new UnsupportedOperationException("Unsupported call: " + method); + } + } + + @Nullable + private Bundle getResultForRevokeReadGrantForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + final List uris; + String[] packageNames; + if (checkPermissionSelf(caller)) { + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + packageNames = pm.getPackagesForUid(packageUid); + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + } else if (checkPermissionShell(caller)) { + // If the caller is the shell, the accepted parameter is EXTRA_PACKAGE_NAME + // (as string). + if (!extras.containsKey(Intent.EXTRA_PACKAGE_NAME)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI or" + + " EXTRA_PACKAGE_NAME"); + } + packageNames = new String[]{extras.getString(Intent.EXTRA_PACKAGE_NAME)}; + uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); + // Caller is always shell which may not have the desired userId. Hence, use + // UserId from the MediaProvider process itself. + userId = UserHandle.myUserId(); + } else { + // All other callers are unauthorized. + throw new SecurityException( + getSecurityExceptionMessage("read media grants")); + } + + mMediaGrants.removeMediaGrantsForPackage(packageNames, uris, userId); + return null; + } + + @Nullable + private Bundle getResultForResolvePlaylistMembers(Bundle extras) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + try { + final Uri playlistUri = extras.getParcelable(MediaStore.EXTRA_URI); + resolvePlaylistMembers(playlistUri); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return null; + } + + @Nullable + private Bundle getResultForSetStableUrisFlag(String volumeName, Bundle extras) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call SET_STABLE_URIS by uid:" + Binder.getCallingUid()); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + try { + final boolean isEnabled = extras.getBoolean(EXTRA_IS_STABLE_URIS_ENABLED); + mDatabaseBackupAndRecovery.setStableUrisGlobalFlag(volumeName, isEnabled); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return null; + } + + @Nullable + private Bundle getResultForRunIdleMaintenance() { + // Protect ourselves from random apps by requiring a generic + // permission held by common debugging components, such as shell + getContext().enforceCallingOrSelfPermission( + Manifest.permission.DUMP, TAG); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + try { + onIdleMaintenance(new CancellationSignal()); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return null; + } + + @Nullable + private Bundle getResultForWaitForIdle() { + // TODO(b/195009139): Remove after overriding wait for idle in test to sync picker + // Syncing the picker while waiting for idle fixes tests with the picker db + // flag enabled because the picker db is in a consistent state with the external + // db after the sync + syncAllMedia(); + ForegroundThread.waitForIdle(); + final CountDownLatch latch = new CountDownLatch(1); + BackgroundThread.getExecutor().execute(latch::countDown); + try { + latch.await(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + return null; + } + + @NotNull + private Bundle getResultForScanFile(String arg) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + + final String filePath = arg; + final Uri uri; + try { + File file; + try { + file = FileUtils.getCanonicalFile(filePath); + } catch (IOException e) { + file = null; + } + + uri = file != null ? scanFile(file, REASON_DEMAND) : null; + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + + // TODO(b/262244882): maybe enforceCallingPermissionInternal(uri, ...) + + final Bundle res = new Bundle(); + res.putParcelable(Intent.EXTRA_STREAM, uri); + return res; + } + + private Bundle getResultForScanVolume(String arg) { + final int userId = uidToUserId(Binder.getCallingUid()); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final CallingIdentity providerToken = clearCallingIdentity(); + + final String volumeName = arg; + try { + final MediaVolume volume = mVolumeCache.findVolume(volumeName, + UserHandle.of(userId)); + MediaService.onScanVolume(getContext(), volume, REASON_DEMAND); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to find volume " + volumeName, e); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + restoreCallingIdentity(providerToken); + restoreLocalCallingIdentity(token); + } + return Bundle.EMPTY; + } + + @NotNull + private Bundle getResultForGetVersion(Bundle extras) { + final String volumeName = extras.getString(Intent.EXTRA_TEXT); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(Files.getContentUri(volumeName)); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + final String version = helper.runWithoutTransaction((db) -> + db.getVersion() + ":" + DatabaseHelper.getOrCreateUuid(db)); + + final Bundle res = new Bundle(); + res.putString(Intent.EXTRA_TEXT, version); + return res; + } + + @NotNull + private Bundle getResultForGetGeneration(Bundle extras) { + final String volumeName = extras.getString(Intent.EXTRA_TEXT); + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(Files.getContentUri(volumeName)); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + final long generation = helper.runWithoutTransaction(DatabaseHelper::getGeneration); + + final Bundle res = new Bundle(); + res.putLong(Intent.EXTRA_INDEX, generation); + return res; + } + + private Bundle getResultForGetDocumentUri(String method, Bundle extras) { + final Uri mediaUri = extras.getParcelable(MediaStore.EXTRA_URI); + enforceCallingPermission(mediaUri, extras, false); + + final Uri fileUri; + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + fileUri = Uri.fromFile(queryForDataFile(mediaUri, null)); + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); + } finally { + restoreLocalCallingIdentity(token); + } + + try (ContentProviderClient client = getContext().getContentResolver() + .acquireUnstableContentProviderClient( + getExternalStorageProviderAuthority())) { + extras.putParcelable(MediaStore.EXTRA_URI, fileUri); + return client.call(method, null, extras); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } + } + + @NotNull + private Bundle getResultForGetMediaUri(String method, Bundle extras) { + final Uri documentUri = extras.getParcelable(MediaStore.EXTRA_URI); + getContext().enforceCallingUriPermission(documentUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, TAG); + + final int callingPid = mCallingIdentity.get().pid; + final int callingUid = mCallingIdentity.get().uid; + final String callingPackage = getCallingPackage(); + final CallingIdentity token = clearCallingIdentity(); + final String authority = documentUri.getAuthority(); + + if (!authority.equals(MediaDocumentsProvider.AUTHORITY) + && !authority.equals(DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY)) { + throw new IllegalArgumentException("Provider for this Uri is not supported."); + } + + try (ContentProviderClient client = getContext().getContentResolver() + .acquireUnstableContentProviderClient(authority)) { + final Bundle clientRes = client.call(method, null, extras); + final Uri fileUri = clientRes.getParcelable(MediaStore.EXTRA_URI); + final Bundle res = new Bundle(); + final Uri mediaStoreUri = fileUri.getAuthority().equals(MediaStore.AUTHORITY) + ? fileUri : queryForMediaUri(new File(fileUri.getPath()), null); + copyUriPermissionGrants(documentUri, mediaStoreUri, callingPid, + callingUid, callingPackage); + res.putParcelable(MediaStore.EXTRA_URI, mediaStoreUri); + return res; + } catch (FileNotFoundException e) { + throw new IllegalArgumentException(e); + } catch (RemoteException e) { + throw new IllegalStateException(e); + } finally { + restoreCallingIdentity(token); + } + } + + @NotNull + private Bundle getResultForGetRedactedMediaUri(Bundle extras) { + final Uri uri = extras.getParcelable(MediaStore.EXTRA_URI); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uri, Bundle.EMPTY, false); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + final Bundle res = new Bundle(); + res.putParcelable(MediaStore.EXTRA_URI, getRedactedUri(uri)); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @NotNull + private Bundle getResultForGetRedactedMediaUriList(Bundle extras) { + final List uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + // NOTE: It is ok to update the DB and return a redacted URI for the cases when + // the user code only has read access, hence we don't check for write permission. + enforceCallingPermission(uris, false); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + final Bundle res = new Bundle(); + res.putParcelableArrayList(MediaStore.EXTRA_URI_LIST, + (ArrayList) getRedactedUri(uris)); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Nullable + private Bundle getResultForGrantMediaReadForPackage(Bundle extras) { + final int caller = Binder.getCallingUid(); + int userId; + final List uris; + String packageName; + if (checkPermissionSelf(caller)) { + // If the caller is MediaProvider the accepted parameters are EXTRA_URI_LIST + // and EXTRA_UID. + if (!extras.containsKey(MediaStore.EXTRA_URI_LIST) + && !extras.containsKey(Intent.EXTRA_UID)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI_LIST or" + " EXTRA_UID"); + } + uris = extras.getParcelableArrayList(MediaStore.EXTRA_URI_LIST); + final PackageManager pm = getContext().getPackageManager(); + final int packageUid = extras.getInt(Intent.EXTRA_UID); + final String[] packages = pm.getPackagesForUid(packageUid); + if (packages == null || packages.length == 0) { + throw new IllegalArgumentException( + String.format( + "Could not find packages for media_grants with uid: %d", + packageUid)); + } + // Use the first package in the returned list for grants. In the case this + // uid has multiple shared packages, the eventual queries to check for file + // access will use all of the packages in this list, so just one is needed + // to create the grants. + packageName = packages[0]; + // Get the userId from packageUid as the initiator could be a cloned app, which + // accesses Media via MP of its parent user and Binder's callingUid reflects + // the latter. + userId = uidToUserId(packageUid); + } else if (checkPermissionShell(caller)) { + // If the caller is the shell, the accepted parameters are EXTRA_URI (as string) + // and EXTRA_PACKAGE_NAME (as string). + if (!extras.containsKey(MediaStore.EXTRA_URI) + && !extras.containsKey(Intent.EXTRA_PACKAGE_NAME)) { + throw new IllegalArgumentException( + "Missing required extras arguments: EXTRA_URI or" + " EXTRA_PACKAGE_NAME"); + } + packageName = extras.getString(Intent.EXTRA_PACKAGE_NAME); + uris = List.of(Uri.parse(extras.getString(MediaStore.EXTRA_URI))); + // Caller is always shell which may not have the desired userId. Hence, use + // UserId from the MediaProvider process itself. + userId = UserHandle.myUserId(); + } else { + // All other callers are unauthorized. + + throw new SecurityException(getSecurityExceptionMessage("Create media grants")); + } + + mMediaGrants.addMediaGrantsForPackage(packageName, uris, userId); + return null; + } + + @NotNull + private Bundle getResultForCreateOperationsRequest(String method, Bundle extras) { + final PendingIntent pi = createRequest(method, extras); + final Bundle res = new Bundle(); + res.putParcelable(MediaStore.EXTRA_RESULT, pi); + return res; + } + + @NotNull + private Bundle getResultForIsSystemGallery(String arg, Bundle extras) { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + String packageName = arg; + int uid = extras.getInt(MediaStore.EXTRA_IS_SYSTEM_GALLERY_UID); + boolean isSystemGallery = PermissionUtils.checkWriteImagesOrVideoAppOps( + getContext(), uid, packageName, getContext().getAttributionTag()); + Bundle res = new Bundle(); + res.putBoolean(MediaStore.EXTRA_IS_SYSTEM_GALLERY_RESPONSE, isSystemGallery); + return res; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Nullable + private Bundle getResultForPickerMediaInit(Bundle extras) { + Log.i(TAG, "Received media init query for extras: " + extras); + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid())) { + throw new SecurityException( + getSecurityExceptionMessage("Picker media init")); + } + mPickerDataLayer.initMediaData(PickerSyncRequestExtras.fromBundle(extras)); + return null; + } + + @NotNull + private Bundle getResultForGetCloudProvider() { + if (!checkPermissionShell(Binder.getCallingUid()) + && !checkPermissionSelf(Binder.getCallingUid())) { + throw new SecurityException( + getSecurityExceptionMessage("Get cloud provider")); + } + final Bundle bundle = new Bundle(); + bundle.putString(MediaStore.GET_CLOUD_PROVIDER_RESULT, + mPickerSyncController.getCloudProvider()); + return bundle; + } + + @NotNull + private Bundle getResultForGetCloudProviderLabel() { + if (!checkPermissionSystem(Binder.getCallingUid())) { + throw new SecurityException(getSecurityExceptionMessage("Get cloud provider label")); + } + final Bundle res = new Bundle(); + String cloudProviderLabel = null; + try { + cloudProviderLabel = mPickerSyncController.getCurrentCloudProviderLocalizedLabel(); + } catch (UnableToAcquireLockException e) { + Log.d(TAG, "Timed out while attempting to acquire the cloud provider lock when getting " + + "the cloud provider label.", e); + } + res.putString(META_DATA_PREFERENCE_SUMMARY, cloudProviderLabel); + return res; + } + + @NotNull + private Bundle getResultForSetCloudProvider(Bundle extras) { + final String cloudProvider = extras.getString(MediaStore.EXTRA_CLOUD_PROVIDER); + Log.i(TAG, "Request received to set cloud provider to " + cloudProvider); + boolean isUpdateSuccessful = false; + if (checkPermissionSelf(Binder.getCallingUid())) { + isUpdateSuccessful = mPickerSyncController.setCloudProvider(cloudProvider); + } else if (checkPermissionShell(Binder.getCallingUid())) { + isUpdateSuccessful = + mPickerSyncController.forceSetCloudProvider(cloudProvider); + } else { + throw new SecurityException( + getSecurityExceptionMessage("Set cloud provider")); + } + + if (isUpdateSuccessful) { + Log.i(TAG, "Completed request to set cloud provider to " + cloudProvider); + } + final Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.SET_CLOUD_PROVIDER_RESULT, isUpdateSuccessful); + return bundle; + } + + @NotNull + private Bundle getResultForSyncProviders() { + syncAllMedia(); + return new Bundle(); + } + + @NotNull + private Bundle getResultForIsSupportedCloudProvider(String arg) { + final boolean isSupported = mPickerSyncController.isProviderSupported(arg, + Binder.getCallingUid()); + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isSupported); + return bundle; + } + + @NotNull + private Bundle getResultForIsCurrentCloudProviderCall(String arg) { + Bundle bundle = new Bundle(); + boolean isEnabled = false; + + if (mConfigStore.isCloudMediaInPhotoPickerEnabled()) { + isEnabled = + mPickerSyncController.isProviderEnabled( + arg, Binder.getCallingUid()); + } + + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, isEnabled); + return bundle; + } + + @NotNull + private Bundle getResultForNotifyCloudMediaChangedEvent(String arg) { + final boolean notifyCloudEventResult; + if (mPickerSyncController.isProviderEnabled(arg, Binder.getCallingUid())) { + mPickerDataLayer.handleMediaEventNotification(/*localOnly=*/ false); + notifyCloudEventResult = true; + } else { + notifyCloudEventResult = false; + } + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.EXTRA_CLOUD_PROVIDER_RESULT, + notifyCloudEventResult); + return bundle; + } + + @NotNull + private Bundle getResultForUsesFusePassThrough(String arg) { + boolean isEnabled = false; + try { + FuseDaemon daemon = getFuseDaemonForFile(new File(arg), mVolumeCache); + if (daemon != null) { + isEnabled = daemon.usesFusePassthrough(); + } + } catch (FileNotFoundException e) { + } + + Bundle bundle = new Bundle(); + bundle.putBoolean(MediaStore.USES_FUSE_PASSTHROUGH_RESULT, isEnabled); + return bundle; + } + + @NotNull + private Bundle getResultForIdleMaintenanceForStableUris() { + backupDatabases(null); + return new Bundle(); + } + + @NotNull + private Bundle getResultForReadBackup(String arg, Bundle extras) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call READ_BACKUP by uid:" + Binder.getCallingUid()); + Bundle bundle = new Bundle(); + Optional backupIdRowOptional = + mDatabaseBackupAndRecovery.readDataFromBackup(arg, extras.getString( + FileColumns.DATA)); + String data = null; + try { + data = backupIdRowOptional.isPresent() ? BackupIdRow.serialize( + backupIdRowOptional.get()) : null; + } catch (IOException e) { + throw new RuntimeException(e); + } + bundle.putString(READ_BACKUP, data); + return bundle; + } + + @NotNull + private Bundle getResultForGetOwnerPackageName(String arg) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_OWNER_PACKAGE_NAME by " + + "uid:" + Binder.getCallingUid()); + try { + String ownerPackageName = mDatabaseBackupAndRecovery.readOwnerPackageName(arg); + Bundle result = new Bundle(); + result.putString(GET_OWNER_PACKAGE_NAME, ownerPackageName); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @NotNull + private Bundle getResultForDeleteBackedUpFilePaths(String arg) { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call DELETE_BACKED_UP_FILE_PATHS by " + + "uid:" + Binder.getCallingUid()); + mDatabaseBackupAndRecovery.deleteBackupForVolume(arg); + return new Bundle(); + } + + @NotNull + private Bundle getResultForGetBackupFiles() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_BACKUP_FILES by " + + "uid:" + Binder.getCallingUid()); + List backupFiles = mDatabaseBackupAndRecovery.getBackupFiles(); + List fileNames = new ArrayList<>(); + for (File file : backupFiles) { + fileNames.add(file.getName()); + } + Bundle bundle = new Bundle(); + Object[] values = fileNames.toArray(); + String[] resultArray = Arrays.copyOf(values, values.length, String[].class); + bundle.putStringArray(GET_BACKUP_FILES, resultArray); + return bundle; + } + + @NotNull + private Bundle getResultForGetRecoveryData() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call GET_RECOVERY_DATA by " + + "uid:" + Binder.getCallingUid()); + + String[] xattrs = null; + try { + xattrs = Os.listxattr("/data/media/0"); + } catch (ErrnoException e) { + Log.w(TAG, "Error in getting xattr list ", e); + } + + Bundle bundle = new Bundle(); + bundle.putStringArray(MediaStore.GET_RECOVERY_DATA, xattrs); + return bundle; + } + + private void removeRecoveryData() { + getContext().enforceCallingPermission(Manifest.permission.WRITE_MEDIA_STORAGE, + "Permission missing to call REMOVE_RECOVERY_DATA by " + + "uid:" + Binder.getCallingUid()); + + List validUsers = mUserManager.getUserHandles(/* excludeDying */ true).stream() + .map(userHandle -> String.valueOf(userHandle.getIdentifier())).collect( + Collectors.toList()); + Log.i(TAG, "Active user ids are:" + validUsers); + mDatabaseBackupAndRecovery.removeRecoveryDataExceptValidUsers(validUsers); + } + + private String getSecurityExceptionMessage(String method) { + int callingUid = Binder.getCallingUid(); + return String.format("%s not allowed. Calling app ID: %d, Calling UID %d. " + + "Media Provider app ID: %d, Media Provider UID: %d.", + method, + UserHandle.getAppId(callingUid), + callingUid, + UserHandle.getAppId(MY_UID), + MY_UID); + } + + public void backupDatabases(CancellationSignal signal) { + mDatabaseBackupAndRecovery.backupDatabases(mInternalDatabase, mExternalDatabase, signal); + } + + private void syncAllMedia() { + // Clear the binder calling identity so that we can sync the unexported + // local_provider while running as MediaProvider + final long t = Binder.clearCallingIdentity(); + try { + Log.v(TAG, "Test initiated cloud provider sync"); + mPickerSyncController.syncAllMedia(); + } finally { + Binder.restoreCallingIdentity(t); + } + } + + private AssetFileDescriptor getOriginalMediaFormatFileDescriptor(Bundle extras) + throws FileNotFoundException { + try (ParcelFileDescriptor inputPfd = + extras.getParcelable(MediaStore.EXTRA_FILE_DESCRIPTOR)) { + File file = getFileFromFileDescriptor(inputPfd); + // Convert from FUSE file to lower fs file because the supportsTranscode() check below + // expects a lower fs file format + file = fromFuseFile(file); + if (!mTranscodeHelper.supportsTranscode(file.getPath())) { + // Note that we should be checking if a file is a modern format and not just + // that it supports transcoding, unfortunately, checking modern format + // requires either a db query or media scan which can lead to ANRs if apps + // or the system implicitly call this method as part of a + // MediaPlayer#setDataSource. + throw new FileNotFoundException("Input file descriptor is already original"); + } + + FuseDaemon fuseDaemon = getFuseDaemonForFile(file, mVolumeCache); + int uid = Binder.getCallingUid(); + + FdAccessResult result = fuseDaemon.checkFdAccess(inputPfd, uid); + if (!result.isSuccess()) { + throw new FileNotFoundException("Invalid path for original media format file"); + } + + String outputPath = result.filePath; + boolean shouldRedact = result.shouldRedact; + + int posixMode = Os.fcntlInt(inputPfd.getFileDescriptor(), F_GETFL, + 0 /* args */); + int modeBits = FileUtils.translateModePosixToPfd(posixMode); + + ParcelFileDescriptor pfd = openWithFuse(outputPath, uid, 0 /* mediaCapabilitiesUid */, + modeBits, shouldRedact, false /* shouldTranscode */, + 0 /* transcodeReason */); + return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH); + } catch (IOException e) { + throw new FileNotFoundException("Failed to fetch original file descriptor"); + } catch (ErrnoException e) { + Log.w(TAG, "Failed to fetch access mode for file descriptor", e); + throw new FileNotFoundException("Failed to fetch access mode for file descriptor"); + } + } + + /** + * Grant similar read/write access for mediaStoreUri as the caller has for documentsUri. + * + * Note: This function assumes that read permission check for documentsUri is already enforced. + * Note: This function currently does not check/grant for persisted Uris. Support for this can + * be added eventually, but the calling application will have to call + * ContentResolver#takePersistableUriPermission(Uri, int) for the mediaStoreUri to persist. + * + * @param documentsUri DocumentsProvider format content Uri + * @param mediaStoreUri MediaStore format content Uri + * @param callingPid pid of the caller + * @param callingUid uid of the caller + * @param callingPackage package name of the caller + */ + private void copyUriPermissionGrants(Uri documentsUri, Uri mediaStoreUri, + int callingPid, int callingUid, String callingPackage) { + // No need to check for read permission, as we enforce it already. + int modeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION; + if (getContext().checkUriPermission(documentsUri, callingPid, callingUid, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED) { + modeFlags |= Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + } + getContext().grantUriPermission(callingPackage, mediaStoreUri, modeFlags); + } + + static List collectUris(ClipData clipData) { + final ArrayList res = new ArrayList<>(); + for (int i = 0; i < clipData.getItemCount(); i++) { + res.add(clipData.getItemAt(i).getUri()); + } + return res; + } + + /** + * Return the filesystem path of the real file on disk that is represented + * by the given {@link ParcelFileDescriptor}. + * + * Note that the file may be a FUSE or lower fs file and depending on the purpose might need + * to be converted with {@link FileUtils#toFuseFile} or {@link FileUtils#fromFuseFile}. + * + * Copied from {@link ParcelFileDescriptor#getFile} + */ + private static File getFileFromFileDescriptor(ParcelFileDescriptor fileDescriptor) + throws IOException { + try { + final String path = Os.readlink("/proc/self/fd/" + fileDescriptor.getFd()); + if (OsConstants.S_ISREG(Os.stat(path).st_mode)) { + return new File(path); + } else { + throw new IOException("Not a regular file: " + path); + } + } catch (ErrnoException e) { + throw e.rethrowAsIOException(); + } + } + + /** + * Generate the {@link PendingIntent} for the given grant request. This + * method also checks the incoming arguments for security purposes + * before creating the privileged {@link PendingIntent}. + */ + @NonNull + private PendingIntent createRequest(@NonNull String method, @NonNull Bundle extras) { + final ClipData clipData = extras.getParcelable(MediaStore.EXTRA_CLIP_DATA); + final List uris = collectUris(clipData); + + for (Uri uri : uris) { + final int match = matchUri(uri, false); + switch (match) { + case IMAGES_MEDIA_ID: + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: + // Caller is requesting a specific media item by its ID, + // which means it's valid for requests + break; + case FILES_ID: + // Allow only subtitle files + if (!isSubtitleFile(uri)) { + throw new IllegalArgumentException( + "All requested items must be Media items"); + } + break; + default: + throw new IllegalArgumentException( + "All requested items must be referenced by specific ID"); + } + } + + // Enforce that limited set of columns can be mutated + final ContentValues values = extras.getParcelable(MediaStore.EXTRA_CONTENT_VALUES); + final List allowedColumns; + switch (method) { + case MediaStore.CREATE_FAVORITE_REQUEST_CALL: + allowedColumns = Collections.singletonList( + MediaColumns.IS_FAVORITE); + break; + case MediaStore.CREATE_TRASH_REQUEST_CALL: + allowedColumns = Collections.singletonList( + MediaColumns.IS_TRASHED); + break; + default: + allowedColumns = Collections.emptyList(); + break; + } + if (values != null) { + for (String key : values.keySet()) { + if (!allowedColumns.contains(key)) { + throw new IllegalArgumentException("Invalid column " + key); + } + } + } + + final Context context = getContext(); + final Intent intent = new Intent(method, null, context, PermissionActivity.class); + intent.putExtras(extras); + final ActivityOptions options = ActivityOptions.makeBasic(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + options.setPendingIntentCreatorBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED); + } + return PendingIntent.getActivity(context, PermissionActivity.REQUEST_CODE, intent, + FLAG_ONE_SHOT | FLAG_CANCEL_CURRENT | FLAG_IMMUTABLE, options.toBundle()); + } + + /** + * @return true if the given Files uri has media_type=MEDIA_TYPE_SUBTITLE + */ + private boolean isSubtitleFile(Uri uri) { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try (Cursor cursor = queryForSingleItem(uri, new String[]{FileColumns.MEDIA_TYPE}, null, + null, null)) { + return cursor.getInt(0) == FileColumns.MEDIA_TYPE_SUBTITLE; + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find database row for requested uri " + uri, e); + } finally { + restoreLocalCallingIdentity(tokenInner); + } + return false; + } + + /** + * Ensure that all local databases have a custom collator registered for the + * given {@link ULocale} locale. + * + * @return the corresponding custom collation name to be used in + * {@code ORDER BY} clauses. + */ + @NonNull + private String ensureCustomCollator(@NonNull String locale) { + // Quick check that requested locale looks reasonable + new ULocale(locale); + + final String collationName = "custom_" + locale.replaceAll("[^a-zA-Z]", ""); + synchronized (mCustomCollators) { + if (!mCustomCollators.contains(collationName)) { + for (DatabaseHelper helper : new DatabaseHelper[] { + mInternalDatabase, + mExternalDatabase + }) { + helper.runWithoutTransaction((db) -> { + db.execPerConnectionSQL("SELECT icu_load_collation(?, ?);", + new String[] { locale, collationName }); + return null; + }); + } + mCustomCollators.add(collationName); + } + } + return collationName; + } + + private int pruneThumbnails(@NonNull SQLiteDatabase db, @NonNull CancellationSignal signal) { + int prunedCount = 0; + + // Determine all known media items + final LongArray knownIds = new LongArray(); + try (Cursor c = db.query(true, "files", new String[] { BaseColumns._ID }, + null, null, null, null, null, null, signal)) { + while (c.moveToNext()) { + knownIds.add(c.getLong(0)); + } + } + + final long[] knownIdsRaw = knownIds.toArray(); + Arrays.sort(knownIdsRaw); + + for (MediaVolume volume : mVolumeCache.getExternalVolumes()) { + final List thumbDirs; + try { + thumbDirs = getThumbnailDirectories(volume); + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to resolve volume " + volume.getName(), e); + continue; + } + + // Reconcile all thumbnails, deleting stale items + for (File thumbDir : thumbDirs) { + // Possibly bail before digging into each directory + signal.throwIfCanceled(); + + final File[] files = thumbDir.listFiles(); + for (File thumbFile : (files != null) ? files : new File[0]) { + if (Objects.equals(thumbFile.getName(), FILE_DATABASE_UUID)) continue; + if (Objects.equals(thumbFile.getName(), MEDIA_IGNORE_FILENAME)) continue; + final String name = FileUtils.extractFileName(thumbFile.getName()); + try { + final long id = Long.parseLong(name); + if (Arrays.binarySearch(knownIdsRaw, id) >= 0) { + // Thumbnail belongs to known media, keep it + continue; + } + } catch (NumberFormatException e) { + } + + Log.v(TAG, "Deleting stale thumbnail " + thumbFile); + deleteAndInvalidate(thumbFile); + prunedCount++; + } + } + } + + // Also delete stale items from legacy tables + db.execSQL("delete from thumbnails " + + "where image_id not in (select _id from images)"); + db.execSQL("delete from videothumbnails " + + "where video_id not in (select _id from video)"); + + return prunedCount; + } + + abstract class Thumbnailer { + final String directoryName; + + public Thumbnailer(String directoryName) { + this.directoryName = directoryName; + } + + private File getThumbnailFile(Uri uri) throws IOException { + final String volumeName = resolveVolumeName(uri); + final File volumePath = getVolumePath(volumeName); + return FileUtils.buildPath(volumePath, directoryName, + DIRECTORY_THUMBNAILS, ContentUris.parseId(uri) + ".jpg"); + } + + public abstract Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) + throws IOException; + + public ParcelFileDescriptor ensureThumbnail(Uri uri, CancellationSignal signal) + throws IOException { + // First attempt to fast-path by opening the thumbnail; if it + // doesn't exist we fall through to create it below + final File thumbFile = getThumbnailFile(uri); + try { + return FileUtils.openSafely(thumbFile, + ParcelFileDescriptor.MODE_READ_ONLY); + } catch (FileNotFoundException ignored) { + } + + final File thumbDir = thumbFile.getParentFile(); + thumbDir.mkdirs(); + + // When multiple threads race for the same thumbnail, the second + // thread could return a file with a thumbnail still in + // progress. We could add heavy per-ID locking to mitigate this + // rare race condition, but it's simpler to have both threads + // generate the same thumbnail using temporary files and rename + // them into place once finished. + final File thumbTempFile = File.createTempFile("thumb", null, thumbDir); + + ParcelFileDescriptor thumbWrite = null; + ParcelFileDescriptor thumbRead = null; + try { + // Open our temporary file twice: once for local writing, and + // once for remote reading. Both FDs point at the same + // underlying inode on disk, so they're stable across renames + // to avoid race conditions between threads. + thumbWrite = FileUtils.openSafely(thumbTempFile, + ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_CREATE); + thumbRead = FileUtils.openSafely(thumbTempFile, + ParcelFileDescriptor.MODE_READ_ONLY); + + final Bitmap thumbnail = getThumbnailBitmap(uri, signal); + thumbnail.compress(Bitmap.CompressFormat.JPEG, 90, + new FileOutputStream(thumbWrite.getFileDescriptor())); + + try { + // Use direct syscall for better failure logs + Os.rename(thumbTempFile.getAbsolutePath(), thumbFile.getAbsolutePath()); + } catch (ErrnoException e) { + e.rethrowAsIOException(); + } + + // Everything above went peachy, so return a duplicate of our + // already-opened read FD to keep our finally logic below simple + return thumbRead.dup(); + + } finally { + // Regardless of success or failure, try cleaning up any + // remaining temporary file and close all our local FDs + FileUtils.closeQuietly(thumbWrite); + FileUtils.closeQuietly(thumbRead); + deleteAndInvalidate(thumbTempFile); + } + } + + public void invalidateThumbnail(Uri uri) throws IOException { + deleteAndInvalidate(getThumbnailFile(uri)); + } + } + + private final Thumbnailer mAudioThumbnailer = new Thumbnailer(Environment.DIRECTORY_MUSIC) { + @Override + public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException { + return ThumbnailUtils.createAudioThumbnail(queryForDataFile(uri, signal), + mThumbSize, signal); + } + }; + + private final Thumbnailer mVideoThumbnailer = new Thumbnailer(Environment.DIRECTORY_MOVIES) { + @Override + public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException { + return ThumbnailUtils.createVideoThumbnail(queryForDataFile(uri, signal), + mThumbSize, signal); + } + }; + + private final Thumbnailer mImageThumbnailer = new Thumbnailer(Environment.DIRECTORY_PICTURES) { + @Override + public Bitmap getThumbnailBitmap(Uri uri, CancellationSignal signal) throws IOException { + return ThumbnailUtils.createImageThumbnail(queryForDataFile(uri, signal), + mThumbSize, signal); + } + }; + + private List getThumbnailDirectories(MediaVolume volume) throws FileNotFoundException { + final File volumePath = volume.getPath(); + return Arrays.asList( + FileUtils.buildPath(volumePath, Environment.DIRECTORY_MUSIC, DIRECTORY_THUMBNAILS), + FileUtils.buildPath(volumePath, Environment.DIRECTORY_MOVIES, DIRECTORY_THUMBNAILS), + FileUtils.buildPath(volumePath, Environment.DIRECTORY_PICTURES, + DIRECTORY_THUMBNAILS)); + } + + private void invalidateThumbnails(Uri uri) { + Trace.beginSection("MP.invalidateThumbnails"); + try { + invalidateThumbnailsInternal(uri); + } finally { + Trace.endSection(); + } + } + + private void invalidateThumbnailsInternal(Uri uri) { + final long id = ContentUris.parseId(uri); + try { + mAudioThumbnailer.invalidateThumbnail(uri); + mVideoThumbnailer.invalidateThumbnail(uri); + mImageThumbnailer.invalidateThumbnail(uri); + } catch (IOException ignored) { + } + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + Log.w(TAG, e); + return; + } + + helper.runWithTransaction((db) -> { + final String idString = Long.toString(id); + try (Cursor c = db.rawQuery("select _data from thumbnails where image_id=?" + + " union all select _data from videothumbnails where video_id=?", + new String[] { idString, idString })) { + while (c.moveToNext()) { + String path = c.getString(0); + deleteIfAllowed(uri, Bundle.EMPTY, path); + } + } + + db.execSQL("delete from thumbnails where image_id=?", new String[] { idString }); + db.execSQL("delete from videothumbnails where video_id=?", new String[] { idString }); + return null; + }); + } + + /** + * @deprecated all operations should be routed through the overload that + * accepts a {@link Bundle} of extras. + */ + @Override + @Deprecated + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return update(uri, values, + DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null)); + } + + @Override + public int update(@NonNull Uri uri, @Nullable ContentValues values, + @Nullable Bundle extras) { + Trace.beginSection(safeTraceSectionNameWithUri("update", uri)); + try { + return updateInternal(uri, values, extras); + } catch (FallbackException e) { + return e.translateForUpdateDelete(getCallingPackageTargetSdkVersion()); + } finally { + Trace.endSection(); + } + } + + private int updateInternal(@NonNull Uri uri, @Nullable ContentValues initialValues, + @Nullable Bundle extras) throws FallbackException { + final String volumeName = getVolumeName(uri); + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + + extras = (extras != null) ? extras : new Bundle(); + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + extras.remove(QUERY_ARG_REDACTED_URI); + + if (isRedactedUri(uri)) { + // we don't support update on redacted uris. + return 0; + } + + // Related items are only considered for new media creation, and they + // can't be leveraged to move existing content into blocked locations + extras.remove(QUERY_ARG_RELATED_URI); + // INCLUDED_DEFAULT_DIRECTORIES extra should only be set inside MediaProvider. + extras.remove(INCLUDED_DEFAULT_DIRECTORIES); + + final String userWhere = extras.getString(QUERY_ARG_SQL_SELECTION); + final String[] userWhereArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS); + + // Limit the hacky workaround to camera targeting Q and below, to allow newer versions + // of camera that does the right thing to work correctly. + if ("com.google.android.GoogleCamera".equals(getCallingPackageOrSelf()) + && getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) { + if (matchUri(uri, false) == IMAGES_MEDIA_ID) { + Log.w(TAG, "Working around app bug in b/111966296"); + uri = MediaStore.Files.getContentUri("external", ContentUris.parseId(uri)); + } else if (matchUri(uri, false) == VIDEO_MEDIA_ID) { + Log.w(TAG, "Working around app bug in b/112246630"); + uri = MediaStore.Files.getContentUri("external", ContentUris.parseId(uri)); + } + } + + uri = safeUncanonicalize(uri); + + int count; + + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + final DatabaseHelper helper = getDatabaseForUri(uri); + + switch (match) { + case AUDIO_PLAYLISTS_ID_MEMBERS_ID: + extras.putString(QUERY_ARG_SQL_SELECTION, + BaseColumns._ID + "=" + uri.getPathSegments().get(5)); + // fall-through + case AUDIO_PLAYLISTS_ID_MEMBERS: { + final long playlistId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri playlistUri = ContentUris.withAppendedId( + MediaStore.Audio.Playlists.getContentUri(volumeName), playlistId); + if (uri.getBooleanQueryParameter("move", false)) { + // Convert explicit request into query; sigh, moveItem() + // uses zero-based indexing instead of one-based indexing + final int from = Integer.parseInt(uri.getPathSegments().get(5)) + 1; + final int to = initialValues.getAsInteger(Playlists.Members.PLAY_ORDER) + 1; + extras.putString(QUERY_ARG_SQL_SELECTION, + Playlists.Members.PLAY_ORDER + "=" + from); + initialValues.put(Playlists.Members.PLAY_ORDER, to); + } + + // Playlist contents are always persisted directly into playlist + // files on disk to ensure that we can reliably migrate between + // devices and recover from database corruption + final int index; + if (initialValues.containsKey(Playlists.Members.PLAY_ORDER)) { + index = movePlaylistMembers(playlistUri, initialValues, extras); + } else { + index = resolvePlaylistIndex(playlistUri, extras); + } + if (initialValues.containsKey(Playlists.Members.AUDIO_ID)) { + final Bundle queryArgs = new Bundle(); + queryArgs.putString(QUERY_ARG_SQL_SELECTION, + Playlists.Members.PLAY_ORDER + "=" + (index + 1)); + removePlaylistMembers(playlistUri, queryArgs); + + final ContentValues values = new ContentValues(); + values.put(Playlists.Members.AUDIO_ID, + initialValues.getAsString(Playlists.Members.AUDIO_ID)); + values.put(Playlists.Members.PLAY_ORDER, (index + 1)); + addPlaylistMembers(playlistUri, values); + } + + acceptWithExpansion(helper::notifyUpdate, volumeName, playlistId, + FileColumns.MEDIA_TYPE_PLAYLIST, false); + return 1; + } + } + + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, match, uri, extras, null); + + // Give callers interacting with a specific media item a chance to + // escalate access if they don't already have it + switch (match) { + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + enforceCallingPermission(uri, extras, true); + } + + boolean triggerInvalidate = false; + boolean triggerScan = false; + boolean isUriPublished = false; + if (initialValues != null) { + // IDs are forever; nobody should be editing them + initialValues.remove(MediaColumns._ID); + + // Expiration times are hard-coded; let's derive them + FileUtils.computeDateExpires(initialValues); + + // Ignore or augment incoming raw filesystem paths + for (String column : sDataColumns.keySet()) { + if (!initialValues.containsKey(column)) continue; + + if (isCallingPackageSelf() || isCallingPackageLegacyWrite()) { + // Mutation allowed + } else { + Log.w(TAG, "Ignoring mutation of " + column + " from " + + getCallingPackageOrSelf()); + initialValues.remove(column); + } + } + + // Enforce allowed ownership transfers + if (initialValues.containsKey(MediaColumns.OWNER_PACKAGE_NAME)) { + if (isCallingPackageSelf() || isCallingPackageShell()) { + // When the caller is the media scanner or the shell, we let + // them change ownership however they see fit; nothing to do + } else if (isCallingPackageDelegator()) { + // When the caller is a delegator, allow them to shift + // ownership only when current owner, or when ownerless + final String currentOwner; + final String proposedOwner = initialValues + .getAsString(MediaColumns.OWNER_PACKAGE_NAME); + final Uri genericUri = MediaStore.Files.getContentUri(volumeName, + ContentUris.parseId(uri)); + try (Cursor c = queryForSingleItem(genericUri, + new String[] { MediaColumns.OWNER_PACKAGE_NAME }, null, null, null)) { + currentOwner = c.getString(0); + } catch (FileNotFoundException e) { + throw new IllegalStateException(e); + } + final boolean transferAllowed = (currentOwner == null) + || Arrays.asList(getSharedPackagesForPackage(getCallingPackageOrSelf())) + .contains(currentOwner); + if (transferAllowed) { + Log.v(TAG, "Ownership transfer from " + currentOwner + " to " + + proposedOwner + " allowed"); + } else { + Log.w(TAG, "Ownership transfer from " + currentOwner + " to " + + proposedOwner + " blocked"); + initialValues.remove(MediaColumns.OWNER_PACKAGE_NAME); + } + } else { + // Otherwise no ownership changes are allowed + initialValues.remove(MediaColumns.OWNER_PACKAGE_NAME); + } + } + + if (!isCallingPackageSelf()) { + Trace.beginSection("MP.filter"); + + // We default to filtering mutable columns, except when we know + // the single item being updated is pending; when it's finally + // published we'll overwrite these values. + final Uri finalUri = uri; + final Supplier isPending = new CachedSupplier<>(() -> { + return isPending(finalUri); + }); + + // Column values controlled by media scanner aren't writable by + // apps, since any edits here don't reflect the metadata on + // disk, and they'd be overwritten during a rescan. + for (String column : new ArraySet<>(initialValues.keySet())) { + if (sMutableColumns.contains(column)) { + // Mutation normally allowed + } else if (isPending.get()) { + // Mutation relaxed while pending + } else { + Log.w(TAG, "Ignoring mutation of " + column + " from " + + getCallingPackageOrSelf()); + initialValues.remove(column); + triggerScan = true; + } + + // If we're publishing this item, perform a blocking scan to + // make sure metadata is updated + if (MediaColumns.IS_PENDING.equals(column)) { + triggerScan = true; + isUriPublished = true; + // Explicitly clear columns used to ignore no-op scans, + // since we need to force a scan on publish + initialValues.putNull(MediaColumns.DATE_MODIFIED); + initialValues.putNull(MediaColumns.SIZE); + } + } + + Trace.endSection(); + } + + if ("files".equals(qb.getTables())) { + maybeMarkAsDownload(initialValues); + } + + // We no longer track location metadata + if (initialValues.containsKey(ImageColumns.LATITUDE)) { + initialValues.putNull(ImageColumns.LATITUDE); + } + if (initialValues.containsKey(ImageColumns.LONGITUDE)) { + initialValues.putNull(ImageColumns.LONGITUDE); + } + if (getCallingPackageTargetSdkVersion() <= Build.VERSION_CODES.Q) { + // These columns are removed in R. + if (initialValues.containsKey("primary_directory")) { + initialValues.remove("primary_directory"); + } + if (initialValues.containsKey("secondary_directory")) { + initialValues.remove("secondary_directory"); + } + } + } + + // If we're not updating anything, then we can skip + if (initialValues.isEmpty()) return 0; + + final boolean isThumbnail; + switch (match) { + case IMAGES_THUMBNAILS: + case IMAGES_THUMBNAILS_ID: + case VIDEO_THUMBNAILS: + case VIDEO_THUMBNAILS_ID: + case AUDIO_ALBUMART: + case AUDIO_ALBUMART_ID: + isThumbnail = true; + break; + default: + isThumbnail = false; + break; + } + + switch (match) { + case AUDIO_PLAYLISTS: + case AUDIO_PLAYLISTS_ID: + // Playlist names are stored as display names, but leave + // values untouched if the caller is ModernMediaScanner + if (!isCallingPackageSelf()) { + if (initialValues.containsKey(Playlists.NAME)) { + initialValues.put(MediaColumns.DISPLAY_NAME, + initialValues.getAsString(Playlists.NAME)); + } + if (!initialValues.containsKey(MediaColumns.MIME_TYPE)) { + initialValues.put(MediaColumns.MIME_TYPE, "audio/mpegurl"); + } + } + break; + } + + // If we're touching columns that would change placement of a file, + // blend in current values and recalculate path + final boolean allowMovement = extras.getBoolean(MediaStore.QUERY_ARG_ALLOW_MOVEMENT, + !isCallingPackageSelf()); + if (containsAny(initialValues.keySet(), sPlacementColumns) + && !initialValues.containsKey(MediaColumns.DATA) + && !isThumbnail + && allowMovement) { + Trace.beginSection("MP.movement"); + + // We only support movement under well-defined collections + switch (match) { + case AUDIO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + case DOWNLOADS_ID: + case FILES_ID: + break; + default: + throw new IllegalArgumentException("Movement of " + uri + + " which isn't part of well-defined collection not allowed"); + } + + final LocalCallingIdentity token = clearLocalCallingIdentity(); + final Uri genericUri = MediaStore.Files.getContentUri(volumeName, + ContentUris.parseId(uri)); + try (Cursor c = queryForSingleItem(genericUri, + sPlacementColumns.toArray(new String[0]), userWhere, userWhereArgs, null)) { + for (int i = 0; i < c.getColumnCount(); i++) { + final String column = c.getColumnName(i); + if (!initialValues.containsKey(column)) { + initialValues.put(column, c.getString(i)); + } + } + } catch (FileNotFoundException e) { + throw new IllegalStateException(e); + } finally { + restoreLocalCallingIdentity(token); + } + + // Regenerate path using blended values; this will throw if caller + // is attempting to place file into invalid location + final String beforePath = initialValues.getAsString(MediaColumns.DATA); + final String beforeVolume = extractVolumeName(beforePath); + final String beforeOwner = extractPathOwnerPackageName(beforePath); + + initialValues.remove(MediaColumns.DATA); + ensureNonUniqueFileColumns(match, uri, extras, initialValues, beforePath); + + final String probePath = initialValues.getAsString(MediaColumns.DATA); + final String probeVolume = extractVolumeName(probePath); + final String probeOwner = extractPathOwnerPackageName(probePath); + if (StringUtils.equalIgnoreCase(beforePath, probePath)) { + Log.d(TAG, "Identical paths " + beforePath + "; not moving"); + } else if (!Objects.equals(beforeVolume, probeVolume)) { + throw new IllegalArgumentException("Changing volume from " + beforePath + " to " + + probePath + " not allowed"); + } else if (!isUpdateAllowedForOwnedPath(beforeOwner, probeOwner, beforePath, + probePath)) { + throw new IllegalArgumentException("Changing ownership from " + beforePath + " to " + + probePath + " not allowed"); + } else { + // Now that we've confirmed an actual movement is taking place, + // ensure we have a unique destination + initialValues.remove(MediaColumns.DATA); + ensureUniqueFileColumns(match, uri, extras, initialValues, beforePath); + + String afterPath = initialValues.getAsString(MediaColumns.DATA); + + if (isCrossUserEnabled()) { + String afterVolume = extractVolumeName(afterPath); + String afterVolumePath = extractVolumePath(afterPath); + String beforeVolumePath = extractVolumePath(beforePath); + + if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equals(beforeVolume) + && beforeVolume.equals(afterVolume) + && !beforeVolumePath.equals(afterVolumePath)) { + // On cross-user enabled devices, it can happen that a rename intended as + // /storage/emulated/999/foo -> /storage/emulated/999/foo can end up as + // /storage/emulated/999/foo -> /storage/emulated/0/foo. We now fix-up + afterPath = afterPath.replaceFirst(afterVolumePath, beforeVolumePath); + } + } + + Log.d(TAG, "Moving " + beforePath + " to " + afterPath); + try { + Os.rename(beforePath, afterPath); + invalidateFuseDentry(beforePath); + invalidateFuseDentry(afterPath); + } catch (ErrnoException e) { + if (e.errno == OsConstants.ENOENT) { + Log.d(TAG, "Missing file at " + beforePath + "; continuing anyway"); + } else { + throw new IllegalStateException(e); + } + } + initialValues.put(MediaColumns.DATA, afterPath); + + // Some indexed metadata may have been derived from the path on + // disk, so scan this item again to update it + triggerScan = true; + } + + Trace.endSection(); + } + + assertPrivatePathNotInValues(initialValues); + + // Make sure any updated paths look consistent + assertFileColumnsConsistent(match, uri, initialValues); + + if (initialValues.containsKey(FileColumns.DATA)) { + // If we're changing paths, invalidate any thumbnails + triggerInvalidate = true; + + // If the new file exists, trigger a scan to adjust any metadata + // that might be derived from the path + final String data = initialValues.getAsString(FileColumns.DATA); + if (!TextUtils.isEmpty(data) && new File(data).exists()) { + triggerScan = true; + } + } + + // If we're already doing this update from an internal scan, no need to + // kick off another no-op scan + if (isCallingPackageSelf()) { + triggerScan = false; + } + + // Since the update mutation may prevent us from matching items after + // it's applied, we need to snapshot affected IDs here + final LongArray updatedIds = new LongArray(); + if (triggerInvalidate || triggerScan) { + Trace.beginSection("MP.snapshot"); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try (Cursor c = qb.query(helper, new String[] { FileColumns._ID }, + userWhere, userWhereArgs, null, null, null, null, null)) { + while (c.moveToNext()) { + updatedIds.add(c.getLong(0)); + } + } finally { + restoreLocalCallingIdentity(token); + Trace.endSection(); + } + } + + final ContentValues values = new ContentValues(initialValues); + switch (match) { + case AUDIO_MEDIA_ID: + case AUDIO_PLAYLISTS_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + case FILES_ID: + case DOWNLOADS_ID: { + FileUtils.computeValuesFromData(values, isFuseThread()); + break; + } + } + + if (initialValues.containsKey(FileColumns.MEDIA_TYPE)) { + final int mediaType = initialValues.getAsInteger(FileColumns.MEDIA_TYPE); + switch (mediaType) { + case FileColumns.MEDIA_TYPE_AUDIO: { + computeAudioLocalizedValues(values); + computeAudioKeyValues(values); + break; + } + } + } + + boolean deferScan = false; + if (triggerScan) { + if (SdkLevel.isAtLeastS() && + CompatChanges.isChangeEnabled(ENABLE_DEFERRED_SCAN, Binder.getCallingUid())) { + if (extras.containsKey(QUERY_ARG_DO_ASYNC_SCAN)) { + throw new IllegalArgumentException("Unsupported argument " + + QUERY_ARG_DO_ASYNC_SCAN + " used in extras"); + } + deferScan = extras.getBoolean(QUERY_ARG_DEFER_SCAN, false); + if (deferScan && initialValues.containsKey(MediaColumns.IS_PENDING) && + (initialValues.getAsInteger(MediaColumns.IS_PENDING) == 1)) { + // if the scan runs in async, ensure that the database row is excluded in + // default query until the metadata is updated by deferred scan. + // Apps will still be able to see this database row when queried with + // QUERY_ARG_MATCH_PENDING=MATCH_INCLUDE + values.put(FileColumns._MODIFIER, FileColumns._MODIFIER_CR_PENDING_METADATA); + qb.allowColumn(FileColumns._MODIFIER); + } + } else { + // Allow apps to use QUERY_ARG_DO_ASYNC_SCAN if the device is R or app is targeting + // targetSDK<=R. + deferScan = extras.getBoolean(QUERY_ARG_DO_ASYNC_SCAN, false); + } + } + + count = updateAllowingReplace(qb, helper, values, userWhere, userWhereArgs); + + // If the caller tried (and failed) to update metadata, the file on disk + // might have changed, to scan it to collect the latest metadata. + if (triggerInvalidate || triggerScan) { + Trace.beginSection("MP.invalidate"); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + for (int i = 0; i < updatedIds.size(); i++) { + final long updatedId = updatedIds.get(i); + final Uri updatedUri = Files.getContentUri(volumeName, updatedId); + helper.postBackground(() -> { + invalidateThumbnails(updatedUri); + }); + + if (triggerScan) { + try (Cursor c = queryForSingleItem(updatedUri, + new String[] { FileColumns.DATA }, null, null, null)) { + final File file = new File(c.getString(0)); + final boolean notifyTranscodeHelper = isUriPublished; + if (deferScan) { + helper.postBackground(() -> { + scanFileAsMediaProvider(file, REASON_DEMAND); + if (notifyTranscodeHelper) { + notifyTranscodeHelperOnUriPublished(updatedUri, file); + } + }); + } else { + helper.postBlocking(() -> { + scanFileAsMediaProvider(file, REASON_DEMAND); + if (notifyTranscodeHelper) { + notifyTranscodeHelperOnUriPublished(updatedUri, file); + } + }); + } + } catch (Exception e) { + Log.w(TAG, "Failed to update metadata for " + updatedUri, e); + } + } + } + } finally { + restoreLocalCallingIdentity(token); + Trace.endSection(); + } + } + + return count; + } + + private boolean isUpdateAllowedForOwnedPath(@Nullable String srcOwner, + @Nullable String destOwner, @NonNull String srcPath, @NonNull String destPath) { + // 1. Allow if the update is within owned path + // update() from /sdcard/Android/media/com.foo/ABC/image.jpeg to + // /sdcard/Android/media/com.foo/XYZ/image.jpeg - Allowed + if(Objects.equals(srcOwner, destOwner)) { + return true; + } + + // 2. Check if the calling package is a special app which has global access + if (isCallingPackageManager() || (canSystemGalleryAccessTheFile(srcPath) && + (canSystemGalleryAccessTheFile(destPath)))) { + return true; + } + + // 3. Allow update from srcPath if the source is not a owned path or calling package is the + // owner of the source path or calling package shares the UID with the owner of the source + // path + // update() from /sdcard/DCIM/Foo.jpeg - Allowed + // update() from /sdcard/Android/media/com.foo/image.jpeg - Allowed for + // callingPackage=com.foo, not allowed for callingPackage=com.bar + final boolean isSrcUpdateAllowed = srcOwner == null + || isCallingIdentitySharedPackageName(srcOwner); + + // 4. Allow update to dstPath if the destination is not a owned path or calling package is + // the owner of the destination path or calling package shares the UID with the owner of the + // destination path + // update() to /sdcard/Pictures/image.jpeg - Allowed + // update() to /sdcard/Android/media/com.foo/image.jpeg - Allowed for + // callingPackage=com.foo, not allowed for callingPackage=com.bar + final boolean isDestUpdateAllowed = destOwner == null + || isCallingIdentitySharedPackageName(destOwner); + + return isSrcUpdateAllowed && isDestUpdateAllowed; + } + + private void notifyTranscodeHelperOnUriPublished(Uri uri, File file) { + if (!mTranscodeHelper.supportsTranscode(file.getPath())) { + return; + } + + BackgroundThread.getExecutor().execute(() -> { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + mTranscodeHelper.onUriPublished(uri); + } finally { + restoreLocalCallingIdentity(token); + } + }); + } + + private void notifyTranscodeHelperOnFileOpen(String path, String ioPath, int uid, + int transformsReason) { + if (!mTranscodeHelper.supportsTranscode(path)) { + return; + } + + BackgroundThread.getExecutor().execute(() -> { + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + mTranscodeHelper.onFileOpen(path, ioPath, uid, transformsReason); + } finally { + restoreLocalCallingIdentity(token); + } + }); + } + + /** + * Update row(s) that match {@code userWhere} in MediaProvider database with {@code values}. + * Treats update as replace for updates with conflicts. + */ + private int updateAllowingReplace(@NonNull SQLiteQueryBuilder qb, + @NonNull DatabaseHelper helper, @NonNull ContentValues values, String userWhere, + String[] userWhereArgs) throws SQLiteConstraintException { + return helper.runWithTransaction((db) -> { + try { + return qb.update(helper, values, userWhere, userWhereArgs); + } catch (SQLiteConstraintException e) { + // b/155320967 Apps sometimes create a file via file path and then update another + // explicitly inserted db row to this file. We have to resolve this update with a + // replace. + + if (getCallingPackageTargetSdkVersion() >= Build.VERSION_CODES.R) { + // We don't support replace for non-legacy apps. Non legacy apps should have + // clearer interactions with MediaProvider. + throw e; + } + + final String path = values.getAsString(FileColumns.DATA); + + // We will only handle UNIQUE constraint error for FileColumns.DATA. We will not try + // update and replace if no file exists for conflicting db row. + if (path == null || !new File(path).exists()) { + throw e; + } + + final Uri uri = FileUtils.getContentUriForPath(path); + final boolean allowHidden = isCallingPackageAllowedHidden(); + // The db row which caused UNIQUE constraint error may not match all column values + // of the given queryBuilder, hence using a generic queryBuilder with Files uri. + Bundle extras = new Bundle(); + extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_INCLUDE); + extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_INCLUDE); + final SQLiteQueryBuilder qbForReplace = getQueryBuilder(TYPE_DELETE, + matchUri(uri, allowHidden), uri, extras, null); + final long rowId = getIdIfPathOwnedByPackages(qbForReplace, helper, path, + mCallingIdentity.get().getSharedPackagesAsString()); + + if (rowId != -1 && qbForReplace.delete(helper, "_id=?", + new String[] {Long.toString(rowId)}) == 1) { + Log.i(TAG, "Retrying database update after deleting conflicting entry"); + return qb.update(helper, values, userWhere, userWhereArgs); + } + // Rethrow SQLiteConstraintException if app doesn't own the conflicting db row. + throw e; + } + }); + } + + /** + * Update the internal table of {@link MediaStore.Audio.Playlists.Members} + * by parsing the playlist file on disk and resolving it against scanned + * audio items. + *

        + * When a playlist references a missing audio item, the associated + * {@link Playlists.Members#PLAY_ORDER} is skipped, leaving a gap to ensure + * that the playlist entry is retained to avoid user data loss. + */ + private void resolvePlaylistMembers(@NonNull Uri playlistUri) { + Trace.beginSection("MP.resolvePlaylistMembers"); + try { + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(playlistUri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + helper.runWithTransaction((db) -> { + resolvePlaylistMembersInternal(playlistUri, db); + return null; + }); + } finally { + Trace.endSection(); + } + } + + private void resolvePlaylistMembersInternal(@NonNull Uri playlistUri, + @NonNull SQLiteDatabase db) { + try { + // Refresh playlist members based on what we parse from disk + final long playlistId = ContentUris.parseId(playlistUri); + final Map membersMap = getAllPlaylistMembers(playlistId); + db.delete("audio_playlists_map", "playlist_id=" + playlistId, null); + + final Path playlistPath = queryForDataFile(playlistUri, null).toPath(); + final Playlist playlist = new Playlist(); + playlist.read(playlistPath.toFile()); + + final List members = playlist.asList(); + for (int i = 0; i < members.size(); i++) { + try { + final Path audioPath = playlistPath.getParent().resolve(members.get(i)); + final long audioId = queryForPlaylistMember(audioPath, membersMap); + + final ContentValues values = new ContentValues(); + values.put(Playlists.Members.PLAY_ORDER, i + 1); + values.put(Playlists.Members.PLAYLIST_ID, playlistId); + values.put(Playlists.Members.AUDIO_ID, audioId); + db.insert("audio_playlists_map", null, values); + } catch (IOException e) { + Log.w(TAG, "Failed to resolve playlist member", e); + } + } + } catch (IOException e) { + Log.w(TAG, "Failed to refresh playlist", e); + } + } + + private Map getAllPlaylistMembers(long playlistId) { + final Map membersMap = new ArrayMap<>(); + + final Uri uri = Playlists.Members.getContentUri(MediaStore.VOLUME_EXTERNAL, playlistId); + final String[] projection = new String[] { + Playlists.Members.DATA, + Playlists.Members.AUDIO_ID + }; + try (Cursor c = query(uri, projection, null, null)) { + if (c == null) { + Log.e(TAG, "Cursor is null, failed to create cached playlist member info."); + return membersMap; + } + while (c.moveToNext()) { + membersMap.put(c.getString(0), c.getLong(1)); + } + } + return membersMap; + } + + /** + * Make two attempts to query this playlist member: first based on the exact + * path, and if that fails, fall back to picking a single item matching the + * display name. When there are multiple items with the same display name, + * we can't resolve between them, and leave this member unresolved. + */ + private long queryForPlaylistMember(@NonNull Path path, @NonNull Map membersMap) + throws IOException { + final String data = path.toFile().getCanonicalPath(); + if (membersMap.containsKey(data)) { + return membersMap.get(data); + } + final Uri audioUri = Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL); + try (Cursor c = queryForSingleItem(audioUri, + new String[] { BaseColumns._ID }, MediaColumns.DATA + "=?", + new String[] { data }, null)) { + return c.getLong(0); + } catch (FileNotFoundException ignored) { + } + try (Cursor c = queryForSingleItem(audioUri, + new String[] { BaseColumns._ID }, MediaColumns.DISPLAY_NAME + "=?", + new String[] { path.toFile().getName() }, null)) { + return c.getLong(0); + } catch (FileNotFoundException ignored) { + } + throw new FileNotFoundException(); + } + + /** + * Add the given audio item to the given playlist. Defaults to adding at the + * end of the playlist when no {@link Playlists.Members#PLAY_ORDER} is + * defined. + */ + private long addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values) + throws FallbackException { + final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID); + final String volumeName = MediaStore.VOLUME_INTERNAL.equals(getVolumeName(playlistUri)) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + final Uri audioUri = Audio.Media.getContentUri(volumeName, audioId); + + Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER); + playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE; + + try { + final File playlistFile = queryForDataFile(playlistUri, null); + final File audioFile = queryForDataFile(audioUri, null); + + final Playlist playlist = new Playlist(); + playlist.read(playlistFile); + playOrder = playlist.add(playOrder, + playlistFile.toPath().getParent().relativize(audioFile.toPath())); + playlist.write(playlistFile); + invalidateFuseDentry(playlistFile); + + resolvePlaylistMembers(playlistUri); + + // Callers are interested in the actual ID we generated + final Uri membersUri = Playlists.Members.getContentUri(volumeName, + ContentUris.parseId(playlistUri)); + try (Cursor c = query(membersUri, new String[] { BaseColumns._ID }, + Playlists.Members.PLAY_ORDER + "=" + (playOrder + 1), null, null)) { + c.moveToFirst(); + return c.getLong(0); + } + } catch (IOException e) { + throw new FallbackException("Failed to update playlist", e, + android.os.Build.VERSION_CODES.R); + } + } + + private int addPlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues[] initialValues) + throws FallbackException { + final String volumeName = getVolumeName(playlistUri); + final String audioVolumeName = + MediaStore.VOLUME_INTERNAL.equals(volumeName) + ? MediaStore.VOLUME_INTERNAL : MediaStore.VOLUME_EXTERNAL; + + try { + final File playlistFile = queryForDataFile(playlistUri, null); + final Playlist playlist = new Playlist(); + playlist.read(playlistFile); + + for (ContentValues values : initialValues) { + final long audioId = values.getAsLong(Audio.Playlists.Members.AUDIO_ID); + final Uri audioUri = Audio.Media.getContentUri(audioVolumeName, audioId); + final File audioFile = queryForDataFile(audioUri, null); + + Integer playOrder = values.getAsInteger(Playlists.Members.PLAY_ORDER); + playOrder = (playOrder != null) ? (playOrder - 1) : Integer.MAX_VALUE; + playlist.add(playOrder, + playlistFile.toPath().getParent().relativize(audioFile.toPath())); + } + playlist.write(playlistFile); + + resolvePlaylistMembers(playlistUri); + } catch (IOException e) { + throw new FallbackException("Failed to update playlist", e, + android.os.Build.VERSION_CODES.R); + } + + return initialValues.length; + } + + /** + * Move an audio item within the given playlist. + */ + private int movePlaylistMembers(@NonNull Uri playlistUri, @NonNull ContentValues values, + @NonNull Bundle queryArgs) throws FallbackException { + final int fromIndex = resolvePlaylistIndex(playlistUri, queryArgs); + final int toIndex = values.getAsInteger(Playlists.Members.PLAY_ORDER) - 1; + if (fromIndex == -1) { + throw new FallbackException("Failed to resolve playlist member " + queryArgs, + android.os.Build.VERSION_CODES.R); + } + try { + final File playlistFile = queryForDataFile(playlistUri, null); + + final Playlist playlist = new Playlist(); + playlist.read(playlistFile); + final int finalIndex = playlist.move(fromIndex, toIndex); + playlist.write(playlistFile); + invalidateFuseDentry(playlistFile); + + resolvePlaylistMembers(playlistUri); + return finalIndex; + } catch (IOException e) { + throw new FallbackException("Failed to update playlist", e, + android.os.Build.VERSION_CODES.R); + } + } + + /** + * Removes an audio item or multiple audio items(if targetSDK { + final String where; + if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) { + where = "playlist_id=?"; + } else { + where = "audio_id=?"; + } + db.delete("audio_playlists_map", where, new String[] { "" + id }); + return null; + }); + } + + /** + * Resolve query arguments that are designed to select specific playlist + * items using the playlist's {@link Playlists.Members#PLAY_ORDER}. + * + * @return an array of the indexes that match the query. + */ + private int[] resolvePlaylistIndexes(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) { + final Uri membersUri = Playlists.Members.getContentUri( + getVolumeName(playlistUri), ContentUris.parseId(playlistUri)); + + final DatabaseHelper helper; + final SQLiteQueryBuilder qb; + try { + helper = getDatabaseForUri(membersUri); + qb = getQueryBuilder(TYPE_DELETE, AUDIO_PLAYLISTS_ID_MEMBERS, + membersUri, queryArgs, null); + } catch (VolumeNotFoundException ignored) { + return new int[0]; + } + + try (Cursor c = qb.query(helper, + new String[] { Playlists.Members.PLAY_ORDER }, queryArgs, null)) { + if ((c.getCount() >= 1) && c.moveToFirst()) { + int size = c.getCount(); + int[] res = new int[size]; + for (int i = 0; i < size; ++i) { + res[i] = c.getInt(0) - 1; + c.moveToNext(); + } + return res; + } else { + // Cursor size is 0 + return new int[0]; + } + } + } + + /** + * Resolve query arguments that are designed to select a specific playlist + * item using its {@link Playlists.Members#PLAY_ORDER}. + * + * @return if there's only 1 item that matches the query, returns its index. Returns -1 + * otherwise. + */ + private int resolvePlaylistIndex(@NonNull Uri playlistUri, @NonNull Bundle queryArgs) { + int[] indexes = resolvePlaylistIndexes(playlistUri, queryArgs); + if (indexes.length == 1) { + return indexes[0]; + } + return -1; + } + + private boolean isPickerUri(Uri uri) { + final int match = matchUri(uri, /* allowHidden */ isCallingPackageAllowedHidden()); + return match == PICKER_ID || match == PICKER_GET_CONTENT_ID; + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException { + return openFileCommon(uri, mode, /*signal*/ null, /*opts*/ null); + } + + @Override + public ParcelFileDescriptor openFile(Uri uri, String mode, CancellationSignal signal) + throws FileNotFoundException { + return openFileCommon(uri, mode, signal, /*opts*/ null); + } + + private ParcelFileDescriptor openFileCommon(Uri uri, String mode, CancellationSignal signal, + @Nullable Bundle opts) + throws FileNotFoundException { + opts = opts == null ? new Bundle() : opts; + // REDACTED_URI_BUNDLE_KEY extra should only be set inside MediaProvider. + opts.remove(QUERY_ARG_REDACTED_URI); + if (isRedactedUri(uri)) { + opts.putParcelable(QUERY_ARG_REDACTED_URI, uri); + uri = getUriForRedactedUri(uri); + } + uri = safeUncanonicalize(uri); + + if (isPickerUri(uri)) { + int tid = Process.myTid(); + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.put(tid, new PendingOpenInfo( + Binder.getCallingUid(), /* mediaCapabilitiesUid */ 0, /* shouldRedact */ + false, /* transcodeReason */ 0)); + } + + try { + return mPickerUriResolver.openFile(uri, mode, signal, mCallingIdentity.get()); + } finally { + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.remove(tid); + } + } + } + + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + final String volumeName = getVolumeName(uri); + + // Handle some legacy cases where we need to redirect thumbnails + try { + switch (match) { + case AUDIO_ALBUMART_ID: { + final long albumId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Audio.Albums.getContentUri(volumeName), albumId); + return ensureThumbnail(targetUri, signal); + } + case AUDIO_ALBUMART_FILE_ID: { + final long audioId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Audio.Media.getContentUri(volumeName), audioId); + return ensureThumbnail(targetUri, signal); + } + case VIDEO_MEDIA_ID_THUMBNAIL: { + final long videoId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Video.Media.getContentUri(volumeName), videoId); + return ensureThumbnail(targetUri, signal); + } + case IMAGES_MEDIA_ID_THUMBNAIL: { + final long imageId = Long.parseLong(uri.getPathSegments().get(3)); + final Uri targetUri = ContentUris + .withAppendedId(Images.Media.getContentUri(volumeName), imageId); + return ensureThumbnail(targetUri, signal); + } + case CLI: { + // Command Line Interface "file" - content://media/cli - may be opened only by + // Shell (or Root). + if (!isCallingPackageShell()) { + throw new SecurityException("Only shell (or root) is allowed to open " + + "MediaProvider's CLI file (" + uri + ')'); + } + + // We expect the uri's query to hold a single parameter - "cmd" - which contains + // the command name followed by the arguments (if any), all joined with '+' + // symbols: + // ?cmd=command[+arg1+[arg2+[arg3...]]] + // + // For example: + // (1) ?cmd=version + // (2) ?cmd=set-cloud-provider=com.my.cloud.provider.authority + // + // We retrieve the command name and the argument (if any) with + // uri.getQueryParameter("cmd") call, which will replace all '+' delimiters with + // spaces. + + final String[] cmdAndArgs = uri.getQueryParameter("cmd").split("\\s+"); + Log.d(TAG, "MediaProvider CLI command: " + Arrays.toString(cmdAndArgs)); + try { + // Create a UNIX pipe. + final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); + // Pass the write end - pipe[1] - to our shell command. + final var cmd = new MediaProviderShellCommand(getContext(), + mConfigStore, + mPickerSyncController, + /* out */ pipe[1]); + cmd.exec(cmdAndArgs); + // Return the read end - pipe[0] - to the caller. + return pipe[0]; + } catch (IOException e) { + Log.e(TAG, "Could not create a pipe", e); + return null; + } + } + } + } finally { + // We have to log separately here because openFileAndEnforcePathPermissionsHelper calls + // a public MediaProvider API and so logs the access there. + PulledMetrics.logVolumeAccessViaMediaProvider(getCallingUidOrSelf(), volumeName); + } + + return openFileAndEnforcePathPermissionsHelper(uri, match, mode, signal, opts); + } + + @Override + public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts) + throws FileNotFoundException { + return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, null); + } + + @Override + public AssetFileDescriptor openTypedAssetFile(Uri uri, String mimeTypeFilter, Bundle opts, + CancellationSignal signal) throws FileNotFoundException { + return openTypedAssetFileCommon(uri, mimeTypeFilter, opts, signal); + } + + private AssetFileDescriptor openTypedAssetFileCommon(Uri uri, String mimeTypeFilter, + Bundle opts, CancellationSignal signal) throws FileNotFoundException { + final boolean wantsThumb = (opts != null) && opts.containsKey(ContentResolver.EXTRA_SIZE) + && StringUtils.startsWithIgnoreCase(mimeTypeFilter, "image/"); + String mode = "r"; + + // If request is not for thumbnail and arising from MediaProvider, then check for EXTRA_MODE + if (opts != null && !wantsThumb && isCallingPackageSelf()) { + mode = opts.getString(MediaStore.EXTRA_MODE, "r"); + } else if (opts != null) { + opts.remove(MediaStore.EXTRA_MODE); + } + + if (opts != null && opts.containsKey(MediaStore.EXTRA_FILE_DESCRIPTOR)) { + // This is called as part of MediaStore#getOriginalMediaFormatFileDescriptor + // We don't need to use the |uri| because the input fd already identifies the file and + // we actually don't have a valid URI, we are going to identify the file via the fd. + // While identifying the file, we also perform the following security checks. + // 1. Find the FUSE file with the associated inode + // 2. Verify that the binder caller opened it + // 3. Verify the access level the fd is opened with (r/w) + // 4. Open the original (non-transcoded) file *with* redaction enabled and the access + // level from #3 + // 5. Return the fd from #4 to the app or throw an exception if any of the conditions + // are not met + try { + return getOriginalMediaFormatFileDescriptor(opts); + } finally { + // Clearing the Bundle closes the underlying Parcel, ensuring that the input fd + // owned by the Parcel is closed immediately and not at the next GC. + // This works around a change in behavior introduced by: + // aosp/Icfe8880cad00c3cd2afcbe4b92400ad4579e680e + opts.clear(); + } + } + + // This is needed for thumbnail resolution as it doesn't go through openFileCommon + if (isPickerUri(uri)) { + int tid = Process.myTid(); + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.put(tid, new PendingOpenInfo( + Binder.getCallingUid(), /* mediaCapabilitiesUid */ 0, /* shouldRedact */ + false, /* transcodeReason */ 0)); + } + + try { + return mPickerUriResolver.openTypedAssetFile(uri, mimeTypeFilter, opts, signal, + mCallingIdentity.get()); + } finally { + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.remove(tid); + } + } + } + + // TODO: enforce that caller has access to this uri + + // Offer thumbnail of media, when requested + if (wantsThumb) { + final ParcelFileDescriptor pfd = ensureThumbnail(uri, signal); + return new AssetFileDescriptor(pfd, 0, AssetFileDescriptor.UNKNOWN_LENGTH); + } + + // Worst case, return the underlying file + return new AssetFileDescriptor(openFileCommon(uri, mode, signal, opts), 0, + AssetFileDescriptor.UNKNOWN_LENGTH); + } + + private ParcelFileDescriptor ensureThumbnail(Uri uri, CancellationSignal signal) + throws FileNotFoundException { + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int match = matchUri(uri, allowHidden); + + Trace.beginSection("MP.ensureThumbnail"); + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + switch (match) { + case AUDIO_ALBUMS_ID: { + final String volumeName = MediaStore.getVolumeName(uri); + final Uri baseUri = MediaStore.Audio.Media.getContentUri(volumeName); + final long albumId = ContentUris.parseId(uri); + try (Cursor c = query(baseUri, new String[] { MediaStore.Audio.Media._ID }, + MediaStore.Audio.Media.ALBUM_ID + "=" + albumId, null, null, signal)) { + if (c.moveToFirst()) { + final long audioId = c.getLong(0); + final Uri targetUri = ContentUris.withAppendedId(baseUri, audioId); + return mAudioThumbnailer.ensureThumbnail(targetUri, signal); + } else { + throw new FileNotFoundException("No media for album " + uri); + } + } + } + case AUDIO_MEDIA_ID: + return mAudioThumbnailer.ensureThumbnail(uri, signal); + case VIDEO_MEDIA_ID: + return mVideoThumbnailer.ensureThumbnail(uri, signal); + case IMAGES_MEDIA_ID: + return mImageThumbnailer.ensureThumbnail(uri, signal); + case FILES_ID: + case DOWNLOADS_ID: { + // When item is referenced in a generic way, resolve to actual type + final int mediaType = MimeUtils.resolveMediaType(getType(uri)); + switch (mediaType) { + case FileColumns.MEDIA_TYPE_AUDIO: + return mAudioThumbnailer.ensureThumbnail(uri, signal); + case FileColumns.MEDIA_TYPE_VIDEO: + return mVideoThumbnailer.ensureThumbnail(uri, signal); + case FileColumns.MEDIA_TYPE_IMAGE: + return mImageThumbnailer.ensureThumbnail(uri, signal); + default: + throw new FileNotFoundException(); + } + } + default: + throw new FileNotFoundException(); + } + } catch (IOException e) { + Log.w(TAG, e); + throw new FileNotFoundException(e.getMessage()); + } finally { + restoreLocalCallingIdentity(token); + Trace.endSection(); + } + } + + /** + * Update the metadata columns for the image residing at given {@link Uri} + * by reading data from the underlying image. + */ + private void updateImageMetadata(ContentValues values, File file) { + final BitmapFactory.Options bitmapOpts = new BitmapFactory.Options(); + bitmapOpts.inJustDecodeBounds = true; + BitmapFactory.decodeFile(file.getAbsolutePath(), bitmapOpts); + + values.put(MediaColumns.WIDTH, bitmapOpts.outWidth); + values.put(MediaColumns.HEIGHT, bitmapOpts.outHeight); + } + + private void handleInsertedRowForFuse(long rowId) { + if (isFuseThread()) { + // Removes restored row ID saved list. + mCallingIdentity.get().removeDeletedRowId(rowId); + } + } + + private void handleUpdatedRowForFuse(@NonNull String oldPath, @NonNull String ownerPackage, + long oldRowId, long newRowId) { + if (oldRowId == newRowId) { + // Update didn't delete or add row ID. We don't need to save row ID or remove saved + // deleted ID. + return; + } + + handleDeletedRowForFuse(oldPath, ownerPackage, oldRowId); + handleInsertedRowForFuse(newRowId); + } + + private void handleDeletedRowForFuse(@NonNull String path, @NonNull String ownerPackage, + long rowId) { + if (!isFuseThread()) { + return; + } + + // Invalidate saved owned ID's of the previous owner of the deleted path, this prevents old + // owner from gaining access to newly created file with restored row ID. + if (!ownerPackage.equals("null") && !ownerPackage.equals(getCallingPackageOrSelf())) { + invalidateLocalCallingIdentityCache(ownerPackage, "owned_database_row_deleted:" + + path); + } + // Saves row ID corresponding to deleted path. Saved row ID will be restored on subsequent + // create or rename. + mCallingIdentity.get().addDeletedRowId(path, rowId); + } + + private void handleOwnerPackageNameChange(@NonNull String oldPath, + @NonNull String oldOwnerPackage, @NonNull String newOwnerPackage) { + if (Objects.equals(oldOwnerPackage, newOwnerPackage)) { + return; + } + // Invalidate saved owned ID's of the previous owner of the renamed path, this prevents old + // owner from gaining access to replaced file. + invalidateLocalCallingIdentityCache(oldOwnerPackage, "owner_package_changed:" + oldPath); + } + + /** + * Return the {@link MediaColumns#DATA} field for the given {@code Uri}. + */ + File queryForDataFile(Uri uri, CancellationSignal signal) + throws FileNotFoundException { + return queryForDataFile(uri, null, null, signal); + } + + /** + * Return the {@link MediaColumns#DATA} field for the given {@code Uri}. + */ + File queryForDataFile(Uri uri, String selection, String[] selectionArgs, + CancellationSignal signal) throws FileNotFoundException { + try (Cursor cursor = queryForSingleItem(uri, new String[] { MediaColumns.DATA }, + selection, selectionArgs, signal)) { + final String data = cursor.getString(0); + if (TextUtils.isEmpty(data)) { + throw new FileNotFoundException("Missing path for " + uri); + } else { + return new File(data); + } + } + } + + /** + * Return the {@link Uri} for the given {@code File}. + */ + Uri queryForMediaUri(File file, CancellationSignal signal) throws FileNotFoundException { + final String volumeName = FileUtils.getVolumeName(getContext(), file); + final Uri uri = Files.getContentUri(volumeName); + try (Cursor cursor = queryForSingleItem(uri, new String[] { MediaColumns._ID }, + MediaColumns.DATA + "=?", new String[] { file.getAbsolutePath() }, signal)) { + return ContentUris.withAppendedId(uri, cursor.getLong(0)); + } + } + + /** + * Query the given {@link Uri} as MediaProvider, expecting only a single item to be found. + * + * @throws FileNotFoundException if no items were found, or multiple items + * were found, or there was trouble reading the data. + */ + Cursor queryForSingleItemAsMediaProvider(Uri uri, String[] projection, String selection, + String[] selectionArgs, CancellationSignal signal) + throws FileNotFoundException { + final LocalCallingIdentity tokenInner = clearLocalCallingIdentity(); + try { + return queryForSingleItem(uri, projection, selection, selectionArgs, signal); + } finally { + restoreLocalCallingIdentity(tokenInner); + } + } + + /** + * Query the given {@link Uri}, expecting only a single item to be found. + * + * @throws FileNotFoundException if no items were found, or multiple items + * were found, or there was trouble reading the data. + */ + Cursor queryForSingleItem(Uri uri, String[] projection, String selection, + String[] selectionArgs, CancellationSignal signal) throws FileNotFoundException { + Cursor c = null; + try { + c = query(uri, projection, + DatabaseUtils.createSqlQueryBundle(selection, selectionArgs, null), + signal, true); + } catch (IllegalArgumentException e) { + throw new FileNotFoundException("Volume not found for " + uri); + } + if (c == null) { + throw new FileNotFoundException("Missing cursor for " + uri); + } else if (c.getCount() < 1) { + FileUtils.closeQuietly(c); + throw new FileNotFoundException("No item at " + uri); + } else if (c.getCount() > 1) { + FileUtils.closeQuietly(c); + throw new FileNotFoundException("Multiple items at " + uri); + } + + if (c.moveToFirst()) { + return c; + } else { + FileUtils.closeQuietly(c); + throw new FileNotFoundException("Failed to read row from " + uri); + } + } + + /** + * Compares {@code itemOwner} with package name of {@link LocalCallingIdentity} and throws + * {@link IllegalStateException} if it doesn't match. + * Make sure to set calling identity properly before calling. + */ + private void requireOwnershipForItem(@Nullable String itemOwner, Uri item) { + final boolean hasOwner = (itemOwner != null); + final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), itemOwner); + if (hasOwner && !callerIsOwner) { + throw new IllegalStateException( + "Only owner is able to interact with pending/trashed item " + item); + } + } + + private ParcelFileDescriptor openWithFuse(String filePath, int uid, int mediaCapabilitiesUid, + int modeBits, boolean shouldRedact, boolean shouldTranscode, int transcodeReason) + throws FileNotFoundException { + Log.d(TAG, "Open with FUSE. FilePath: " + filePath + + ". Uid: " + uid + + ". Media Capabilities Uid: " + mediaCapabilitiesUid + + ". ShouldRedact: " + shouldRedact + + ". ShouldTranscode: " + shouldTranscode); + + int tid = android.os.Process.myTid(); + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.put(tid, + new PendingOpenInfo(uid, mediaCapabilitiesUid, shouldRedact, transcodeReason)); + } + + try { + return FileUtils.openSafely(toFuseFile(new File(filePath)), modeBits); + } finally { + synchronized (mPendingOpenInfo) { + mPendingOpenInfo.remove(tid); + } + } + } + + /** + * @return {@link FuseDaemon} corresponding to a given file + */ + @NonNull + public static FuseDaemon getFuseDaemonForFile(@NonNull File file, VolumeCache volumeCache) + throws FileNotFoundException { + final FuseDaemon daemon = ExternalStorageServiceImpl.getFuseDaemon( + volumeCache.getVolumeId(file)); + if (daemon == null) { + throw new FileNotFoundException("Missing FUSE daemon for " + file); + } else { + return daemon; + } + } + + @NonNull + public static FuseDaemon getFuseDaemonForFileWithWait(@NonNull File file, + VolumeCache volumeCache, long waitTimeInMilliseconds) throws FileNotFoundException { + FuseDaemon fuseDaemon = null; + long time = 0; + while (time < waitTimeInMilliseconds) { + fuseDaemon = ExternalStorageServiceImpl.getFuseDaemon( + volumeCache.getVolumeId(file)); + if (fuseDaemon != null) { + break; + } + SystemClock.sleep(POLLING_TIME_IN_MILLIS); + time += POLLING_TIME_IN_MILLIS; + } + + if (fuseDaemon == null) { + throw new FileNotFoundException("Missing FUSE daemon for " + file); + } else { + return fuseDaemon; + } + } + + private void invalidateFuseDentry(@NonNull File file) { + invalidateFuseDentry(file.getAbsolutePath()); + } + + private void invalidateFuseDentry(@NonNull String path) { + try { + final FuseDaemon daemon = getFuseDaemonForFile(new File(path), mVolumeCache); + if (isFuseThread()) { + // If we are on a FUSE thread, we don't need to invalidate, + // (and *must* not, otherwise we'd crash) because the invalidation + // is already reflected in the lower filesystem + return; + } else { + daemon.invalidateFuseDentryCache(path); + } + } catch (FileNotFoundException e) { + Log.w(TAG, "Failed to invalidate FUSE dentry", e); + } + } + + /** + * Replacement for {@link #openFileHelper(Uri, String)} which enforces any + * permissions applicable to the path before returning. + * + *

        This function should never be called from the fuse thread since it tries to open + * a "/mnt/user" path. + */ + private ParcelFileDescriptor openFileAndEnforcePathPermissionsHelper(Uri uri, int match, + String mode, CancellationSignal signal, @NonNull Bundle opts) + throws FileNotFoundException { + int modeBits = ParcelFileDescriptor.parseMode(mode); + boolean forWrite = (modeBits & ParcelFileDescriptor.MODE_WRITE_ONLY) != 0; + final Uri redactedUri = opts.getParcelable(QUERY_ARG_REDACTED_URI); + if (forWrite) { + if (redactedUri != null) { + throw new UnsupportedOperationException( + "Write is not supported on " + redactedUri.toString()); + } + // Upgrade 'w' only to 'rw'. This allows us acquire a WR_LOCK when calling + // #shouldOpenWithFuse + modeBits |= ParcelFileDescriptor.MODE_READ_WRITE; + } + + final boolean hasOwnerPackageName = hasOwnerPackageName(uri); + final String[] projection = new String[] { + MediaColumns.DATA, + hasOwnerPackageName ? MediaColumns.OWNER_PACKAGE_NAME : "NULL", + hasOwnerPackageName ? MediaColumns.IS_PENDING : "0", + }; + + final File file; + final String ownerPackageName; + final boolean isPending; + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try (Cursor c = queryForSingleItem(uri, projection, null, null, signal)) { + final String data = c.getString(0); + if (TextUtils.isEmpty(data)) { + throw new FileNotFoundException("Missing path for " + uri); + } else { + file = new File(data).getCanonicalFile(); + } + ownerPackageName = c.getString(1); + isPending = c.getInt(2) != 0; + } catch (IOException e) { + throw new FileNotFoundException(e.toString()); + } finally { + restoreLocalCallingIdentity(token); + } + + if (redactedUri == null) { + checkAccess(uri, Bundle.EMPTY, file, forWrite); + } else { + checkAccess(redactedUri, Bundle.EMPTY, file, false); + } + + // We don't check ownership for files with IS_PENDING set by FUSE + if (isPending && !isPendingFromFuse(file)) { + requireOwnershipForItem(ownerPackageName, uri); + } + + // Figure out if we need to redact contents + final boolean redactionNeeded = isRedactionNeededForOpenViaContentResolver(redactedUri, + ownerPackageName, file); + final RedactionInfo redactionInfo; + try { + redactionInfo = redactionNeeded ? getRedactionRanges(file) + : new RedactionInfo(new long[0], new long[0]); + } catch (IOException e) { + throw new IllegalStateException(e); + } + + // Yell if caller requires original, since we can't give it to them + // unless they have access granted above + if (redactionNeeded && MediaStore.getRequireOriginal(uri)) { + throw new UnsupportedOperationException( + "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"); + } + + // Kick off metadata update when writing is finished + final OnCloseListener listener = (e) -> { + // We always update metadata to reflect the state on disk, even when + // the remote writer tried claiming an exception + invalidateThumbnails(uri); + + // Invalidate so subsequent stat(2) on the upper fs is eventually consistent + invalidateFuseDentry(file); + try { + switch (match) { + case IMAGES_THUMBNAILS_ID: + case VIDEO_THUMBNAILS_ID: + final ContentValues values = new ContentValues(); + updateImageMetadata(values, file); + update(uri, values, null, null); + break; + default: + scanFileAsMediaProvider(file, REASON_DEMAND); + break; + } + } catch (Exception e2) { + Log.w(TAG, "Failed to update metadata for " + uri, e2); + } + }; + + try { + // First, handle any redaction that is needed for caller + final ParcelFileDescriptor pfd; + final String filePath = file.getPath(); + final int uid = Binder.getCallingUid(); + final int transcodeReason = mTranscodeHelper.shouldTranscode(filePath, uid, opts); + final boolean shouldTranscode = transcodeReason > 0; + int mediaCapabilitiesUid = opts.getInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID); + if (!shouldTranscode || mediaCapabilitiesUid < Process.FIRST_APPLICATION_UID) { + // Although 0 is a valid UID, it's not a valid app uid. + // So, we use it to signify that mediaCapabilitiesUid is not set. + mediaCapabilitiesUid = 0; + } + if (redactionInfo.redactionRanges.length > 0) { + // If fuse is enabled, we can provide an fd that points to the fuse + // file system and handle redaction in the fuse handler when the caller reads. + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + true /* shouldRedact */, shouldTranscode, transcodeReason); + } else if (shouldTranscode) { + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + false /* shouldRedact */, shouldTranscode, transcodeReason); + } else { + FuseDaemon daemon = null; + try { + daemon = getFuseDaemonForFile(file, mVolumeCache); + } catch (FileNotFoundException ignored) { + } + ParcelFileDescriptor lowerFsFd = FileUtils.openSafely(file, modeBits); + // Always acquire a readLock. This allows us make multiple opens via lower + // filesystem + boolean shouldOpenWithFuse = daemon != null + && daemon.shouldOpenWithFuse(filePath, true /* forRead */, + lowerFsFd.getFd()); + + if (shouldOpenWithFuse) { + // If the file is already opened on the FUSE mount with VFS caching enabled + // we return an upper filesystem fd (via FUSE) to avoid file corruption + // resulting from cache inconsistencies between the upper and lower + // filesystem caches + pfd = openWithFuse(filePath, uid, mediaCapabilitiesUid, modeBits, + false /* shouldRedact */, shouldTranscode, transcodeReason); + try { + lowerFsFd.close(); + } catch (IOException e) { + Log.w(TAG, "Failed to close lower filesystem fd " + file.getPath(), e); + } + } else { + Log.i(TAG, "Open with lower FS for " + filePath + ". Uid: " + uid); + if (forWrite) { + // When opening for write on the lower filesystem, invalidate the VFS dentry + // so subsequent open/getattr calls will return correctly. + // + // A 'dirty' dentry with write back cache enabled can cause the kernel to + // ignore file attributes or even see stale page cache data when the lower + // filesystem has been modified outside of the FUSE driver + invalidateFuseDentry(file); + } + + pfd = lowerFsFd; + } + } + + // Second, wrap in any listener that we've requested + if (!isPending && forWrite) { + return ParcelFileDescriptor.wrap(pfd, BackgroundThread.getHandler(), listener); + } else { + return pfd; + } + } catch (IOException e) { + if (e instanceof FileNotFoundException) { + throw (FileNotFoundException) e; + } else { + throw new IllegalStateException(e); + } + } + } + + private boolean isRedactionNeededForOpenViaContentResolver(Uri redactedUri, + String ownerPackageName, File file) { + // Redacted Uris should always redact information + if (redactedUri != null) { + return true; + } + + final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), ownerPackageName); + if (callerIsOwner) { + return false; + } + + // To be consistent with FUSE redaction checks we allow similar access for File Manager + // and System Gallery apps. + if (isCallingPackageManager() || canSystemGalleryAccessTheFile(file.getPath())) { + return false; + } + + return isRedactionNeeded(); + } + + private void deleteAndInvalidate(@NonNull Path path) { + deleteAndInvalidate(path.toFile()); + } + + private void deleteAndInvalidate(@NonNull File file) { + file.delete(); + invalidateFuseDentry(file); + } + + private void deleteIfAllowed(Uri uri, Bundle extras, String path) { + try { + final File file = new File(path).getCanonicalFile(); + checkAccess(uri, extras, file, true); + deleteAndInvalidate(file); + } catch (Exception e) { + Log.e(TAG, "Couldn't delete " + path, e); + } + } + + @Deprecated + private boolean isPending(Uri uri) { + final int match = matchUri(uri, true); + switch (match) { + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + try (Cursor c = queryForSingleItem(uri, + new String[] { MediaColumns.IS_PENDING }, null, null, null)) { + return (c.getInt(0) != 0); + } catch (FileNotFoundException e) { + throw new IllegalStateException(e); + } + default: + return false; + } + } + + @Deprecated + private boolean isRedactionNeeded(Uri uri) { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_REDACTION_NEEDED); + } + + private boolean isRedactionNeeded() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_REDACTION_NEEDED); + } + + private boolean isCallingPackageRequestingLegacy() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_GRANTED); + } + + private boolean shouldBypassDatabase(int uid) { + if (uid != android.os.Process.SHELL_UID && isCallingPackageManager()) { + return mCallingIdentity.get().shouldBypassDatabase(false /*isSystemGallery*/); + } else if (isCallingPackageSystemGallery()) { + if (isCallingPackageLegacyWrite()) { + // We bypass db operations for legacy system galleries with W_E_S (see b/167307393). + // Tracking a longer term solution in b/168784136. + return true; + } else if (!SdkLevel.isAtLeastS()) { + // We don't parse manifest flags for SdkLevel<=R yet. Hence, we don't bypass + // database updates for SystemGallery targeting R or above on R OS. + return false; + } + return mCallingIdentity.get().shouldBypassDatabase(true /*isSystemGallery*/); + } + return false; + } + + private static int getFileMediaType(String path) { + final File file = new File(path); + final String mimeType = MimeUtils.resolveMimeType(file); + return MimeUtils.resolveMediaType(mimeType); + } + + private boolean canSystemGalleryAccessTheFile(String filePath) { + + if (!isCallingPackageSystemGallery()) { + return false; + } + + final int mediaType = getFileMediaType(filePath); + + return mediaType == FileColumns.MEDIA_TYPE_IMAGE || + mediaType == FileColumns.MEDIA_TYPE_VIDEO; + } + + /** + * Returns true if: + *

          + *
        • the calling identity is an app targeting Q or older versions AND is requesting legacy + * storage and has the corresponding legacy access (read/write) permissions + *
        • the calling identity holds {@code MANAGE_EXTERNAL_STORAGE} + *
        • the calling identity owns or has access to the filePath (eg /Android/data/com.foo) + *
        • the calling identity has permission to write images and the given file is an image file + *
        • the calling identity has permission to write video and the given file is an video file + *
        + */ + private boolean shouldBypassFuseRestrictions(boolean forWrite, String filePath) { + boolean isRequestingLegacyStorage = forWrite ? isCallingPackageLegacyWrite() + : isCallingPackageLegacyRead(); + if (isRequestingLegacyStorage) { + return true; + } + + if (isCallingPackageManager()) { + return true; + } + + // Check if the caller has access to private app directories. + if (isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, filePath)) { + return true; + } + + // Apps with write access to images and/or videos can bypass our restrictions if all of the + // the files they're accessing are of the compatible media type. + return canSystemGalleryAccessTheFile(filePath); + } + + /** + * Returns true if the passed in path is an application-private data directory + * (such as Android/data/com.foo or Android/obb/com.foo) that does not belong to the caller and + * the caller does not have special access. + */ + private boolean isPrivatePackagePathNotAccessibleByCaller(String path) { + // Files under the apps own private directory + final String appSpecificDir = extractPathOwnerPackageName(path); + + if (appSpecificDir == null) { + return false; + } + + // Android/media is not considered private, because it contains media that is explicitly + // scanned and shared by other apps + if (isExternalMediaDirectory(path)) { + return false; + } + return !isUidAllowedAccessToDataOrObbPathForFuse(mCallingIdentity.get().uid, path); + } + + private boolean shouldBypassDatabaseAndSetDirtyForFuse(int uid, String path) { + if (shouldBypassDatabase(uid)) { + synchronized (mNonHiddenPaths) { + File file = new File(path); + String key = file.getParent(); + boolean maybeHidden = !mNonHiddenPaths.containsKey(key); + + if (maybeHidden) { + File topNoMediaDir = FileUtils.getTopLevelNoMedia(new File(path)); + if (topNoMediaDir == null) { + mNonHiddenPaths.put(key, 0); + } else { + mMediaScanner.onDirectoryDirty(topNoMediaDir); + } + } + } + return true; + } + return false; + } + + /** + * Set of Exif tags that should be considered for redaction. + */ + private static final String[] REDACTED_EXIF_TAGS = new String[] { + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_VERSION_ID, + }; + + /** + * Set of ISO boxes that should be considered for redaction. + */ + private static final int[] REDACTED_ISO_BOXES = new int[] { + IsoInterface.BOX_LOCI, + IsoInterface.BOX_XYZ, + IsoInterface.BOX_GPS, + IsoInterface.BOX_GPS0, + }; + + public static final Set sRedactedExifTags = new ArraySet<>( + Arrays.asList(REDACTED_EXIF_TAGS)); + + private static final class RedactionInfo { + public final long[] redactionRanges; + public final long[] freeOffsets; + + public RedactionInfo() { + this.redactionRanges = new long[0]; + this.freeOffsets = new long[0]; + } + + public RedactionInfo(long[] redactionRanges, long[] freeOffsets) { + this.redactionRanges = redactionRanges; + this.freeOffsets = freeOffsets; + } + } + + private static class LRUCache extends LinkedHashMap { + private final int mMaxSize; + + public LRUCache(int maxSize) { + this.mMaxSize = maxSize; + } + + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > mMaxSize; + } + } + + private static final class PendingOpenInfo { + public final int uid; + public final int mediaCapabilitiesUid; + public final boolean shouldRedact; + public final int transcodeReason; + + public PendingOpenInfo(int uid, int mediaCapabilitiesUid, boolean shouldRedact, + int transcodeReason) { + this.uid = uid; + this.mediaCapabilitiesUid = mediaCapabilitiesUid; + this.shouldRedact = shouldRedact; + this.transcodeReason = transcodeReason; + } + } + + /** + * Calculates the ranges that need to be redacted for the given file and user that wants to + * access the file. + * Note: This method assumes that the caller of this function has already done permission checks + * for the uid to access this path. + * + * @param uid UID of the package wanting to access the file + * @param path File path + * @param tid thread id making IO on the FUSE filesystem + * @return Ranges that should be redacted. + * + * @throws IOException if an error occurs while calculating the redaction ranges + */ + @NonNull + private long[] getRedactionRangesForFuse(String path, String ioPath, int original_uid, int uid, + int tid, boolean forceRedaction) throws IOException { + // |ioPath| might refer to a transcoded file path (which is not indexed in the db) + // |path| will always refer to a valid _data column + // We use |ioPath| for the filesystem access because in the case of transcoding, + // we want to get redaction ranges from the transcoded file and *not* the original file + final File file = new File(ioPath); + + if (forceRedaction) { + return getRedactionRanges(file).redactionRanges; + } + + // When calculating redaction ranges initiated from MediaProvider, the redaction policy + // is slightly different from the FUSE initiated opens redaction policy. targetSdk=29 from + // MediaProvider requires redaction, but targetSdk=29 apps from FUSE don't require redaction + // Hence, we check the mPendingOpenInfo object (populated when opens are initiated from + // MediaProvider) if there's a pending open from MediaProvider with matching tid and uid and + // use the shouldRedact decision there if there's one. + synchronized (mPendingOpenInfo) { + PendingOpenInfo info = mPendingOpenInfo.get(tid); + if (info != null && info.uid == original_uid) { + boolean shouldRedact = info.shouldRedact; + if (shouldRedact) { + return getRedactionRanges(file).redactionRanges; + } else { + return new long[0]; + } + } + } + + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + try { + if (!isRedactionNeeded() + || shouldBypassFuseRestrictions(/* forWrite */ false, path)) { + return new long[0]; + } + + final Uri contentUri = FileUtils.getContentUriForPath(path); + final String[] projection = new String[]{ + MediaColumns.OWNER_PACKAGE_NAME, MediaColumns._ID , FileColumns.MEDIA_TYPE}; + final String selection = MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[]{path}; + final String ownerPackageName; + final int id; + final int mediaType; + // Query as MediaProvider as non-RES apps will result in FileNotFoundException. + // Note: The caller uid already has passed permission checks to access this file. + try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, + selection, selectionArgs, null)) { + c.moveToFirst(); + ownerPackageName = c.getString(0); + id = c.getInt(1); + mediaType = c.getInt(2); + } catch (FileNotFoundException e) { + // Ideally, this shouldn't happen unless the file was deleted after we checked its + // existence and before we get to the redaction logic here. In this case we throw + // and fail the operation and FuseDaemon should handle this and fail the whole open + // operation gracefully. + throw new FileNotFoundException( + path + " not found while calculating redaction ranges: " + e.getMessage()); + } + + final boolean callerIsOwner = Objects.equals(getCallingPackageOrSelf(), + ownerPackageName); + + // Do not redact if the caller is the owner + if (callerIsOwner) { + return new long[0]; + } + + // Do not redact if the caller has write uri permission granted on the file. + final Uri fileUri = ContentUris.withAppendedId(contentUri, id); + boolean callerHasWriteUriPermission = getContext().checkUriPermission( + fileUri, mCallingIdentity.get().pid, mCallingIdentity.get().uid, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION) == PERMISSION_GRANTED; + if (callerHasWriteUriPermission) { + return new long[0]; + } + // Check if the caller has write access to other uri formats for the same file. + callerHasWriteUriPermission = getOtherUriGrantsForPath(path, mediaType, + Long.toString(id), /* forWrite */ true) != null; + if (callerHasWriteUriPermission) { + return new long[0]; + } + + return getRedactionRanges(file).redactionRanges; + } finally { + restoreLocalCallingIdentity(token); + } + } + + /** + * Calculates the ranges containing sensitive metadata that should be redacted if the caller + * doesn't have the required permissions. + * + * @param file file to be redacted + * @return the ranges to be redacted in a RedactionInfo object, could be empty redaction ranges + * if there's sensitive metadata + * @throws IOException if an IOException happens while calculating the redaction ranges + */ + @VisibleForTesting + public static RedactionInfo getRedactionRanges(File file) throws IOException { + try (FileInputStream is = new FileInputStream(file)) { + return getRedactionRanges(is, MimeUtils.resolveMimeType(file)); + } catch (FileNotFoundException ignored) { + // If file not found, then there's nothing to redact + return new RedactionInfo(); + } catch (IOException e) { + throw new IOException("Failed to redact " + file, e); + } + } + + /** + * Calculates the ranges containing sensitive metadata that should be redacted if the caller + * doesn't have the required permissions. + * + * @param fis {@link FileInputStream} to be redacted + * @return the ranges to be redacted in a RedactionInfo object, could be empty redaction ranges + * if there's sensitive metadata + * @throws IOException if an IOException happens while calculating the redaction ranges + */ + @VisibleForTesting + public static RedactionInfo getRedactionRanges(FileInputStream fis, String mimeType) + throws IOException { + final LongArray res = new LongArray(); + final LongArray freeOffsets = new LongArray(); + + Trace.beginSection("MP.getRedactionRanges"); + try { + if (ExifInterface.isSupportedMimeType(mimeType)) { + final ExifInterface exif = new ExifInterface(fis.getFD()); + for (String tag : REDACTED_EXIF_TAGS) { + final long[] range = exif.getAttributeRange(tag); + if (range != null) { + res.add(range[0]); + res.add(range[0] + range[1]); + } + } + // Redact xmp where present + final XmpInterface exifXmp = XmpInterface.fromContainer(exif); + res.addAll(exifXmp.getRedactionRanges()); + } + + if (IsoInterface.isSupportedMimeType(mimeType)) { + final IsoInterface iso = IsoInterface.fromFileDescriptor(fis.getFD()); + for (int box : REDACTED_ISO_BOXES) { + final long[] ranges = iso.getBoxRanges(box); + for (int i = 0; i < ranges.length; i += 2) { + long boxTypeOffset = ranges[i] - 4; + freeOffsets.add(boxTypeOffset); + res.add(boxTypeOffset); + res.add(ranges[i + 1]); + } + } + // Redact xmp where present + final XmpInterface isoXmp = XmpInterface.fromContainer(iso); + res.addAll(isoXmp.getRedactionRanges()); + } + + return new RedactionInfo(res.toArray(), freeOffsets.toArray()); + } finally { + Trace.endSection(); + } + } + + /** + * @return {@code true} if {@code file} is pending from FUSE, {@code false} otherwise. + * Files pending from FUSE will not have pending file pattern. + */ + private static boolean isPendingFromFuse(@NonNull File file) { + final Matcher matcher = + FileUtils.PATTERN_EXPIRES_FILE.matcher(extractDisplayName(file.getName())); + return !matcher.matches(); + } + + private FileAccessAttributes queryForFileAttributes(final String path) + throws FileNotFoundException { + Trace.beginSection("MP.queryFileAttr"); + final Uri contentUri = FileUtils.getContentUriForPath(path); + final String[] projection = new String[]{ + MediaColumns._ID, + MediaColumns.OWNER_PACKAGE_NAME, + MediaColumns.IS_PENDING, + FileColumns.MEDIA_TYPE, + MediaColumns.IS_TRASHED + }; + final String selection = MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[]{path}; + FileAccessAttributes fileAccessAttributes; + try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, + selection, + selectionArgs, null)) { + fileAccessAttributes = FileAccessAttributes.fromCursor(c); + } + Trace.endSection(); + return fileAccessAttributes; + } + + private void checkIfFileOpenIsPermitted(String path, + FileAccessAttributes fileAccessAttributes, String redactedUriId, + boolean forWrite) throws FileNotFoundException { + final File file = new File(path); + Uri fileUri = MediaStore.Files.getContentUri(extractVolumeName(path), + fileAccessAttributes.getId()); + // We don't check ownership for files with IS_PENDING set by FUSE + // Please note that even if ownerPackageName is null, the check below will throw an + // IllegalStateException + if (fileAccessAttributes.isTrashed() || (fileAccessAttributes.isPending() + && !isPendingFromFuse(new File(path)))) { + requireOwnershipForItem(fileAccessAttributes.getOwnerPackageName(), fileUri); + } + + // Check that path looks consistent before uri checks + if (!FileUtils.contains(Environment.getStorageDirectory(), file)) { + checkWorldReadAccess(file.getAbsolutePath()); + } + + try { + // checkAccess throws FileNotFoundException only from checkWorldReadAccess(), + // which we already check above. Hence, handling only SecurityException. + if (redactedUriId != null) { + fileUri = ContentUris.removeId(fileUri).buildUpon().appendPath( + redactedUriId).build(); + } + checkAccess(fileUri, Bundle.EMPTY, file, forWrite); + } catch (SecurityException e) { + // Check for other Uri formats only when the single uri check flow fails. + // Throw the previous exception if the multi-uri checks failed. + final String uriId = redactedUriId == null + ? Long.toString(fileAccessAttributes.getId()) : redactedUriId; + if (getOtherUriGrantsForPath(path, fileAccessAttributes.getMediaType(), + uriId, forWrite) == null) { + throw e; + } + } + } + + + /** + * Checks if the app identified by the given UID is allowed to open the given file for the given + * access mode. + * + * @param path the path of the file to be opened + * @param uid UID of the app requesting to open the file + * @param forWrite specifies if the file is to be opened for write + * @return {@link FileOpenResult} with {@code status} {@code 0} upon success and + * {@link FileOpenResult} with {@code status} {@link OsConstants#EACCES} if the operation is + * illegal or not permitted for the given {@code uid} or if the calling package is a legacy app + * that doesn't have right storage permission. + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public FileOpenResult onFileOpenForFuse(String path, String ioPath, int uid, int tid, + int transformsReason, boolean forWrite, boolean redact, boolean logTransformsMetrics) { + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + + boolean isSuccess = false; + + final int originalUid = getBinderUidForFuse(uid, tid); + // Use MediaProvider's own ID here since the caller may be cross profile. + final int userId = UserHandle.myUserId(); + int mediaCapabilitiesUid = 0; + final PendingOpenInfo pendingOpenInfo; + synchronized (mPendingOpenInfo) { + pendingOpenInfo = mPendingOpenInfo.get(tid); + } + + if (pendingOpenInfo != null && pendingOpenInfo.uid == originalUid) { + mediaCapabilitiesUid = pendingOpenInfo.mediaCapabilitiesUid; + } + + try { + boolean forceRedaction = false; + String redactedUriId = null; + if (isSyntheticPath(path, userId)) { + if (forWrite) { + // Synthetic URIs are not allowed to update EXIF headers. + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } + + if (isRedactedPath(path, userId)) { + redactedUriId = extractFileName(path); + + // If path is redacted Uris' path, ioPath must be the real path, ioPath must + // haven been updated to the real path during onFileLookupForFuse. + path = ioPath; + + // Irrespective of the permissions we want to redact in this case. + redact = true; + forceRedaction = true; + } else if (isPickerPath(path, userId)) { + return handlePickerFileOpen(path, originalUid); + } else { + // we don't support any other transformations under .transforms/synthetic dir + return new FileOpenResult(OsConstants.ENOENT /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } + } + + if (isPrivatePackagePathNotAccessibleByCaller(path)) { + Log.e(TAG, "Can't open a file in another app's external directory!"); + return new FileOpenResult(OsConstants.ENOENT, originalUid, mediaCapabilitiesUid, + new long[0]); + } + + if (shouldBypassFuseRestrictions(forWrite, path)) { + isSuccess = true; + return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid, + redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid, + forceRedaction) : new long[0]); + } + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (isCallingPackageRequestingLegacy()) { + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } + // TODO: Fetch owner id from Android/media directory and check if caller is owner + FileAccessAttributes fileAttributes = null; + if (XAttrUtils.ENABLE_XATTR_METADATA_FOR_FUSE) { + Optional fileAttributesThroughXattr = + XAttrUtils.getFileAttributesFromXAttr(path, + XAttrUtils.FILE_ACCESS_XATTR_KEY); + if (fileAttributesThroughXattr.isPresent()) { + fileAttributes = fileAttributesThroughXattr.get(); + } + } + + // FileAttributes will be null if the xattr call failed or the flag to enable xattr + // metadata support is not set + if (fileAttributes == null) { + fileAttributes = queryForFileAttributes(path); + } + checkIfFileOpenIsPermitted(path, fileAttributes, redactedUriId, forWrite); + isSuccess = true; + return new FileOpenResult(0 /* status */, originalUid, mediaCapabilitiesUid, + redact ? getRedactionRangesForFuse(path, ioPath, originalUid, uid, tid, + forceRedaction) : new long[0]); + } catch (IOException e) { + // We are here because + // * There is no db row corresponding to the requested path, which is more unlikely. + // * getRedactionRangesForFuse couldn't fetch the redaction info correctly + // In all of these cases, it means that app doesn't have access permission to the file. + Log.e(TAG, "Couldn't find file: " + path, e); + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } catch (IllegalStateException | SecurityException e) { + Log.e(TAG, "Permission to access file: " + path + " is denied"); + return new FileOpenResult(OsConstants.EACCES /* status */, originalUid, + mediaCapabilitiesUid, new long[0]); + } finally { + if (isSuccess && logTransformsMetrics) { + notifyTranscodeHelperOnFileOpen(path, ioPath, originalUid, transformsReason); + } + restoreLocalCallingIdentity(token); + } + } + + @Nullable + private Uri getOtherUriGrantsForPath(String path, boolean forWrite) { + final Uri contentUri = FileUtils.getContentUriForPath(path); + final String[] projection = new String[]{ + MediaColumns._ID, + FileColumns.MEDIA_TYPE}; + final String selection = MediaColumns.DATA + "=?"; + final String[] selectionArgs = new String[]{path}; + final String id; + final int mediaType; + try (final Cursor c = queryForSingleItemAsMediaProvider(contentUri, projection, selection, + selectionArgs, null)) { + id = c.getString(0); + mediaType = c.getInt(1); + return getOtherUriGrantsForPath(path, mediaType, id, forWrite); + } catch (FileNotFoundException ignored) { + } + return null; + } + + @Nullable + private Uri getOtherUriGrantsForPath(String path, int mediaType, String id, boolean forWrite) { + List otherUris = new ArrayList<>(); + final Uri mediaUri = getMediaUriForFuse(extractVolumeName(path), mediaType, id); + otherUris.add(mediaUri); + final Uri externalMediaUri = getMediaUriForFuse(MediaStore.VOLUME_EXTERNAL, mediaType, id); + otherUris.add(externalMediaUri); + return getPermissionGrantedUri(otherUris, forWrite); + } + + @NonNull + private Uri getMediaUriForFuse(@NonNull String volumeName, int mediaType, String id) { + Uri uri = MediaStore.Files.getContentUri(volumeName); + switch (mediaType) { + case FileColumns.MEDIA_TYPE_IMAGE: + uri = MediaStore.Images.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_VIDEO: + uri = MediaStore.Video.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_AUDIO: + uri = MediaStore.Audio.Media.getContentUri(volumeName); + break; + case FileColumns.MEDIA_TYPE_PLAYLIST: + uri = MediaStore.Audio.Playlists.getContentUri(volumeName); + break; + } + + return uri.buildUpon().appendPath(id).build(); + } + + /** + * Returns {@code true} if {@link #mCallingIdentity#getSharedPackageNamesList(String)} contains + * the given package name, {@code false} otherwise. + *

        Assumes that {@code mCallingIdentity} has been properly set to reflect the calling + * package. + */ + private boolean isCallingIdentitySharedPackageName(@NonNull String packageName) { + for (String sharedPkgName : mCallingIdentity.get().getSharedPackageNamesArray()) { + if (packageName.toLowerCase(Locale.ROOT) + .equals(sharedPkgName.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + /** + * @throws IllegalStateException if path is invalid or doesn't match a volume. + */ + @NonNull + private Uri getContentUriForFile(@NonNull String filePath, @NonNull String mimeType) { + final String volName; + try { + volName = FileUtils.getVolumeName(getContext(), new File(filePath)); + } catch (FileNotFoundException e) { + throw new IllegalStateException("Couldn't get volume name for " + filePath); + } + Uri uri = Files.getContentUri(volName); + String topLevelDir = extractTopLevelDir(filePath); + if (topLevelDir == null) { + // If the file path doesn't match the external storage directory, we use the files URI + // as default and let #insert enforce the restrictions + return uri; + } + topLevelDir = topLevelDir.toLowerCase(Locale.ROOT); + + switch (topLevelDir) { + case DIRECTORY_PODCASTS_LOWER_CASE: + case DIRECTORY_RINGTONES_LOWER_CASE: + case DIRECTORY_ALARMS_LOWER_CASE: + case DIRECTORY_NOTIFICATIONS_LOWER_CASE: + case DIRECTORY_AUDIOBOOKS_LOWER_CASE: + case DIRECTORY_RECORDINGS_LOWER_CASE: + uri = Audio.Media.getContentUri(volName); + break; + case DIRECTORY_MUSIC_LOWER_CASE: + if (MimeUtils.isPlaylistMimeType(mimeType)) { + uri = Audio.Playlists.getContentUri(volName); + } else if (!MimeUtils.isSubtitleMimeType(mimeType)) { + // Send Files uri for media type subtitle + uri = Audio.Media.getContentUri(volName); + } + break; + case DIRECTORY_MOVIES_LOWER_CASE: + if (MimeUtils.isPlaylistMimeType(mimeType)) { + uri = Audio.Playlists.getContentUri(volName); + } else if (!MimeUtils.isSubtitleMimeType(mimeType)) { + // Send Files uri for media type subtitle + uri = Video.Media.getContentUri(volName); + } + break; + case DIRECTORY_DCIM_LOWER_CASE: + case DIRECTORY_PICTURES_LOWER_CASE: + if (MimeUtils.isImageMimeType(mimeType)) { + uri = Images.Media.getContentUri(volName); + } else { + uri = Video.Media.getContentUri(volName); + } + break; + case DIRECTORY_DOWNLOADS_LOWER_CASE: + case DIRECTORY_DOCUMENTS_LOWER_CASE: + break; + default: + Log.w(TAG, "Forgot to handle a top level directory in getContentUriForFile?"); + } + return uri; + } + + private boolean containsIgnoreCase(@Nullable List stringsList, @Nullable String item) { + if (item == null || stringsList == null) return false; + + for (String current : stringsList) { + if (item.equalsIgnoreCase(current)) return true; + } + return false; + } + + private boolean fileExists(@NonNull String absolutePath) { + // We don't care about specific columns in the match, + // we just want to check IF there's a match + final String[] projection = {}; + final String selection = FileColumns.DATA + " = ?"; + final String[] selectionArgs = {absolutePath}; + final Uri uri = FileUtils.getContentUriForPath(absolutePath); + + final LocalCallingIdentity token = clearLocalCallingIdentity(); + try { + try (final Cursor c = query(uri, projection, selection, selectionArgs, null)) { + // Shouldn't return null + return c.getCount() > 0; + } + } finally { + clearLocalCallingIdentity(token); + } + } + + private Uri insertFileForFuse(@NonNull String path, @NonNull Uri uri, @NonNull String mimeType, + boolean useData) { + ContentValues values = new ContentValues(); + values.put(FileColumns.OWNER_PACKAGE_NAME, getCallingPackageOrSelf()); + values.put(MediaColumns.MIME_TYPE, mimeType); + values.put(FileColumns.IS_PENDING, 1); + + int userIdFromPath = FileUtils.extractUserId(path); + + if (useData) { + values.put(FileColumns.DATA, path); + } else { + values.put(FileColumns.VOLUME_NAME, extractVolumeName(path)); + values.put(FileColumns.RELATIVE_PATH, extractRelativePath(path)); + values.put(FileColumns.DISPLAY_NAME, extractDisplayName(path)); + // In some cases when clone profile is active, this userId can be used to determine + // the path to be saved in MP database. + // We do this only if the path contains a valid user-id and any such value set is + // only a hint, the actual userId set will be determined later. + if (userIdFromPath != -1) { + values.put(FileColumns._USER_ID, userIdFromPath); + } + } + return insert(uri, values, Bundle.EMPTY); + } + + /** + * Enforces file creation restrictions (see return values) for the given file on behalf of the + * app with the given {@code uid}. If the file is added to the shared storage, creates a + * database entry for it. + *

        Does NOT create file. + * + * @param path the path of the file + * @param uid UID of the app requesting to create the file + * @return In case of success, 0. If the operation is illegal or not permitted, returns the + * appropriate {@code errno} value: + *

          + *
        • {@link OsConstants#ENOENT} if the app tries to create file in other app's external dir + *
        • {@link OsConstants#EEXIST} if the file already exists + *
        • {@link OsConstants#EPERM} if the file type doesn't match the relative path, or if the + * calling package is a legacy app that doesn't have WRITE_EXTERNAL_STORAGE permission. + *
        • {@link OsConstants#EIO} in case of any other I/O exception + *
        + * + * @throws IllegalStateException if given path is invalid. + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public int insertFileIfNecessaryForFuse(@NonNull String path, int uid) { + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + + try { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { + Log.e(TAG, "Can't create a file in another app's external directory"); + return OsConstants.ENOENT; + } + + if (!path.equals(getAbsoluteSanitizedPath(path))) { + Log.e(TAG, "File name contains invalid characters"); + return OsConstants.EPERM; + } + + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) { + if (path.endsWith("/.nomedia")) { + File parent = new File(path).getParentFile(); + synchronized (mNonHiddenPaths) { + mNonHiddenPaths.keySet().removeIf( + k -> FileUtils.contains(parent, new File(k))); + } + } + return 0; + } + + final String mimeType = MimeUtils.resolveMimeType(new File(path)); + + if (shouldBypassFuseRestrictions(/* forWrite */ true, path)) { + final boolean callerRequestingLegacy = isCallingPackageRequestingLegacy(); + if (!fileExists(path)) { + // If app has already inserted the db row, inserting the row again might set + // IS_PENDING=1. We shouldn't overwrite existing entry as part of FUSE + // operation, hence, insert the db row only when it doesn't exist. + try { + insertFileForFuse(path, FileUtils.getContentUriForPath(path), + mimeType, /* useData */ callerRequestingLegacy); + } catch (Exception ignored) { + } + } else { + // Upon creating a file via FUSE, if a row matching the path already exists + // but a file doesn't exist on the filesystem, we transfer ownership to the + // app attempting to create the file. If we don't update ownership, then the + // app that inserted the original row may be able to observe the contents of + // written file even though they don't hold the right permissions to do so. + if (callerRequestingLegacy) { + final String owner = getCallingPackageOrSelf(); + if (owner != null && !updateOwnerForPath(path, owner)) { + return OsConstants.EPERM; + } + } + } + + return 0; + } + + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (isCallingPackageRequestingLegacy()) { + return OsConstants.EPERM; + } + + if (fileExists(path)) { + // If the file already exists in the db, we shouldn't allow the file creation. + return OsConstants.EEXIST; + } + + final Uri contentUri = getContentUriForFile(path, mimeType); + final Uri item = insertFileForFuse(path, contentUri, mimeType, /* useData */ false); + if (item == null) { + return OsConstants.EPERM; + } + return 0; + } catch (IllegalArgumentException e) { + Log.e(TAG, "insertFileIfNecessary failed", e); + return OsConstants.EPERM; + } finally { + restoreLocalCallingIdentity(token); + } + } + + private boolean updateOwnerForPath(@NonNull String path, @NonNull String newOwner) { + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(FileUtils.getContentUriForPath(path)); + } catch (VolumeNotFoundException e) { + // Cannot happen, as this is a path that we already resolved. + throw new AssertionError("Path must already be resolved", e); + } + + ContentValues values = new ContentValues(1); + values.put(FileColumns.OWNER_PACKAGE_NAME, newOwner); + + return helper.runWithoutTransaction( + (db) -> db.update("files", values, "_data=?", new String[] { path })) == 1; + } + + private int deleteFileUnchecked(@NonNull String path, + LocalCallingIdentity localCallingIdentity) { + final File toDelete = new File(path); + if (toDelete.delete()) { + final int mediaType = MimeUtils.resolveMediaType(MimeUtils.resolveMimeType(toDelete)); + localCallingIdentity.incrementDeletedFileCountBypassingDatabase(mediaType); + return 0; + } else { + return OsConstants.ENOENT; + } + } + + /** + * Deletes file with the given {@code path} on behalf of the app with the given {@code uid}. + *

        Before deleting, checks if app has permissions to delete this file. + * + * @param path the path of the file + * @param uid UID of the app requesting to delete the file + * @return 0 upon success. + * In case of error, return the appropriate negated {@code errno} value: + *

          + *
        • {@link OsConstants#ENOENT} if the file does not exist or if the app tries to delete file + * in another app's external dir + *
        • {@link OsConstants#EPERM} a security exception was thrown by {@link #delete}, or if the + * calling package is a legacy app that doesn't have WRITE_EXTERNAL_STORAGE permission. + *
        + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public int deleteFileForFuse(@NonNull String path, int uid) throws IOException { + final LocalCallingIdentity localCallingIdentity = getCachedCallingIdentityForFuse(uid); + final LocalCallingIdentity token = clearLocalCallingIdentity(localCallingIdentity); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + + try { + if (isPrivatePackagePathNotAccessibleByCaller(path)) { + Log.e(TAG, "Can't delete a file in another app's external directory!"); + return OsConstants.ENOENT; + } + + if (shouldBypassDatabaseAndSetDirtyForFuse(uid, path)) { + return deleteFileUnchecked(path, localCallingIdentity); + } + + final boolean shouldBypass = shouldBypassFuseRestrictions(/*forWrite*/ true, path); + + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (!shouldBypass && isCallingPackageRequestingLegacy()) { + return OsConstants.EPERM; + } + + final Uri contentUri = FileUtils.getContentUriForPath(path); + final String where = FileColumns.DATA + " = ?"; + final String[] whereArgs = {path}; + + if (delete(contentUri, where, whereArgs) == 0) { + if (shouldBypass) { + return deleteFileUnchecked(path, localCallingIdentity); + } + return OsConstants.ENOENT; + } else { + // success - 1 file was deleted + return 0; + } + + } catch (SecurityException e) { + Log.e(TAG, "File deletion not allowed", e); + return OsConstants.EPERM; + } finally { + restoreLocalCallingIdentity(token); + } + } + + // These need to stay in sync with MediaProviderWrapper.cpp's DirectoryAccessRequestType enum + @IntDef(flag = true, prefix = { "DIRECTORY_ACCESS_FOR_" }, value = { + DIRECTORY_ACCESS_FOR_READ, + DIRECTORY_ACCESS_FOR_WRITE, + DIRECTORY_ACCESS_FOR_CREATE, + DIRECTORY_ACCESS_FOR_DELETE, + }) + @Retention(RetentionPolicy.SOURCE) + @VisibleForTesting + @interface DirectoryAccessType {} + + @VisibleForTesting + static final int DIRECTORY_ACCESS_FOR_READ = 1; + + @VisibleForTesting + static final int DIRECTORY_ACCESS_FOR_WRITE = 2; + + @VisibleForTesting + static final int DIRECTORY_ACCESS_FOR_CREATE = 3; + + @VisibleForTesting + static final int DIRECTORY_ACCESS_FOR_DELETE = 4; + + /** + * Checks whether the app with the given UID is allowed to access the directory denoted by the + * given path. + * + * @param path directory's path + * @param uid UID of the requesting app + * @param accessType type of access being requested - eg {@link + * MediaProvider#DIRECTORY_ACCESS_FOR_READ} + * @return 0 if it's allowed to access the directory, {@link OsConstants#ENOENT} for attempts + * to access a private package path in Android/data or Android/obb the caller doesn't have + * access to, and otherwise {@link OsConstants#EACCES} if the calling package is a legacy app + * that doesn't have READ_EXTERNAL_STORAGE permission or for other invalid attempts to access + * Android/data or Android/obb dirs. + * + * Called from JNI in jni/MediaProviderWrapper.cpp + */ + @Keep + public int isDirAccessAllowedForFuse(@NonNull String path, int uid, + @DirectoryAccessType int accessType) { + Preconditions.checkArgumentInRange(accessType, 1, DIRECTORY_ACCESS_FOR_DELETE, + "accessType"); + + final boolean forRead = accessType == DIRECTORY_ACCESS_FOR_READ; + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + PulledMetrics.logFileAccessViaFuse(getCallingUidOrSelf(), path); + try { + if ("/storage/emulated".equals(path)) { + return OsConstants.EPERM; + } + if (isPrivatePackagePathNotAccessibleByCaller(path)) { + Log.e(TAG, "Can't access another app's external directory!"); + return OsConstants.ENOENT; + } + + if (shouldBypassFuseRestrictions(/* forWrite= */ !forRead, path)) { + return 0; + } + + // Do not allow apps that reach this point to access Android/data or Android/obb dirs. + // Creation should be via getContext().getExternalFilesDir() etc methods. + // Reads and writes on primary volumes should be via mount views of lowerfs for apps + // that get special access to these directories. + // Reads and writes on secondary volumes would be provided via an early return from + // shouldBypassFuseRestrictions above (again just for apps with special access). + if (isDataOrObbPath(path)) { + return OsConstants.EACCES; + } + + // Legacy apps that made is this far don't have the right storage permission and hence + // are not allowed to access anything other than their external app directory + if (isCallingPackageRequestingLegacy()) { + return OsConstants.EACCES; + } + // This is a non-legacy app. Rest of the directories are generally writable + // except for non-default top-level directories. + if (!forRead) { + final String[] relativePath = sanitizePath(extractRelativePath(path)); + if (relativePath.length == 0) { + Log.e(TAG, + "Directory update not allowed on invalid relative path for " + path); + return OsConstants.EPERM; + } + final boolean isTopLevelDir = + relativePath.length == 1 && TextUtils.isEmpty(relativePath[0]); + if (isTopLevelDir) { + // We don't allow deletion of any top-level folders + if (accessType == DIRECTORY_ACCESS_FOR_DELETE) { + Log.e(TAG, "Deleting top level directories are not allowed!"); + return OsConstants.EACCES; + } + + // We allow creating or writing to default top-level folders, but we don't + // allow creation or writing to non-default top-level folders. + if ((accessType == DIRECTORY_ACCESS_FOR_CREATE + || accessType == DIRECTORY_ACCESS_FOR_WRITE) + && FileUtils.isDefaultDirectoryName(extractDisplayName(path))) { + return 0; + } + + Log.e(TAG, + "Creating or writing to a non-default top level directory is not " + + "allowed!"); + return OsConstants.EACCES; + } + } + + return 0; + } finally { + restoreLocalCallingIdentity(token); + } + } + + @Keep + public boolean isUidAllowedAccessToDataOrObbPathForFuse(int uid, String path) { + final LocalCallingIdentity token = + clearLocalCallingIdentity(getCachedCallingIdentityForFuse(uid)); + try { + return isCallingIdentityAllowedAccessToDataOrObbPath( + extractRelativePathWithDisplayName(path)); + } finally { + restoreLocalCallingIdentity(token); + } + } + + private boolean isCallingIdentityAllowedAccessToDataOrObbPath(String relativePath) { + // Files under the apps own private directory + final String appSpecificDir = extractOwnerPackageNameFromRelativePath(relativePath); + + if (appSpecificDir != null && isCallingIdentitySharedPackageName(appSpecificDir)) { + return true; + } + // This is a private-package relativePath; return true if accessible by the caller + return isCallingIdentityAllowedSpecialPrivatePathAccess(relativePath); + } + + /** + * @return true iff the caller has installer privileges which gives write access to obb dirs. + * + * @deprecated This method should only be called for Android R. For Android S+, please use + * {@link StorageManager#getExternalStorageMountMode} to check if the caller has + * {@link StorageManager#MOUNT_MODE_EXTERNAL_INSTALLER} access. + * + * Note: WRITE_EXTERNAL_STORAGE permission should ideally not be requested by non-legacy apps. + * But to be consistent with {@link StorageManager} check for Installer apps access for primary + * volumes in Android R, we do not add non-legacy apps check here as well. + */ + @Deprecated + private boolean isCallingIdentityAllowedInstallerAccess() { + final boolean hasWrite = mCallingIdentity.get(). + hasPermission(PERMISSION_WRITE_EXTERNAL_STORAGE); + + if (!hasWrite) { + return false; + } + + // We're only willing to give out installer access if they also hold + // runtime permission; this is a firm CDD requirement + final boolean hasInstall = mCallingIdentity.get(). + hasPermission(PERMISSION_INSTALL_PACKAGES); + + if (hasInstall) { + return true; + } + // OPSTR_REQUEST_INSTALL_PACKAGES is granted/denied per package but vold can't + // update mountpoints of a specific package. So, check the appop for all packages + // sharing the uid and allow same level of storage access for all packages even if + // one of the packages has the appop granted. + // To maintain consistency of access in primary volume and secondary volumes use the same + // logic as we do for Zygote.MOUNT_EXTERNAL_INSTALLER view. + return mCallingIdentity.get().hasPermission(APPOP_REQUEST_INSTALL_PACKAGES_FOR_SHARED_UID); + } + + private String getExternalStorageProviderAuthority() { + if (SdkLevel.isAtLeastS()) { + return getExternalStorageProviderAuthorityFromDocumentsContract(); + } + return MediaStore.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + @RequiresApi(Build.VERSION_CODES.S) + private String getExternalStorageProviderAuthorityFromDocumentsContract() { + return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + private String getDownloadsProviderAuthority() { + if (SdkLevel.isAtLeastS()) { + return getDownloadsProviderAuthorityFromDocumentsContract(); + } + return DOWNLOADS_PROVIDER_AUTHORITY; + } + + @RequiresApi(Build.VERSION_CODES.S) + private String getDownloadsProviderAuthorityFromDocumentsContract() { + return DocumentsContract.EXTERNAL_STORAGE_PROVIDER_AUTHORITY; + } + + private boolean isCallingIdentityDownloadProvider() { + return getCallingUidOrSelf() == mDownloadsAuthorityAppId; + } + + private boolean isCallingIdentityExternalStorageProvider() { + return getCallingUidOrSelf() == mExternalStorageAuthorityAppId; + } + + private boolean isCallingIdentityMtp() { + return mCallingIdentity.get().hasPermission(PERMISSION_ACCESS_MTP); + } + + /** + * The following apps have access to all private-app directories on secondary volumes: + * * ExternalStorageProvider + * * DownloadProvider + * * Signature apps with ACCESS_MTP permission granted + * (Note: For Android R we also allow privileged apps with ACCESS_MTP to access all + * private-app directories, this additional access is removed for Android S+). + * + * Installer apps can only access private-app directories on Android/obb. + * + * @param relativePath the relative path of the file to access + */ + private boolean isCallingIdentityAllowedSpecialPrivatePathAccess(String relativePath) { + if (SdkLevel.isAtLeastS()) { + return isMountModeAllowedPrivatePathAccess(getCallingUidOrSelf(), getCallingPackage(), + relativePath); + } else { + if (isCallingIdentityDownloadProvider() || + isCallingIdentityExternalStorageProvider() || isCallingIdentityMtp()) { + return true; + } + return (isObbOrChildRelativePath(relativePath) && + isCallingIdentityAllowedInstallerAccess()); + } + } + + @RequiresApi(Build.VERSION_CODES.S) + private boolean isMountModeAllowedPrivatePathAccess(int uid, String packageName, + String relativePath) { + // This is required as only MediaProvider (package with WRITE_MEDIA_STORAGE) can access + // mount modes. + final CallingIdentity token = clearCallingIdentity(); + try { + final int mountMode = mStorageManager.getExternalStorageMountMode(uid, packageName); + switch (mountMode) { + case StorageManager.MOUNT_MODE_EXTERNAL_ANDROID_WRITABLE: + case StorageManager.MOUNT_MODE_EXTERNAL_PASS_THROUGH: + return true; + case StorageManager.MOUNT_MODE_EXTERNAL_INSTALLER: + return isObbOrChildRelativePath(relativePath); + } + } catch (Exception e) { + Log.w(TAG, "Caller does not have the permissions to access mount modes: ", e); + } finally { + restoreCallingIdentity(token); + } + return false; + } + + private boolean checkCallingPermissionGlobal(Uri uri, boolean forWrite) { + // System internals can work with all media + if (isCallingPackageSelf() || isCallingPackageShell()) { + return true; + } + + // Apps that have permission to manage external storage can work with all files + if (isCallingPackageManager()) { + return true; + } + + // Check if caller is known to be owner of this item, to speed up + // performance of our permission checks + final int table = matchUri(uri, true); + switch (table) { + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + case IMAGES_MEDIA_ID: + case FILES_ID: + case DOWNLOADS_ID: + final long id = ContentUris.parseId(uri); + if (mCallingIdentity.get().isOwned(id)) { + return true; + } + break; + default: + // continue below + } + + // Check whether the uri is a specific table or not. Don't allow the global access to these + // table uris + switch (table) { + case AUDIO_MEDIA: + case IMAGES_MEDIA: + case VIDEO_MEDIA: + case DOWNLOADS: + case FILES: + case AUDIO_ALBUMS: + case AUDIO_ARTISTS: + case AUDIO_GENRES: + case AUDIO_PLAYLISTS: + return false; + default: + // continue below + } + + // Outstanding grant means they get access + return isUriPermissionGranted(uri, forWrite); + } + + /** + * Returns any uri that is granted from the set of Uris passed. + */ + @Nullable + private Uri getPermissionGrantedUri(@NonNull List uris, boolean forWrite) { + for (Uri uri : uris) { + if (isUriPermissionGranted(uri, forWrite)) { + return uri; + } + } + return null; + } + + private boolean isUriPermissionGranted(Uri uri, boolean forWrite) { + final int modeFlags = forWrite + ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION + : Intent.FLAG_GRANT_READ_URI_PERMISSION; + int uriPermission = getContext().checkUriPermission(uri, mCallingIdentity.get().pid, + mCallingIdentity.get().uid, modeFlags); + return uriPermission == PERMISSION_GRANTED; + } + + @VisibleForTesting + public boolean isFuseThread() { + return FuseDaemon.native_is_fuse_thread(); + } + + + /** + * Enforce that caller has access to the given {@link Uri}. + * + * @throws SecurityException if access isn't allowed. + */ + private void enforceCallingPermission(@NonNull Uri uri, @NonNull Bundle extras, + boolean forWrite) { + Trace.beginSection("MP.enforceCallingPermission"); + try { + enforceCallingPermissionInternal(uri, extras, forWrite); + } finally { + Trace.endSection(); + } + } + + private void enforceCallingPermission(@NonNull Collection uris, boolean forWrite) { + for (Uri uri : uris) { + enforceCallingPermission(uri, Bundle.EMPTY, forWrite); + } + } + + private void enforceCallingPermissionInternal(@NonNull Uri uri, @NonNull Bundle extras, + boolean forWrite) { + Objects.requireNonNull(uri); + Objects.requireNonNull(extras); + + // Try a simple global check first before falling back to performing a + // simple query to probe for access. + if (checkCallingPermissionGlobal(uri, forWrite)) { + // Access allowed, yay! + return; + } + + // For redacted URI proceed with its corresponding URI as query builder doesn't support + // redacted URIs for fetching a database row + // NOTE: The grants (if any) must have been on redacted URI hence global check requires + // redacted URI + Uri redactedUri = null; + if (isRedactedUri(uri)) { + redactedUri = uri; + uri = getUriForRedactedUri(uri); + } + + final DatabaseHelper helper; + try { + helper = getDatabaseForUri(uri); + } catch (VolumeNotFoundException e) { + throw e.rethrowAsIllegalArgumentException(); + } + + final boolean allowHidden = isCallingPackageAllowedHidden(); + final int table = matchUri(uri, allowHidden); + + final String selection = extras.getString(QUERY_ARG_SQL_SELECTION); + final String[] selectionArgs = extras.getStringArray(QUERY_ARG_SQL_SELECTION_ARGS); + + // First, check to see if caller has direct write access + if (forWrite) { + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_UPDATE, table, uri, extras, null); + qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN); + try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN }, + selection, selectionArgs, null, null, null, null, null)) { + if (c.moveToFirst()) { + // Direct write access granted, yay! + return; + } + } + } + + // We only allow the user to grant access to specific media items in + // strongly typed collections; never to broad collections + boolean allowUserGrant = false; + final int matchUri = matchUri(uri, true); + switch (matchUri) { + case IMAGES_MEDIA_ID: + case AUDIO_MEDIA_ID: + case VIDEO_MEDIA_ID: + allowUserGrant = true; + break; + } + + // Second, check to see if caller has direct read access + final SQLiteQueryBuilder qb = getQueryBuilder(TYPE_QUERY, table, uri, extras, null); + qb.allowColumn(SQLiteQueryBuilder.ROWID_COLUMN); + try (Cursor c = qb.query(helper, new String[] { SQLiteQueryBuilder.ROWID_COLUMN }, + selection, selectionArgs, null, null, null, null, null)) { + if (c.moveToFirst()) { + if (!forWrite) { + // Direct read access granted, yay! + return; + } else if (allowUserGrant) { + // Caller has read access, but they wanted to write, and + // they'll need to get the user to grant that access + final Context context = getContext(); + final Collection uris = Collections.singletonList(uri); + final PendingIntent intent = MediaStore + .createWriteRequest(ContentResolver.wrap(this), uris); + + final Icon icon = getCollectionIcon(uri); + final RemoteAction action = new RemoteAction(icon, + context.getText(R.string.permission_required_action), + context.getText(R.string.permission_required_action), + intent); + + throw new RecoverableSecurityException(new SecurityException( + getCallingPackageOrSelf() + " has no access to " + uri), + context.getText(R.string.permission_required), action); + } + } + } + + if (redactedUri != null) uri = redactedUri; + throw new SecurityException(getCallingPackageOrSelf() + " has no access to " + uri); + } + + private Icon getCollectionIcon(Uri uri) { + final PackageManager pm = getContext().getPackageManager(); + final String type = uri.getPathSegments().get(1); + final String groupName; + switch (type) { + default: groupName = android.Manifest.permission_group.STORAGE; break; + } + try { + final PermissionGroupInfo perm = pm.getPermissionGroupInfo(groupName, 0); + return Icon.createWithResource(perm.packageName, perm.icon); + } catch (NameNotFoundException e) { + throw new RuntimeException(e); + } + } + + private void checkAccess(@NonNull Uri uri, @NonNull Bundle extras, @NonNull File file, + boolean isWrite) throws FileNotFoundException { + // First, does caller have the needed row-level access? + enforceCallingPermission(uri, extras, isWrite); + + // Second, does the path look consistent? + if (!FileUtils.contains(Environment.getStorageDirectory(), file)) { + checkWorldReadAccess(file.getAbsolutePath()); + } + } + + /** + * Check whether the path is a world-readable file + */ + @VisibleForTesting + public static void checkWorldReadAccess(String path) throws FileNotFoundException { + // Path has already been canonicalized, and we relax the check to look + // at groups to support runtime storage permissions. + final int accessBits = path.startsWith("/storage/") ? OsConstants.S_IRGRP + : OsConstants.S_IROTH; + try { + StructStat stat = Os.stat(path); + if (OsConstants.S_ISREG(stat.st_mode) && + ((stat.st_mode & accessBits) == accessBits)) { + checkLeadingPathComponentsWorldExecutable(path); + return; + } + } catch (ErrnoException e) { + // couldn't stat the file, either it doesn't exist or isn't + // accessible to us + } + + throw new FileNotFoundException("Can't access " + path); + } + + private static void checkLeadingPathComponentsWorldExecutable(String filePath) + throws FileNotFoundException { + File parent = new File(filePath).getParentFile(); + + // Path has already been canonicalized, and we relax the check to look + // at groups to support runtime storage permissions. + final int accessBits = filePath.startsWith("/storage/") ? OsConstants.S_IXGRP + : OsConstants.S_IXOTH; + + while (parent != null) { + if (! parent.exists()) { + // parent dir doesn't exist, give up + throw new FileNotFoundException("access denied"); + } + try { + StructStat stat = Os.stat(parent.getPath()); + if ((stat.st_mode & accessBits) != accessBits) { + // the parent dir doesn't have the appropriate access + throw new FileNotFoundException("Can't access " + filePath); + } + } catch (ErrnoException e1) { + // couldn't stat() parent + throw new FileNotFoundException("Can't access " + filePath); + } + parent = parent.getParentFile(); + } + } + + @VisibleForTesting + static class FallbackException extends Exception { + private final int mThrowSdkVersion; + + public FallbackException(String message, int throwSdkVersion) { + super(message); + mThrowSdkVersion = throwSdkVersion; + } + + public FallbackException(String message, Throwable cause, int throwSdkVersion) { + super(message, cause); + mThrowSdkVersion = throwSdkVersion; + } + + @Override + public String getMessage() { + if (getCause() != null) { + return super.getMessage() + ": " + getCause().getMessage(); + } else { + return super.getMessage(); + } + } + + public IllegalArgumentException rethrowAsIllegalArgumentException() { + throw new IllegalArgumentException(getMessage()); + } + + public Cursor translateForQuery(int targetSdkVersion) { + if (targetSdkVersion >= mThrowSdkVersion) { + throw new IllegalArgumentException(getMessage()); + } else { + Log.w(TAG, getMessage()); + return null; + } + } + + public Uri translateForInsert(int targetSdkVersion) { + if (targetSdkVersion >= mThrowSdkVersion) { + throw new IllegalArgumentException(getMessage()); + } else { + Log.w(TAG, getMessage()); + return null; + } + } + + public int translateForBulkInsert(int targetSdkVersion) { + if (targetSdkVersion >= mThrowSdkVersion) { + throw new IllegalArgumentException(getMessage()); + } else { + Log.w(TAG, getMessage()); + return 0; + } + } + + public int translateForUpdateDelete(int targetSdkVersion) { + if (targetSdkVersion >= mThrowSdkVersion) { + throw new IllegalArgumentException(getMessage()); + } else { + Log.w(TAG, getMessage()); + return 0; + } + } + } + + @VisibleForTesting + static class VolumeNotFoundException extends FallbackException { + public VolumeNotFoundException(String volumeName) { + super("Volume " + volumeName + " not found", Build.VERSION_CODES.Q); + } + } + + @VisibleForTesting + static class VolumeArgumentException extends FallbackException { + public VolumeArgumentException(File actual, Collection allowed) { + super("Requested path " + actual + " doesn't appear under " + allowed, + Build.VERSION_CODES.Q); + } + } + + public List getSupportedTranscodingRelativePaths() { + return mTranscodeHelper.getSupportedRelativePaths(); + } + + public List getSupportedUncachedRelativePaths() { + return StringUtils.verifySupportedUncachedRelativePaths( + StringUtils.getStringArrayConfig(getContext(), + R.array.config_supported_uncached_relative_paths)); + } + + /** + * Creating a new method for Transcoding to avoid any merge conflicts. + * TODO(b/170465810): Remove this when the code is refactored. + */ + @NonNull DatabaseHelper getDatabaseForUriForTranscoding(Uri uri) + throws VolumeNotFoundException { + return getDatabaseForUri(uri); + } + + @NonNull + private DatabaseHelper getDatabaseForUri(Uri uri) throws VolumeNotFoundException { + final String volumeName = resolveVolumeName(uri); + synchronized (mAttachedVolumes) { + boolean volumeAttached = false; + UserHandle user = mCallingIdentity.get().getUser(); + for (MediaVolume vol : mAttachedVolumes) { + if (vol.getName().equals(volumeName) + && (vol.isVisibleToUser(user) || vol.isPublicVolume()) ) { + volumeAttached = true; + break; + } + } + if (!volumeAttached) { + // Dump some more debug info + Log.e(TAG, "Volume " + volumeName + " not found, calling identity: " + + user + ", attached volumes: " + mAttachedVolumes); + throw new VolumeNotFoundException(volumeName); + } + } + if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + return mInternalDatabase; + } else { + return mExternalDatabase; + } + } + + static boolean isMediaDatabaseName(String name) { + if (INTERNAL_DATABASE_NAME.equals(name)) { + return true; + } + if (EXTERNAL_DATABASE_NAME.equals(name)) { + return true; + } + return name.startsWith("external-") && name.endsWith(".db"); + } + + @NonNull + private Uri getBaseContentUri(@NonNull String volumeName) { + return MediaStore.AUTHORITY_URI.buildUpon().appendPath(volumeName).build(); + } + + public Uri attachVolume(MediaVolume volume, boolean validate, String volumeState) { + Log.v(TAG, "attachVolume() called for " + volume.getName() + " with state:" + volumeState); + if (mCallingIdentity.get().pid != android.os.Process.myPid()) { + throw new SecurityException( + "Opening and closing databases not allowed."); + } + + final String volumeName = volume.getName(); + + // Quick check for shady volume names + MediaStore.checkArgumentVolumeName(volumeName); + + // Quick check that volume actually exists + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName) && validate) { + try { + getVolumePath(volumeName); + } catch (IOException e) { + throw new IllegalArgumentException( + "Volume " + volume + " currently unavailable", e); + } + } + + synchronized (mAttachedVolumes) { + mAttachedVolumes.add(volume); + } + + final ContentResolver resolver = getContext().getContentResolver(); + final Uri uri = getBaseContentUri(volumeName); + // TODO(b/182396009) we probably also want to notify clone profile (and vice versa) + resolver.notifyChange(getBaseContentUri(volumeName), null); + + if (LOGV) Log.v(TAG, "Attached volume: " + volume); + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + // Also notify on synthetic view of all devices + resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null); + + ForegroundThread.getExecutor().execute(() -> { + mExternalDatabase.runWithTransaction((db) -> { + ensureNecessaryFolders(volume, db); + return null; + }); + + // We just finished the database operation above, we know that + // it's ready to answer queries, so notify our DocumentProvider + // so it can answer queries without risking ANR + MediaDocumentsProvider.onMediaStoreReady(getContext()); + }); + } + + if (Environment.MEDIA_MOUNTED.equalsIgnoreCase(volumeState)) { + mDatabaseBackupAndRecovery.setupVolumeDbBackupAndRecovery(volume.getName()); + } + + return uri; + } + + private void detachVolume(Uri uri) { + final String volumeName = MediaStore.getVolumeName(uri); + try { + detachVolume(getVolume(volumeName)); + } catch (FileNotFoundException e) { + Log.e(TAG, "Couldn't find volume for URI " + uri, e) ; + } + } + + public boolean isVolumeAttached(MediaVolume volume) { + synchronized (mAttachedVolumes) { + return mAttachedVolumes.contains(volume); + } + } + + public void detachVolume(MediaVolume volume) { + Log.v(TAG, "detachVolume() received for " + volume.getName()); + if (mCallingIdentity.get().pid != android.os.Process.myPid()) { + throw new SecurityException( + "Opening and closing databases not allowed."); + } + + final String volumeName = volume.getName(); + + // Quick check for shady volume names + MediaStore.checkArgumentVolumeName(volumeName); + + if (MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + throw new UnsupportedOperationException( + "Deleting the internal volume is not allowed"); + } + + mDatabaseBackupAndRecovery.onDetachVolume(volumeName); + // Signal any scanning to shut down + mMediaScanner.onDetachVolume(volume); + + synchronized (mAttachedVolumes) { + mAttachedVolumes.remove(volume); + } + + final ContentResolver resolver = getContext().getContentResolver(); + resolver.notifyChange(getBaseContentUri(volumeName), null); + + if (!MediaStore.VOLUME_INTERNAL.equals(volumeName)) { + // Also notify on synthetic view of all devices + resolver.notifyChange(getBaseContentUri(MediaStore.VOLUME_EXTERNAL), null); + } + + if (LOGV) Log.v(TAG, "Detached volume: " + volumeName); + } + + private void ensureNecessaryFolders(MediaVolume volume, SQLiteDatabase db) { + ensureDefaultFolders(volume, db); + ensureThumbnailsValid(volume, db); + + // Create redacted directories + if (MediaStore.VOLUME_EXTERNAL_PRIMARY.equalsIgnoreCase(volume.getName())) { + // Create dir for redacted and picker URI paths. + File redactedRelativePath = buildPrimaryVolumeFile(uidToUserId(MY_UID), + getRedactedRelativePath()); + if (!redactedRelativePath.exists() && !redactedRelativePath.mkdirs()) { + // We should always be able to create these directories from MediaProvider + Log.wtf(TAG, "Couldn't create redacted path for " + UserHandle.myUserId()); + } + } + } + + @GuardedBy("mAttachedVolumes") + private final ArraySet mAttachedVolumes = new ArraySet<>(); + @GuardedBy("mCustomCollators") + private final ArraySet mCustomCollators = new ArraySet<>(); + + private MediaScanner mMediaScanner; + + private ProjectionHelper mProjectionHelper; + private DatabaseHelper mInternalDatabase; + private DatabaseHelper mExternalDatabase; + private PickerDbFacade mPickerDbFacade; + private ExternalDbFacade mExternalDbFacade; + private PickerDataLayer mPickerDataLayer; + private ConfigStore mConfigStore; + private PickerSyncController mPickerSyncController; + private TranscodeHelper mTranscodeHelper; + private MediaGrants mMediaGrants; + private DatabaseBackupAndRecovery mDatabaseBackupAndRecovery; + + // name of the volume currently being scanned by the media scanner (or null) + private String mMediaScannerVolume; + + + private static final HashSet REDACTED_URI_SUPPORTED_TYPES = new HashSet<>( + Arrays.asList(AUDIO_MEDIA_ID, IMAGES_MEDIA_ID, VIDEO_MEDIA_ID, FILES_ID, DOWNLOADS_ID)); + + private LocalUriMatcher mUriMatcher; + + private int matchUri(Uri uri, boolean allowHidden) { + return mUriMatcher.matchUri(uri, allowHidden); + } + + + + /** + * Set of columns that can be safely mutated by external callers; all other + * columns are treated as read-only, since they reflect what the media + * scanner found on disk, and any mutations would be overwritten the next + * time the media was scanned. + */ + private static final ArraySet sMutableColumns = new ArraySet<>(); + + static { + sMutableColumns.add(MediaStore.MediaColumns.DATA); + sMutableColumns.add(MediaStore.MediaColumns.RELATIVE_PATH); + sMutableColumns.add(MediaStore.MediaColumns.DISPLAY_NAME); + sMutableColumns.add(MediaStore.MediaColumns.IS_PENDING); + sMutableColumns.add(MediaStore.MediaColumns.IS_TRASHED); + sMutableColumns.add(MediaStore.MediaColumns.IS_FAVORITE); + sMutableColumns.add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME); + + sMutableColumns.add(MediaStore.Audio.AudioColumns.BOOKMARK); + + sMutableColumns.add(MediaStore.Video.VideoColumns.TAGS); + sMutableColumns.add(MediaStore.Video.VideoColumns.CATEGORY); + sMutableColumns.add(MediaStore.Video.VideoColumns.BOOKMARK); + + sMutableColumns.add(MediaStore.Audio.Playlists.NAME); + sMutableColumns.add(MediaStore.Audio.Playlists.Members.AUDIO_ID); + sMutableColumns.add(MediaStore.Audio.Playlists.Members.PLAY_ORDER); + + sMutableColumns.add(MediaStore.DownloadColumns.DOWNLOAD_URI); + sMutableColumns.add(MediaStore.DownloadColumns.REFERER_URI); + + sMutableColumns.add(MediaStore.Files.FileColumns.MIME_TYPE); + sMutableColumns.add(MediaStore.Files.FileColumns.MEDIA_TYPE); + } + + /** + * Set of columns that affect placement of files on disk. + */ + private static final ArraySet sPlacementColumns = new ArraySet<>(); + + static { + sPlacementColumns.add(MediaStore.MediaColumns.DATA); + sPlacementColumns.add(MediaStore.MediaColumns.RELATIVE_PATH); + sPlacementColumns.add(MediaStore.MediaColumns.DISPLAY_NAME); + sPlacementColumns.add(MediaStore.MediaColumns.MIME_TYPE); + sPlacementColumns.add(MediaStore.MediaColumns.IS_PENDING); + sPlacementColumns.add(MediaStore.MediaColumns.IS_TRASHED); + sPlacementColumns.add(MediaStore.MediaColumns.DATE_EXPIRES); + } + + /** + * List of abusive custom columns that we're willing to allow via + * {@link SQLiteQueryBuilder#setProjectionAllowlist(Collection)}. + */ + static final ArrayList sAllowlist = new ArrayList<>(); + + private static void addAllowlistPattern(String pattern) { + sAllowlist.add(Pattern.compile(" *" + pattern + " *")); + } + + static { + final String maybeAs = "( (as )?[_a-z0-9]+)?"; + addAllowlistPattern("(?i)[_a-z0-9]+" + maybeAs); + addAllowlistPattern("audio\\._id AS _id"); + addAllowlistPattern( + "(?i)(min|max|sum|avg|total|count|cast)\\(([_a-z0-9]+" + + maybeAs + + "|\\*)\\)" + + maybeAs); + addAllowlistPattern( + "case when case when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added" + + " \\* \\d+ when \\(date_added >= \\d+ and date_added < \\d+\\) then" + + " date_added when \\(date_added >= \\d+ and date_added < \\d+\\) then" + + " date_added / \\d+ else \\d+ end > case when \\(date_modified >= \\d+ and" + + " date_modified < \\d+\\) then date_modified \\* \\d+ when \\(date_modified" + + " >= \\d+ and date_modified < \\d+\\) then date_modified when" + + " \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified /" + + " \\d+ else \\d+ end then case when \\(date_added >= \\d+ and date_added <" + + " \\d+\\) then date_added \\* \\d+ when \\(date_added >= \\d+ and date_added" + + " < \\d+\\) then date_added when \\(date_added >= \\d+ and date_added <" + + " \\d+\\) then date_added / \\d+ else \\d+ end else case when" + + " \\(date_modified >= \\d+ and date_modified < \\d+\\) then date_modified \\*" + + " \\d+ when \\(date_modified >= \\d+ and date_modified < \\d+\\) then" + + " date_modified when \\(date_modified >= \\d+ and date_modified < \\d+\\)" + + " then date_modified / \\d+ else \\d+ end end as corrected_added_modified"); + addAllowlistPattern( + "MAX\\(case when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken \\*" + + " \\d+ when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken when" + + " \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken / \\d+ else" + + " \\d+ end\\)"); + addAllowlistPattern( + "MAX\\(case when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added \\*" + + " \\d+ when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added" + + " when \\(date_added >= \\d+ and date_added < \\d+\\) then date_added / \\d+" + + " else \\d+ end\\)"); + addAllowlistPattern( + "MAX\\(case when \\(date_modified >= \\d+ and date_modified < \\d+\\) then" + + " date_modified \\* \\d+ when \\(date_modified >= \\d+ and date_modified <" + + " \\d+\\) then date_modified when \\(date_modified >= \\d+ and date_modified" + + " < \\d+\\) then date_modified / \\d+ else \\d+ end\\)"); + addAllowlistPattern("\"content://media/[a-z]+/audio/media\""); + addAllowlistPattern( + "substr\\(_data, length\\(_data\\)-length\\(_display_name\\), 1\\) as" + + " filename_prevchar"); + addAllowlistPattern("\\*" + maybeAs); + addAllowlistPattern( + "case when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken \\* \\d+" + + " when \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken when" + + " \\(datetaken >= \\d+ and datetaken < \\d+\\) then datetaken / \\d+ else" + + " \\d+ end"); + } + + public ArrayMap getProjectionMap(Class... clazzes) { + return mProjectionHelper.getProjectionMap(clazzes); + } + + static boolean containsAny(Set a, Set b) { + for (T i : b) { + if (a.contains(i)) { + return true; + } + } + return false; + } + + @VisibleForTesting + @Nullable + static Uri computeCommonPrefix(@NonNull List uris) { + if (uris.isEmpty()) return null; + + final Uri base = uris.get(0); + final List basePath = new ArrayList<>(base.getPathSegments()); + for (int i = 1; i < uris.size(); i++) { + final List probePath = uris.get(i).getPathSegments(); + for (int j = 0; j < basePath.size() && j < probePath.size(); j++) { + if (!Objects.equals(basePath.get(j), probePath.get(j))) { + // Trim away all remaining common elements + while (basePath.size() > j) { + basePath.remove(j); + } + } + } + + final int probeSize = probePath.size(); + while (basePath.size() > probeSize) { + basePath.remove(probeSize); + } + } + + final Uri.Builder builder = base.buildUpon().path(null); + for (String s : basePath) { + builder.appendPath(s); + } + return builder.build(); + } + + public ExternalDbFacade getExternalDbFacade() { + return mExternalDbFacade; + } + + public PickerSyncController getPickerSyncController() { + return mPickerSyncController; + } + + private boolean isCallingPackageSystemGallery() { + if (mCallingIdentity.get().hasPermission(PERMISSION_IS_SYSTEM_GALLERY)) { + if (isCallingPackageRequestingLegacy()) { + return isCallingPackageLegacyWrite(); + } + return true; + } + return false; + } + + private int getCallingUidOrSelf() { + return mCallingIdentity.get().uid; + } + + @Deprecated + private String getCallingPackageOrSelf() { + return mCallingIdentity.get().getPackageName(); + } + + @Deprecated + @VisibleForTesting + public int getCallingPackageTargetSdkVersion() { + return mCallingIdentity.get().getTargetSdkVersion(); + } + + @Deprecated + private boolean isCallingPackageAllowedHidden() { + return isCallingPackageSelf(); + } + + @Deprecated + private boolean isCallingPackageSelf() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_SELF); + } + + @Deprecated + private boolean isCallingPackageShell() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_SHELL); + } + + @Deprecated + private boolean isCallingPackageManager() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_MANAGER); + } + + @Deprecated + private boolean isCallingPackageDelegator() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_DELEGATOR); + } + + @Deprecated + private boolean isCallingPackageLegacyRead() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_READ); + } + + @Deprecated + private boolean isCallingPackageLegacyWrite() { + return mCallingIdentity.get().hasPermission(PERMISSION_IS_LEGACY_WRITE); + } + + @Override + public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { + writer.println("mThumbSize=" + mThumbSize); + synchronized (mAttachedVolumes) { + writer.println("mAttachedVolumes=" + mAttachedVolumes); + } + writer.println(); + + mVolumeCache.dump(writer); + writer.println(); + + mUserCache.dump(writer); + writer.println(); + + mTranscodeHelper.dump(writer); + writer.println(); + + mConfigStore.dump(writer); + writer.println(); + + mPickerDbFacade.dump(writer); + writer.println(); + + mPickerSyncController.dump(writer); + writer.println(); + + dumpAccessLogs(writer); + writer.println(); + + Logging.dumpPersistent(writer); + } + + private void dumpAccessLogs(PrintWriter writer) { + synchronized (mCachedCallingIdentityForFuse) { + for (int i = 0; i < mCachedCallingIdentityForFuse.size(); i++) { + mCachedCallingIdentityForFuse.valueAt(i).dump(writer); + } + } + } + + /** + * Called once - from {@link #onCreate()}. + */ + @NonNull + private ConfigStore createConfigStore() { + // Tests may want override provideConfigStore() in order to inject a mock object. + ConfigStore configStore = provideConfigStore(); + if (configStore == null) { + // Tests did not provide an alternative implementation: create our regular "production" + // ConfigStore. + configStore = MediaApplication.getConfigStore(); + } + return configStore; + } + + /** + * FOT TESTING PURPOSES ONLY + *

        + * Allows injecting alternative {@link ConfigStore} implementation. + */ + @VisibleForTesting + @Nullable + protected ConfigStore provideConfigStore() { + return null; + } + + protected VolumeCache getVolumeCache() { + return mVolumeCache; + } + + protected DatabaseBackupAndRecovery createDatabaseBackupAndRecovery() { + return new DatabaseBackupAndRecovery(mConfigStore, mVolumeCache); + } +} diff --git a/aosp/system/core/init/first_stage_init.cpp b/aosp/system/core/init/first_stage_init.cpp index 0ef9c3f2e..efb064bc3 100644 --- a/aosp/system/core/init/first_stage_init.cpp +++ b/aosp/system/core/init/first_stage_init.cpp @@ -372,7 +372,7 @@ int FirstStageMain(int argc, char** argv) { // Mount staging areas for devices managed by vold // See storage config details at http://source.android.com/devices/storage/ CHECKCALL(mount("tmpfs", "/mnt", "tmpfs", MS_NOEXEC | MS_NOSUID | MS_NODEV, - "mode=0755,uid=0,gid=1000")); + "mode=0755,uid=0,gid=1000,size=16m")); // /mnt/vendor is used to mount vendor-specific partitions that can not be // part of the vendor partition, e.g. because they are mounted read-write. CHECKCALL(mkdir("/mnt/vendor", 0755)); diff --git a/aosp/vendor/isula/properties.mk b/aosp/vendor/isula/properties.mk index 53311d9a8..2f7d57527 100644 --- a/aosp/vendor/isula/properties.mk +++ b/aosp/vendor/isula/properties.mk @@ -66,4 +66,5 @@ PRODUCT_PROPERTY_OVERRIDES += \ ro.product.manufacturer=Google \ ro.product.brand=google \ ro.product.model=sayram \ + persist.sys.fuse.passthrough.enable=true \ -- Gitee