/* * Copyright (C) 2020 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.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; import static android.provider.MediaStore.Files.FileColumns.TRANSCODE_COMPLETE; import static android.provider.MediaStore.Files.FileColumns.TRANSCODE_EMPTY; import static android.provider.MediaStore.MATCH_EXCLUDE; import static android.provider.MediaStore.QUERY_ARG_MATCH_PENDING; import static android.provider.MediaStore.QUERY_ARG_MATCH_TRASHED; import static com.android.providers.media.MediaProvider.VolumeNotFoundException; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SESSION_CANCELED; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__FAIL; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__SUCCESS; import static com.android.providers.media.MediaProviderStatsLog.TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED; import android.annotation.IntRange; import android.annotation.LongDef; import android.app.ActivityManager; import android.app.ActivityManager.OnUidImportanceListener; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.compat.CompatChanges; import android.compat.annotation.ChangeId; import android.compat.annotation.Disabled; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.InstallSourceInfo; import android.content.pm.PackageManager; import android.content.pm.PackageManager.Property; import android.content.res.XmlResourceParser; import android.database.Cursor; import android.media.ApplicationMediaCapabilities; import android.media.MediaFeature; import android.media.MediaFormat; import android.media.MediaTranscodingManager; import android.media.MediaTranscodingManager.VideoTranscodingRequest; import android.media.MediaTranscodingManager.TranscodingRequest.VideoFormatResolver; import android.media.MediaTranscodingManager.TranscodingSession; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.os.ParcelFileDescriptor; import android.os.Process; import android.os.SystemClock; import android.os.SystemProperties; import android.os.UserHandle; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; import android.provider.MediaStore; import android.provider.MediaStore.Files.FileColumns; import android.provider.MediaStore.MediaColumns; import android.provider.MediaStore.Video.VideoColumns; import android.text.TextUtils; import android.util.ArrayMap; import android.util.ArraySet; import android.util.Log; import android.util.Pair; import android.util.SparseArray; import android.widget.Toast; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import com.android.internal.annotations.VisibleForTesting; import com.android.modules.utils.build.SdkLevel; import com.android.providers.media.util.BackgroundThread; import com.android.providers.media.util.FileUtils; import com.android.providers.media.util.ForegroundThread; import com.android.providers.media.util.SQLiteQueryBuilder; import java.io.BufferedReader; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintWriter; import java.io.RandomAccessFile; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.regex.Matcher; import java.util.regex.Pattern; @RequiresApi(Build.VERSION_CODES.S) public class TranscodeHelperImpl implements TranscodeHelper { private static final String TAG = "TranscodeHelper"; private static final boolean DEBUG = SystemProperties.getBoolean("persist.sys.fuse.log", false); private static final float MAX_APP_NAME_SIZE_PX = 500f; // Notice the pairing of the keys.When you change a DEVICE_CONFIG key, then please also change // the corresponding SYS_PROP key too; and vice-versa. // Keeping the whole strings separate for the ease of text search. private static final String TRANSCODE_ENABLED_SYS_PROP_KEY = "persist.sys.fuse.transcode_enabled"; private static final String TRANSCODE_ENABLED_DEVICE_CONFIG_KEY = "transcode_enabled"; private static final String TRANSCODE_DEFAULT_SYS_PROP_KEY = "persist.sys.fuse.transcode_default"; private static final String TRANSCODE_DEFAULT_DEVICE_CONFIG_KEY = "transcode_default"; private static final String TRANSCODE_USER_CONTROL_SYS_PROP_KEY = "persist.sys.fuse.transcode_user_control"; private static final String TRANSCODE_COMPAT_MANIFEST_KEY = "transcode_compat_manifest"; private static final String TRANSCODE_COMPAT_STALE_KEY = "transcode_compat_stale"; private static final String TRANSCODE_MAX_DURATION_MS_KEY = "transcode_max_duration_ms"; private static final int MY_UID = android.os.Process.myUid(); private static final int MAX_TRANSCODE_DURATION_MS = (int) TimeUnit.MINUTES.toMillis(1); /** * Force enable an app to support the HEVC media capability * * Apps should declare their supported media capabilities in their manifest but this flag can be * used to force an app into supporting HEVC, hence avoiding transcoding while accessing media * encoded in HEVC. * * Setting this flag will override any OS level defaults for apps. It is disabled by default, * meaning that the OS defaults would take precedence. * * Setting this flag and {@code FORCE_DISABLE_HEVC_SUPPORT} is an undefined * state and will result in the OS ignoring both flags. */ @ChangeId @Disabled private static final long FORCE_ENABLE_HEVC_SUPPORT = 174228127L; /** * Force disable an app from supporting the HEVC media capability * * Apps should declare their supported media capabilities in their manifest but this flag can be * used to force an app into not supporting HEVC, hence forcing transcoding while accessing * media encoded in HEVC. * * Setting this flag will override any OS level defaults for apps. It is disabled by default, * meaning that the OS defaults would take precedence. * * Setting this flag and {@code FORCE_ENABLE_HEVC_SUPPORT} is an undefined state * and will result in the OS ignoring both flags. */ @ChangeId @Disabled private static final long FORCE_DISABLE_HEVC_SUPPORT = 174227820L; @VisibleForTesting static final int FLAG_HEVC = 1 << 0; @VisibleForTesting static final int FLAG_SLOW_MOTION = 1 << 1; private static final int FLAG_HDR_10 = 1 << 2; private static final int FLAG_HDR_10_PLUS = 1 << 3; private static final int FLAG_HDR_HLG = 1 << 4; private static final int FLAG_HDR_DOLBY_VISION = 1 << 5; private static final int MEDIA_FORMAT_FLAG_MASK = FLAG_HEVC | FLAG_SLOW_MOTION | FLAG_HDR_10 | FLAG_HDR_10_PLUS | FLAG_HDR_HLG | FLAG_HDR_DOLBY_VISION; @LongDef({ FLAG_HEVC, FLAG_SLOW_MOTION, FLAG_HDR_10, FLAG_HDR_10_PLUS, FLAG_HDR_HLG, FLAG_HDR_DOLBY_VISION }) @Retention(RetentionPolicy.SOURCE) public @interface ApplicationMediaCapabilitiesFlags { } /** Coefficient to 'guess' how long a transcoding session might take */ private static final double TRANSCODING_TIMEOUT_COEFFICIENT = 2; /** Coefficient to 'guess' how large a transcoded file might be */ private static final double TRANSCODING_SIZE_COEFFICIENT = 2; /** * Copied from MediaProvider.java * TODO(b/170465810): Remove this when getQueryBuilder code is refactored. */ private static final int TYPE_QUERY = 0; private static final int TYPE_UPDATE = 2; private static final int MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT = 16; private static final String DIRECTORY_CAMERA = "Camera"; private static final boolean IS_TRANSCODING_SUPPORTED = SdkLevel.isAtLeastS(); private final Object mLock = new Object(); private final Context mContext; private final MediaProvider mMediaProvider; private final PackageManager mPackageManager; private final StorageManager mStorageManager; private final ActivityManager mActivityManager; private final File mTranscodeDirectory; @GuardedBy("mLock") private UUID mTranscodeVolumeUuid; @GuardedBy("mLock") private final Map mStorageTranscodingSessions = new ArrayMap<>(); // These are for dumping purpose only. // We keep these separately because the probability of getting cancelled and error'ed sessions // is pretty low, and we are limiting the count of what we keep. So, we don't wanna miss out // on dumping the cancelled and error'ed sessions. @GuardedBy("mLock") private final Map mSuccessfulTranscodeSessions = createFinishedTranscodingSessionMap(); @GuardedBy("mLock") private final Map mCancelledTranscodeSessions = createFinishedTranscodingSessionMap(); @GuardedBy("mLock") private final Map mErroredTranscodeSessions = createFinishedTranscodingSessionMap(); private final TranscodeUiNotifier mTranscodingUiNotifier; private final TranscodeDenialController mTranscodeDenialController; private final SessionTiming mSessionTiming; @GuardedBy("mLock") private final Map mAppCompatMediaCapabilities = new ArrayMap<>(); @GuardedBy("mLock") private boolean mIsTranscodeEnabled; private static final String[] TRANSCODE_CACHE_INFO_PROJECTION = {FileColumns._ID, FileColumns._TRANSCODE_STATUS}; private static final String TRANSCODE_WHERE_CLAUSE = FileColumns.DATA + "=?" + " and mime_type not like 'null'"; public TranscodeHelperImpl(Context context, MediaProvider mediaProvider) { mContext = context; mPackageManager = context.getPackageManager(); mStorageManager = context.getSystemService(StorageManager.class); mActivityManager = context.getSystemService(ActivityManager.class); mMediaProvider = mediaProvider; mTranscodeDirectory = new File("/storage/emulated/" + UserHandle.myUserId(), DIRECTORY_TRANSCODE); mTranscodeDirectory.mkdirs(); mSessionTiming = new SessionTiming(); mTranscodingUiNotifier = new TranscodeUiNotifier(context, mSessionTiming); mIsTranscodeEnabled = isTranscodeEnabled(); int maxTranscodeDurationMs = mMediaProvider.getIntDeviceConfig(TRANSCODE_MAX_DURATION_MS_KEY, MAX_TRANSCODE_DURATION_MS); mTranscodeDenialController = new TranscodeDenialController(mActivityManager, mTranscodingUiNotifier, maxTranscodeDurationMs); parseTranscodeCompatManifest(); // The storage namespace is a boot namespace so we actually don't expect this to be changed // after boot, but it is useful for tests mMediaProvider.addOnPropertiesChangedListener(properties -> parseTranscodeCompatManifest()); } /** * Regex that matches path of transcode file. The regex only * matches emulated volume, for files in other volumes we don't * seamlessly transcode. */ private static final Pattern PATTERN_TRANSCODE_PATH = Pattern.compile( "(?i)^/storage/emulated/(?:[0-9]+)/\\.transforms/transcode/(?:\\d+)$"); private static final String DIRECTORY_TRANSCODE = ".transforms/transcode"; /** * @return true if the file path matches transcode file path. */ private static boolean isTranscodeFile(@NonNull String path) { final Matcher matcher = PATTERN_TRANSCODE_PATH.matcher(path); return matcher.matches(); } public void freeCache(long bytes) { File[] files = mTranscodeDirectory.listFiles(); for (File file : files) { if (bytes <= 0) { return; } if (file.exists() && file.isFile()) { long size = file.length(); boolean deleted = file.delete(); if (deleted) { bytes -= size; } } } } private UUID getTranscodeVolumeUuid() { synchronized (mLock) { if (mTranscodeVolumeUuid != null) { return mTranscodeVolumeUuid; } } StorageVolume vol = mStorageManager.getStorageVolume(mTranscodeDirectory); if (vol != null) { synchronized (mLock) { mTranscodeVolumeUuid = vol.getStorageUuid(); return mTranscodeVolumeUuid; } } else { Log.w(TAG, "Failed to get storage volume UUID for: " + mTranscodeDirectory); return null; } } /** * @return transcode file's path for given {@code rowId} */ @NonNull private String getTranscodePath(long rowId) { return new File(mTranscodeDirectory, String.valueOf(rowId)).getAbsolutePath(); } public void onAnrDelayStarted(String packageName, int uid, int tid, int reason) { if (!isTranscodeEnabled()) { return; } if (uid == MY_UID) { Log.w(TAG, "Skipping ANR delay handling for MediaProvider"); return; } logVerbose("Checking transcode status during ANR of " + packageName); Set sessions = new ArraySet<>(); synchronized (mLock) { sessions.addAll(mStorageTranscodingSessions.values()); } for (StorageTranscodingSession session: sessions) { if (session.isUidBlocked(uid)) { session.setAnr(); Log.i(TAG, "Package: " + packageName + " with uid: " + uid + " and tid: " + tid + " is blocked on transcoding: " + session); // TODO(b/170973510): Show UI } } } // TODO(b/170974147): This should probably use a cache so we don't need to ask the // package manager every time for the package name or installer name private String getMetricsSafeNameForUid(int uid) { String name = mPackageManager.getNameForUid(uid); if (name == null) { Log.w(TAG, "null package name received from getNameForUid for uid " + uid + ", logging uid instead."); return Integer.toString(uid); } else if (name.isEmpty()) { Log.w(TAG, "empty package name received from getNameForUid for uid " + uid + ", logging uid instead"); return ":empty_package_name:" + uid; } else { try { InstallSourceInfo installInfo = mPackageManager.getInstallSourceInfo(name); ApplicationInfo applicationInfo = mPackageManager.getApplicationInfo(name, 0); if (installInfo.getInstallingPackageName() == null && ((applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0)) { // For privacy reasons, we don't log metrics for side-loaded packages that // are not system packages return ":installer_adb:" + uid; } return name; } catch (PackageManager.NameNotFoundException e) { Log.w(TAG, "Unable to check installer for uid: " + uid, e); return ":name_not_found:" + uid; } } } private void reportTranscodingResult(int uid, boolean success, int errorCode, int failureReason, long transcodingDurationMs, int transcodingReason, String src, String dst, boolean hasAnr) { BackgroundThread.getExecutor().execute(() -> { try (Cursor c = queryFileForTranscode(src, new String[]{MediaColumns.DURATION, MediaColumns.CAPTURE_FRAMERATE, MediaColumns.WIDTH, MediaColumns.HEIGHT})) { if (c != null && c.moveToNext()) { MediaProviderStatsLog.write( TRANSCODING_DATA, getMetricsSafeNameForUid(uid), MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_TRANSCODE, success ? new File(dst).length() : -1, success ? TRANSCODING_DATA__TRANSCODE_RESULT__SUCCESS : TRANSCODING_DATA__TRANSCODE_RESULT__FAIL, transcodingDurationMs, c.getLong(0) /* video_duration */, c.getLong(1) /* capture_framerate */, transcodingReason, c.getLong(2) /* width */, c.getLong(3) /* height */, hasAnr, failureReason, errorCode); } } }); } public boolean transcode(String src, String dst, int uid, int reason) { // This can only happen when we are in a version that supports transcoding. // So, no need to check for the SDK version here. StorageTranscodingSession storageSession = null; TranscodingSession transcodingSession = null; CountDownLatch latch = null; long startTime = SystemClock.elapsedRealtime(); boolean result = false; int errorCode = TranscodingSession.ERROR_SERVICE_DIED; int failureReason = TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; try { synchronized (mLock) { storageSession = mStorageTranscodingSessions.get(src); if (storageSession == null) { latch = new CountDownLatch(1); try { transcodingSession = enqueueTranscodingSession(src, dst, uid, latch); if (transcodingSession == null) { Log.e(TAG, "Failed to enqueue request due to Service unavailable"); throw new IllegalStateException("Failed to enqueue request"); } } catch (UnsupportedOperationException | IOException e) { throw new IllegalStateException(e); } storageSession = new StorageTranscodingSession(transcodingSession, latch, src, dst); mStorageTranscodingSessions.put(src, storageSession); } else { latch = storageSession.latch; transcodingSession = storageSession.session; if (latch == null || transcodingSession == null) { throw new IllegalStateException("Uninitialised TranscodingSession for uid: " + uid + ". Path: " + src); } } storageSession.addBlockedUid(uid); } failureReason = waitTranscodingResult(uid, src, transcodingSession, latch); errorCode = transcodingSession.getErrorCode(); result = failureReason == TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; if (result) { updateTranscodeStatus(src, TRANSCODE_COMPLETE); } else { logEvent("Transcoding failed for " + src + ". session: ", transcodingSession); // Attempt to workaround potential media transcoding deadlock // Cancelling a deadlocked session seems to unblock the transcoder transcodingSession.cancel(); } } finally { if (storageSession == null) { Log.w(TAG, "Failed to create a StorageTranscodingSession"); // We were unable to even queue the request. Which means the media service is // in a very bad state reportTranscodingResult(uid, result, errorCode, failureReason, SystemClock.elapsedRealtime() - startTime, reason, src, dst, false /* hasAnr */); return false; } storageSession.notifyFinished(failureReason, errorCode); if (errorCode == TranscodingSession.ERROR_DROPPED_BY_SERVICE) { // If the transcoding service drops a request for a uid the uid will be denied // transcoding access until the next boot, notify the denial controller which may // also show a denial UI mTranscodeDenialController.onTranscodingDropped(uid); } reportTranscodingResult(uid, result, errorCode, failureReason, SystemClock.elapsedRealtime() - startTime, reason, src, dst, storageSession.hasAnr()); } return result; } /** * Returns IO path for a {@code path} and {@code uid} * * IO path is the actual path to be used on the lower fs for IO via FUSE. For some file * transforms, this path might be different from the path the app is requesting IO on. * * @param path file path to get an IO path for * @param uid app requesting IO * */ public String getIoPath(String path, int uid) { // This can only happen when we are in a version that supports transcoding. // So, no need to check for the SDK version here. Pair cacheInfo = getTranscodeCacheInfoFromDB(path); final long rowId = cacheInfo.first; if (rowId == -1) { // No database row found, The file is pending/trashed or not added to database yet. // Assuming that no transcoding needed. return path; } int transcodeStatus = cacheInfo.second; final String transcodePath = getTranscodePath(rowId); final File transcodeFile = new File(transcodePath); if (transcodeFile.exists()) { return transcodePath; } if (transcodeStatus == TRANSCODE_COMPLETE) { // The transcode file doesn't exist but db row is marked as TRANSCODE_COMPLETE, // update db row to TRANSCODE_EMPTY so that cache state remains valid. updateTranscodeStatus(path, TRANSCODE_EMPTY); } final File file = new File(path); long maxFileSize = (long) (file.length() * 2); mTranscodeDirectory.mkdirs(); try (RandomAccessFile raf = new RandomAccessFile(transcodeFile, "rw")) { raf.setLength(maxFileSize); } catch (IOException e) { Log.e(TAG, "Failed to initialise transcoding for file " + path, e); transcodeFile.delete(); return transcodePath; } return transcodePath; } private static int getMediaCapabilitiesUid(int uid, Bundle bundle) { if (bundle == null || !bundle.containsKey(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID)) { return uid; } int mediaCapabilitiesUid = bundle.getInt(MediaStore.EXTRA_MEDIA_CAPABILITIES_UID); if (mediaCapabilitiesUid >= Process.FIRST_APPLICATION_UID) { logVerbose( "Media capabilities uid " + mediaCapabilitiesUid + ", passed for uid " + uid); return mediaCapabilitiesUid; } Log.w(TAG, "Ignoring invalid media capabilities uid " + mediaCapabilitiesUid + " for uid: " + uid); return uid; } // TODO(b/173491972): Generalize to consider other file/app media capabilities beyond hevc /** * @return 0 or >0 representing whether we should transcode or not. * 0 means we should not transcode, otherwise we should transcode and the value is the * reason that will be logged to statsd as a transcode reason. Possible values are: *
    *
  • MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_DEFAULT=1 *
  • MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_CONFIG=2 *
  • MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_MANIFEST=3 *
  • MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_COMPAT=4 *
  • MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_EXTRA=5 *
* */ public int shouldTranscode(String path, int uid, Bundle bundle) { boolean isTranscodeEnabled = isTranscodeEnabled(); updateConfigs(isTranscodeEnabled); if (!isTranscodeEnabled) { logVerbose("Transcode not enabled"); return 0; } uid = getMediaCapabilitiesUid(uid, bundle); logVerbose("Checking shouldTranscode for: " + path + ". Uid: " + uid); if (!supportsTranscode(path) || uid < Process.FIRST_APPLICATION_UID || uid == MY_UID) { logVerbose("Transcode not supported"); // Never transcode in any of these conditions // 1. Path doesn't support transcode // 2. Uid is from native process on device // 3. Uid is ourselves, which can happen when we are opening a file via FUSE for // redaction on behalf of another app via ContentResolver return 0; } // Transcode only if file needs transcoding Pair result = getFileFlagsAndDurationMs(path); int fileFlags = result.first; long durationMs = result.second; if (fileFlags == 0) { // Nothing to transcode logVerbose("File is not HEVC"); return 0; } int accessReason = doesAppNeedTranscoding(uid, bundle, fileFlags, durationMs); if (accessReason != 0 && mTranscodeDenialController.checkFileAccess(uid, durationMs)) { logVerbose("Transcoding denied"); return 0; } return accessReason; } @VisibleForTesting int doesAppNeedTranscoding(int uid, Bundle bundle, int fileFlags, long durationMs) { // Check explicit Bundle provided if (bundle != null) { if (bundle.getBoolean(MediaStore.EXTRA_ACCEPT_ORIGINAL_MEDIA_FORMAT, false)) { logVerbose("Original format requested"); return 0; } ApplicationMediaCapabilities capabilities = bundle.getParcelable(MediaStore.EXTRA_MEDIA_CAPABILITIES); if (capabilities != null) { Pair flags = capabilitiesToMediaFormatFlags(capabilities); Optional appExtraResult = checkAppMediaSupport(flags.first, flags.second, fileFlags, "app_extra"); if (appExtraResult.isPresent()) { if (appExtraResult.get()) { return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_EXTRA; } return 0; } // Bundle didn't have enough information to make decision, continue } } // Check app compat support Optional appCompatResult = checkAppCompatSupport(uid, fileFlags); if (appCompatResult.isPresent()) { if (appCompatResult.get()) { return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_COMPAT; } return 0; } // App compat didn't have enough information to make decision, continue // If we are here then the file supports HEVC, so we only check if the package is in the // mAppCompatCapabilities. If it's there, we will respect that value. LocalCallingIdentity identity = mMediaProvider.getCachedCallingIdentityForTranscoding(uid); final String[] callingPackages = identity.getSharedPackageNames(); // Check app manifest support for (String callingPackage : callingPackages) { Optional appManifestResult = checkManifestSupport(callingPackage, identity, fileFlags); if (appManifestResult.isPresent()) { if (appManifestResult.get()) { return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_MANIFEST; } return 0; } // App manifest didn't have enough information to make decision, continue // TODO(b/169327180): We should also check app's targetSDK version to verify if app // still qualifies to be on these lists. // Check config compat manifest synchronized (mLock) { if (mAppCompatMediaCapabilities.containsKey(callingPackage)) { int configCompatFlags = mAppCompatMediaCapabilities.get(callingPackage); int supportedFlags = configCompatFlags; int unsupportedFlags = ~configCompatFlags & MEDIA_FORMAT_FLAG_MASK; Optional systemConfigResult = checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, "system_config"); if (systemConfigResult.isPresent()) { if (systemConfigResult.get()) { return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_CONFIG; } return 0; } // Should never get here because the supported & unsupported flags should span // the entire universe of file flags } } } // TODO: Need to add transcode_default as flags if (shouldTranscodeDefault()) { logVerbose("Default behavior should transcode"); return MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_DEFAULT; } else { logVerbose("Default behavior should not transcode"); return 0; } } /** * Checks if transcode is required for the given app media capabilities and file media formats * * @param appSupportedMediaFormatFlags bit mask of media capabilites explicitly supported by an * app, e.g 001 indicating HEVC support * @param appUnsupportedMediaFormatFlags bit mask of media capabilites explicitly not supported * by an app, e.g 10 indicating HDR_10 is not supportted * @param fileMediaFormatFlags bit mask of media capabilites contained in a file e.g 101 * indicating HEVC and HDR_10 media file * * @return {@code Optional} containing {@code boolean}. {@code true} means transcode is * required, {@code false} means transcode is not required and {@code empty} means a decision * could not be made. */ private Optional checkAppMediaSupport(int appSupportedMediaFormatFlags, int appUnsupportedMediaFormatFlags, int fileMediaFormatFlags, String type) { if ((appSupportedMediaFormatFlags & appUnsupportedMediaFormatFlags) != 0) { Log.w(TAG, "Ignoring app media capabilities for type: [" + type + "]. Supported and unsupported capapbilities are not mutually exclusive"); return Optional.empty(); } // As an example: // 1. appSupportedMediaFormatFlags=001 # App supports HEVC // 2. appUnsupportedMediaFormatFlags=100 # App does not support HDR_10 // 3. fileSupportedMediaFormatFlags=101 # File contains HEVC and HDR_10 // File contains HDR_10 but app explicitly doesn't support it int fileMediaFormatsUnsupportedByApp = fileMediaFormatFlags & appUnsupportedMediaFormatFlags; if (fileMediaFormatsUnsupportedByApp != 0) { // If *any* file media formats are unsupported by the app we need to transcode logVerbose("App media capability check for type: [" + type + "]" + ". transcode=true"); return Optional.of(true); } // fileMediaFormatsSupportedByApp=001 # File contains HEVC but app explicitly supports HEVC int fileMediaFormatsSupportedByApp = appSupportedMediaFormatFlags & fileMediaFormatFlags; // fileMediaFormatsNotSupportedByApp=100 # File contains HDR_10 but app doesn't support it int fileMediaFormatsNotSupportedByApp = fileMediaFormatsSupportedByApp ^ fileMediaFormatFlags; if (fileMediaFormatsNotSupportedByApp == 0) { logVerbose("App media capability check for type: [" + type + "]" + ". transcode=false"); // If *all* file media formats are supported by the app, we don't need to transcode return Optional.of(false); } // If there are some file media formats that are neither supported nor unsupported by the // app we can't make a decision yet return Optional.empty(); } private Pair getFileFlagsAndDurationMs(String path) { final String[] projection = new String[] { FileColumns._VIDEO_CODEC_TYPE, VideoColumns.COLOR_STANDARD, VideoColumns.COLOR_TRANSFER, MediaColumns.DURATION }; try (Cursor cursor = queryFileForTranscode(path, projection)) { if (cursor == null || !cursor.moveToNext()) { logVerbose("Couldn't find database row"); return Pair.create(0, 0L); } int result = 0; if (isHevc(cursor.getString(0))) { result |= FLAG_HEVC; } if (isHdr10Plus(cursor.getInt(1), cursor.getInt(2))) { result |= FLAG_HDR_10_PLUS; } return Pair.create(result, cursor.getLong(3)); } } private static boolean isHevc(String mimeType) { return MediaFormat.MIMETYPE_VIDEO_HEVC.equalsIgnoreCase(mimeType); } private static boolean isHdr10Plus(int colorStandard, int colorTransfer) { return (colorStandard == MediaFormat.COLOR_STANDARD_BT2020) && (colorTransfer == MediaFormat.COLOR_TRANSFER_ST2084 || colorTransfer == MediaFormat.COLOR_TRANSFER_HLG); } private static boolean isModernFormat(String mimeType, int colorStandard, int colorTransfer) { return isHevc(mimeType) || isHdr10Plus(colorStandard, colorTransfer); } public boolean supportsTranscode(String path) { File file = new File(path); String name = file.getName(); final String cameraRelativePath = String.format("%s/%s/", Environment.DIRECTORY_DCIM, DIRECTORY_CAMERA); return !isTranscodeFile(path) && name.toLowerCase(Locale.ROOT).endsWith(".mp4") && path.startsWith("/storage/emulated/") && cameraRelativePath.equalsIgnoreCase(FileUtils.extractRelativePath(path)); } private Optional checkAppCompatSupport(int uid, int fileFlags) { int supportedFlags = 0; int unsupportedFlags = 0; boolean hevcSupportEnabled = CompatChanges.isChangeEnabled(FORCE_ENABLE_HEVC_SUPPORT, uid); boolean hevcSupportDisabled = CompatChanges.isChangeEnabled(FORCE_DISABLE_HEVC_SUPPORT, uid); if (hevcSupportEnabled) { supportedFlags = FLAG_HEVC; logVerbose("App compat hevc support enabled"); } if (hevcSupportDisabled) { unsupportedFlags = FLAG_HEVC; logVerbose("App compat hevc support disabled"); } return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, "app_compat"); } /** * @return {@code true} if HEVC is explicitly supported by the manifest of {@code packageName}, * {@code false} otherwise. */ private Optional checkManifestSupport(String packageName, LocalCallingIdentity identity, int fileFlags) { // TODO(b/169327180): // 1. Support beyond HEVC // 2. Shared package names policy: // If appA and appB share the same uid. And appA supports HEVC but appB doesn't. // Should we assume entire uid supports or doesn't? // For now, we assume uid supports, but this might change in future int supportedFlags = identity.getApplicationMediaCapabilitiesSupportedFlags(); int unsupportedFlags = identity.getApplicationMediaCapabilitiesUnsupportedFlags(); if (supportedFlags != -1 && unsupportedFlags != -1) { return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, "cached_app_manifest"); } try { Property mediaCapProperty = mPackageManager.getProperty( PackageManager.PROPERTY_MEDIA_CAPABILITIES, packageName); XmlResourceParser parser = mPackageManager.getResourcesForApplication(packageName) .getXml(mediaCapProperty.getResourceId()); ApplicationMediaCapabilities capability = ApplicationMediaCapabilities.createFromXml( parser); Pair flags = capabilitiesToMediaFormatFlags(capability); supportedFlags = flags.first; unsupportedFlags = flags.second; identity.setApplicationMediaCapabilitiesFlags(supportedFlags, unsupportedFlags); return checkAppMediaSupport(supportedFlags, unsupportedFlags, fileFlags, "app_manifest"); } catch (PackageManager.NameNotFoundException | UnsupportedOperationException e) { return Optional.empty(); } } @ApplicationMediaCapabilitiesFlags private Pair capabilitiesToMediaFormatFlags( ApplicationMediaCapabilities capability) { int supportedFlags = 0; int unsupportedFlags = 0; // MimeType if (capability.isFormatSpecified(MediaFormat.MIMETYPE_VIDEO_HEVC)) { if (capability.isVideoMimeTypeSupported(MediaFormat.MIMETYPE_VIDEO_HEVC)) { supportedFlags |= FLAG_HEVC; } else { unsupportedFlags |= FLAG_HEVC; } } // HdrType if (capability.isFormatSpecified(MediaFeature.HdrType.HDR10)) { if (capability.isHdrTypeSupported(MediaFeature.HdrType.HDR10)) { supportedFlags |= FLAG_HDR_10; } else { unsupportedFlags |= FLAG_HDR_10; } } if (capability.isFormatSpecified(MediaFeature.HdrType.HDR10_PLUS)) { if (capability.isHdrTypeSupported(MediaFeature.HdrType.HDR10_PLUS)) { supportedFlags |= FLAG_HDR_10_PLUS; } else { unsupportedFlags |= FLAG_HDR_10_PLUS; } } if (capability.isFormatSpecified(MediaFeature.HdrType.HLG)) { if (capability.isHdrTypeSupported(MediaFeature.HdrType.HLG)) { supportedFlags |= FLAG_HDR_HLG; } else { unsupportedFlags |= FLAG_HDR_HLG; } } if (capability.isFormatSpecified(MediaFeature.HdrType.DOLBY_VISION)) { if (capability.isHdrTypeSupported(MediaFeature.HdrType.DOLBY_VISION)) { supportedFlags |= FLAG_HDR_DOLBY_VISION; } else { unsupportedFlags |= FLAG_HDR_DOLBY_VISION; } } return Pair.create(supportedFlags, unsupportedFlags); } private boolean getBooleanProperty(String sysPropKey, String deviceConfigKey, boolean defaultValue) { // If the user wants to override the default, respect that; otherwise use the DeviceConfig // which is filled with the values sent from server. if (SystemProperties.getBoolean(TRANSCODE_USER_CONTROL_SYS_PROP_KEY, false)) { return SystemProperties.getBoolean(sysPropKey, defaultValue); } return mMediaProvider.getBooleanDeviceConfig(deviceConfigKey, defaultValue); } private Pair getTranscodeCacheInfoFromDB(String path) { try (Cursor cursor = queryFileForTranscode(path, TRANSCODE_CACHE_INFO_PROJECTION)) { if (cursor != null && cursor.moveToNext()) { return Pair.create(cursor.getLong(0), cursor.getInt(1)); } } return Pair.create((long) -1, TRANSCODE_EMPTY); } // called from MediaProvider public void onUriPublished(Uri uri) { if (!isTranscodeEnabled()) { return; } try (Cursor c = mMediaProvider.queryForSingleItem(uri, new String[]{ FileColumns._VIDEO_CODEC_TYPE, FileColumns.SIZE, FileColumns.OWNER_PACKAGE_NAME, FileColumns.DATA, MediaColumns.DURATION, MediaColumns.CAPTURE_FRAMERATE, MediaColumns.WIDTH, MediaColumns.HEIGHT }, null, null, null)) { if (supportsTranscode(c.getString(3))) { if (isHevc(c.getString(0))) { MediaProviderStatsLog.write( TRANSCODING_DATA, c.getString(2) /* owner_package_name */, MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__HEVC_WRITE, c.getLong(1) /* file size */, TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, -1 /* transcoding_duration */, c.getLong(4) /* video_duration */, c.getLong(5) /* capture_framerate */, -1 /* transcode_reason */, c.getLong(6) /* width */, c.getLong(7) /* height */, false /* hit_anr */, TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, TranscodingSession.ERROR_NONE); } else { MediaProviderStatsLog.write( TRANSCODING_DATA, c.getString(2) /* owner_package_name */, MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__AVC_WRITE, c.getLong(1) /* file size */, TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, -1 /* transcoding_duration */, c.getLong(4) /* video_duration */, c.getLong(5) /* capture_framerate */, -1 /* transcode_reason */, c.getLong(6) /* width */, c.getLong(7) /* height */, false /* hit_anr */, TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, TranscodingSession.ERROR_NONE); } } } catch (Exception e) { Log.w(TAG, "Couldn't get cursor for scanned file", e); } } public void onFileOpen(String path, String ioPath, int uid, int transformsReason) { if (!isTranscodeEnabled()) { return; } String[] resolverInfoProjection = new String[] { FileColumns._VIDEO_CODEC_TYPE, FileColumns.SIZE, MediaColumns.DURATION, MediaColumns.CAPTURE_FRAMERATE, MediaColumns.WIDTH, MediaColumns.HEIGHT, VideoColumns.COLOR_STANDARD, VideoColumns.COLOR_TRANSFER }; try (Cursor c = queryFileForTranscode(path, resolverInfoProjection)) { if (c != null && c.moveToNext()) { if (supportsTranscode(path) && isModernFormat(c.getString(0), c.getInt(6), c.getInt(7))) { if (transformsReason == 0) { MediaProviderStatsLog.write( TRANSCODING_DATA, getMetricsSafeNameForUid(uid) /* owner_package_name */, MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_DIRECT, c.getLong(1) /* file size */, TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, -1 /* transcoding_duration */, c.getLong(2) /* video_duration */, c.getLong(3) /* capture_framerate */, -1 /* transcode_reason */, c.getLong(4) /* width */, c.getLong(5) /* height */, false /*hit_anr*/, TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, TranscodingSession.ERROR_NONE); } else if (isTranscodeFileCached(path, ioPath)) { MediaProviderStatsLog.write( TRANSCODING_DATA, getMetricsSafeNameForUid(uid) /* owner_package_name */, MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_TYPE__READ_CACHE, c.getLong(1) /* file size */, TRANSCODING_DATA__TRANSCODE_RESULT__UNDEFINED, -1 /* transcoding_duration */, c.getLong(2) /* video_duration */, c.getLong(3) /* capture_framerate */, transformsReason /* transcode_reason */, c.getLong(4) /* width */, c.getLong(5) /* height */, false /*hit_anr*/, TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN, TranscodingSession.ERROR_NONE); } // else if file is not in cache, we'll log at read(2) when we transcode } } } catch (IllegalStateException e) { Log.w(TAG, "Unable to log metrics on file open", e); } } public boolean isTranscodeFileCached(String path, String transcodePath) { // This can only happen when we are in a version that supports transcoding. // So, no need to check for the SDK version here. if (SystemProperties.getBoolean("persist.sys.fuse.disable_transcode_cache", false)) { // Caching is disabled. Hence, delete the cached transcode file. return false; } Pair cacheInfo = getTranscodeCacheInfoFromDB(path); final long rowId = cacheInfo.first; if (rowId != -1) { final int transcodeStatus = cacheInfo.second; boolean result = transcodePath.equalsIgnoreCase(getTranscodePath(rowId)) && transcodeStatus == TRANSCODE_COMPLETE && new File(transcodePath).exists(); if (result) { logEvent("Transcode cache hit: " + path, null /* session */); } return result; } return false; } @Nullable private MediaFormat getVideoTrackFormat(String path) { String[] resolverInfoProjection = new String[]{ FileColumns._VIDEO_CODEC_TYPE, MediaStore.MediaColumns.WIDTH, MediaStore.MediaColumns.HEIGHT, MediaStore.MediaColumns.BITRATE, MediaStore.MediaColumns.CAPTURE_FRAMERATE }; try (Cursor c = queryFileForTranscode(path, resolverInfoProjection)) { if (c != null && c.moveToNext()) { String codecType = c.getString(0); int width = c.getInt(1); int height = c.getInt(2); int bitRate = c.getInt(3); float framerate = c.getFloat(4); // TODO(b/169849854): Get this info from Manifest, for now if app got here it // definitely doesn't support hevc ApplicationMediaCapabilities capability = new ApplicationMediaCapabilities.Builder().build(); MediaFormat sourceFormat = MediaFormat.createVideoFormat( codecType, width, height); if (framerate > 0) { sourceFormat.setFloat(MediaFormat.KEY_FRAME_RATE, framerate); } VideoFormatResolver resolver = new VideoFormatResolver(capability, sourceFormat); MediaFormat resolvedFormat = resolver.resolveVideoFormat(); resolvedFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); return resolvedFormat; } } throw new IllegalStateException("Couldn't get video format info from database for " + path); } private TranscodingSession enqueueTranscodingSession(String src, String dst, int uid, final CountDownLatch latch) throws UnsupportedOperationException, IOException { // Fetch the service lazily to improve memory usage final MediaTranscodingManager mediaTranscodeManager = mContext.getSystemService(MediaTranscodingManager.class); File file = new File(src); File transcodeFile = new File(dst); // These are file URIs (effectively file paths) and even if the |transcodeFile| is // inaccesible via FUSE, it works because the transcoding service calls into the // MediaProvider to open them and within the MediaProvider, it is opened directly on // the lower fs. Uri uri = Uri.fromFile(file); Uri transcodeUri = Uri.fromFile(transcodeFile); ParcelFileDescriptor srcPfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY); ParcelFileDescriptor dstPfd = ParcelFileDescriptor.open(transcodeFile, ParcelFileDescriptor.MODE_READ_WRITE); MediaFormat format = getVideoTrackFormat(src); VideoTranscodingRequest request = new VideoTranscodingRequest.Builder(uri, transcodeUri, format) .setClientUid(uid) .setSourceFileDescriptor(srcPfd) .setDestinationFileDescriptor(dstPfd) .build(); TranscodingSession session = mediaTranscodeManager.enqueueRequest(request, ForegroundThread.getExecutor(), s -> { mTranscodingUiNotifier.stop(s, src); finishTranscodingResult(uid, src, s, latch); mSessionTiming.logSessionEnd(s); }); session.setOnProgressUpdateListener(ForegroundThread.getExecutor(), (s, progress) -> mTranscodingUiNotifier.setProgress(s, src, progress)); mSessionTiming.logSessionStart(session); mTranscodingUiNotifier.start(session, src); logEvent("Transcoding start: " + src + ". Uid: " + uid, session); return session; } /** * Returns an {@link Integer} indicating whether the transcoding {@code session} was successful * or not. * * @return {@link TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN} on success, * otherwise indicates failure. */ private int waitTranscodingResult(int uid, String src, TranscodingSession session, CountDownLatch latch) { UUID uuid = getTranscodeVolumeUuid(); try { if (uuid != null) { // tid is 0 since we can't really get the apps tid over binder mStorageManager.notifyAppIoBlocked(uuid, uid, 0 /* tid */, StorageManager.APP_IO_BLOCKED_REASON_TRANSCODING); } int timeout = getTranscodeTimeoutSeconds(src); String waitStartLog = "Transcoding wait start: " + src + ". Uid: " + uid + ". Timeout: " + timeout + "s"; logEvent(waitStartLog, session); boolean latchResult = latch.await(timeout, TimeUnit.SECONDS); int sessionResult = session.getResult(); boolean transcodeResult = sessionResult == TranscodingSession.RESULT_SUCCESS; String waitEndLog = "Transcoding wait end: " + src + ". Uid: " + uid + ". Timeout: " + !latchResult + ". Success: " + transcodeResult; logEvent(waitEndLog, session); if (sessionResult == TranscodingSession.RESULT_SUCCESS) { return TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; } else if (sessionResult == TranscodingSession.RESULT_CANCELED) { return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SESSION_CANCELED; } else if (!latchResult) { return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; } else { return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_SERVICE_ERROR; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); Log.w(TAG, "Transcoding latch interrupted." + session); return TRANSCODING_DATA__FAILURE_CAUSE__TRANSCODING_CLIENT_TIMEOUT; } finally { if (uuid != null) { // tid is 0 since we can't really get the apps tid over binder mStorageManager.notifyAppIoResumed(uuid, uid, 0 /* tid */, StorageManager.APP_IO_BLOCKED_REASON_TRANSCODING); } } } private int getTranscodeTimeoutSeconds(String file) { double sizeMb = (new File(file).length() / (1024 * 1024)); // Ensure size is at least 1MB so transcoding timeout is at least the timeout coefficient sizeMb = Math.max(sizeMb, 1); return (int) (sizeMb * TRANSCODING_TIMEOUT_COEFFICIENT); } private void finishTranscodingResult(int uid, String src, TranscodingSession session, CountDownLatch latch) { final StorageTranscodingSession finishedSession; synchronized (mLock) { latch.countDown(); session.cancel(); finishedSession = mStorageTranscodingSessions.remove(src); switch (session.getResult()) { case TranscodingSession.RESULT_SUCCESS: mSuccessfulTranscodeSessions.put(finishedSession, false /* placeholder */); break; case TranscodingSession.RESULT_CANCELED: mCancelledTranscodeSessions.put(finishedSession, false /* placeholder */); break; case TranscodingSession.RESULT_ERROR: mErroredTranscodeSessions.put(finishedSession, false /* placeholder */); break; default: Log.w(TAG, "TranscodingSession.RESULT_NONE received for a finished session"); } } logEvent("Transcoding end: " + src + ". Uid: " + uid, session); } private boolean updateTranscodeStatus(String path, int transcodeStatus) { final Uri uri = FileUtils.getContentUriForPath(path); // TODO(b/170465810): Replace this with matchUri when the code is refactored. final int match = MediaProvider.FILES; final SQLiteQueryBuilder qb = mMediaProvider.getQueryBuilderForTranscoding(TYPE_UPDATE, match, uri, Bundle.EMPTY, null); final String[] selectionArgs = new String[]{path}; ContentValues values = new ContentValues(); values.put(FileColumns._TRANSCODE_STATUS, transcodeStatus); final boolean success = qb.update(getDatabaseHelperForUri(uri), values, TRANSCODE_WHERE_CLAUSE, selectionArgs) == 1; if (!success) { Log.w(TAG, "Transcoding status update to: " + transcodeStatus + " failed for " + path); } return success; } public boolean deleteCachedTranscodeFile(long rowId) { return new File(mTranscodeDirectory, String.valueOf(rowId)).delete(); } private DatabaseHelper getDatabaseHelperForUri(Uri uri) { final DatabaseHelper helper; try { return mMediaProvider.getDatabaseForUriForTranscoding(uri); } catch (VolumeNotFoundException e) { throw new IllegalStateException("Volume not found while querying transcode path", e); } } /** * @return given {@code projection} columns from database for given {@code path}. * Note that cursor might be empty if there is no database row or file is pending or trashed. * TODO(b/170465810): Optimize these queries by bypassing getQueryBuilder(). These queries are * always on Files table and doesn't have any dependency on calling package. i.e., query is * always called with callingPackage=self. */ @Nullable private Cursor queryFileForTranscode(String path, String[] projection) { final Uri uri = FileUtils.getContentUriForPath(path); // TODO(b/170465810): Replace this with matchUri when the code is refactored. final int match = MediaProvider.FILES; final SQLiteQueryBuilder qb = mMediaProvider.getQueryBuilderForTranscoding(TYPE_QUERY, match, uri, Bundle.EMPTY, null); final String[] selectionArgs = new String[]{path}; Bundle extras = new Bundle(); extras.putInt(QUERY_ARG_MATCH_PENDING, MATCH_EXCLUDE); extras.putInt(QUERY_ARG_MATCH_TRASHED, MATCH_EXCLUDE); extras.putString(ContentResolver.QUERY_ARG_SQL_SELECTION, TRANSCODE_WHERE_CLAUSE); extras.putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, selectionArgs); return qb.query(getDatabaseHelperForUri(uri), projection, extras, null); } private boolean isTranscodeEnabled() { return IS_TRANSCODING_SUPPORTED && getBooleanProperty(TRANSCODE_ENABLED_SYS_PROP_KEY, TRANSCODE_ENABLED_DEVICE_CONFIG_KEY, true /* defaultValue */); } private boolean shouldTranscodeDefault() { return getBooleanProperty(TRANSCODE_DEFAULT_SYS_PROP_KEY, TRANSCODE_DEFAULT_DEVICE_CONFIG_KEY, false /* defaultValue */); } private void updateConfigs(boolean transcodeEnabled) { synchronized (mLock) { boolean isTranscodeEnabledChanged = transcodeEnabled != mIsTranscodeEnabled; if (isTranscodeEnabledChanged) { Log.i(TAG, "Reloading transcode configs. transcodeEnabled: " + transcodeEnabled + ". lastTranscodeEnabled: " + mIsTranscodeEnabled); mIsTranscodeEnabled = transcodeEnabled; parseTranscodeCompatManifest(); } } } private void parseTranscodeCompatManifest() { synchronized (mLock) { // Clear the transcode_compat manifest before parsing. If transcode is disabled, // nothing will be parsed, effectively leaving the compat manifest empty. mAppCompatMediaCapabilities.clear(); if (!mIsTranscodeEnabled) { return; } Set stalePackages = getTranscodeCompatStale(); parseTranscodeCompatManifestFromResourceLocked(stalePackages); parseTranscodeCompatManifestFromDeviceConfigLocked(); } } /** @return {@code true} if the manifest was parsed successfully, {@code false} otherwise */ private boolean parseTranscodeCompatManifestFromDeviceConfigLocked() { final String[] manifest = mMediaProvider.getStringDeviceConfig( TRANSCODE_COMPAT_MANIFEST_KEY, "").split(","); if (manifest.length == 0 || manifest[0].isEmpty()) { Log.i(TAG, "Empty device config transcode compat manifest"); return false; } if ((manifest.length % 2) != 0) { Log.w(TAG, "Uneven number of items in device config transcode compat manifest"); return false; } String packageName = ""; int packageCompatValue; int i = 0; int count = 0; while (i < manifest.length - 1) { try { packageName = manifest[i++]; packageCompatValue = Integer.parseInt(manifest[i++]); synchronized (mLock) { // Lock is already held, explicitly hold again to make error prone happy mAppCompatMediaCapabilities.put(packageName, packageCompatValue); count++; } } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse media capability from device config for package: " + packageName, e); } } Log.i(TAG, "Parsed " + count + " packages from device config"); return count != 0; } /** @return {@code true} if the manifest was parsed successfully, {@code false} otherwise */ private boolean parseTranscodeCompatManifestFromResourceLocked(Set stalePackages) { InputStream inputStream = mContext.getResources().openRawResource( R.raw.transcode_compat_manifest); BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); int count = 0; try { while (reader.ready()) { String line = reader.readLine(); String packageName = ""; int packageCompatValue; if (line == null) { Log.w(TAG, "Unexpected null line while parsing transcode compat manifest"); continue; } String[] lineValues = line.split(","); if (lineValues.length != 2) { Log.w(TAG, "Failed to read line while parsing transcode compat manifest"); continue; } try { packageName = lineValues[0]; packageCompatValue = Integer.parseInt(lineValues[1]); if (stalePackages.contains(packageName)) { Log.i(TAG, "Skipping stale package in transcode compat manifest: " + packageName); continue; } synchronized (mLock) { // Lock is already held, explicitly hold again to make error prone happy mAppCompatMediaCapabilities.put(packageName, packageCompatValue); count++; } } catch (NumberFormatException e) { Log.w(TAG, "Failed to parse media capability from resource for package: " + packageName, e); } } } catch (IOException e) { Log.w(TAG, "Failed to read transcode compat manifest", e); } Log.i(TAG, "Parsed " + count + " packages from resource"); return count != 0; } private Set getTranscodeCompatStale() { Set stalePackages = new ArraySet<>(); final String[] staleConfig = mMediaProvider.getStringDeviceConfig( TRANSCODE_COMPAT_STALE_KEY, "").split(","); if (staleConfig.length == 0 || staleConfig[0].isEmpty()) { Log.i(TAG, "Empty transcode compat stale"); return stalePackages; } for (String stalePackage : staleConfig) { stalePackages.add(stalePackage); } int size = stalePackages.size(); Log.i(TAG, "Parsed " + size + " stale packages from device config"); return stalePackages; } public void dump(PrintWriter writer) { writer.println("isTranscodeEnabled=" + isTranscodeEnabled()); writer.println("shouldTranscodeDefault=" + shouldTranscodeDefault()); synchronized (mLock) { writer.println("mAppCompatMediaCapabilities=" + mAppCompatMediaCapabilities); writer.println("mStorageTranscodingSessions=" + mStorageTranscodingSessions); dumpFinishedSessions(writer); } } private void dumpFinishedSessions(PrintWriter writer) { synchronized (mLock) { writer.println("mSuccessfulTranscodeSessions=" + mSuccessfulTranscodeSessions.keySet()); writer.println("mCancelledTranscodeSessions=" + mCancelledTranscodeSessions.keySet()); writer.println("mErroredTranscodeSessions=" + mErroredTranscodeSessions.keySet()); } } private static void logEvent(String event, @Nullable TranscodingSession session) { Log.d(TAG, event + (session == null ? "" : session)); } private static void logVerbose(String message) { if (DEBUG) { Log.v(TAG, message); } } // We want to keep track of only the most recent [MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT] // finished transcoding sessions. private static LinkedHashMap createFinishedTranscodingSessionMap() { return new LinkedHashMap() { @Override protected boolean removeEldestEntry(Entry eldest) { return size() > MAX_FINISHED_TRANSCODING_SESSION_STORE_COUNT; } }; } @VisibleForTesting static int getMyUid() { return MY_UID; } private static class StorageTranscodingSession { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"); public final TranscodingSession session; public final CountDownLatch latch; private final String mSrcPath; private final String mDstPath; @GuardedBy("latch") private final Set mBlockedUids = new ArraySet<>(); private final LocalDateTime mStartTime; @GuardedBy("latch") private LocalDateTime mFinishTime; @GuardedBy("latch") private boolean mHasAnr; @GuardedBy("latch") private int mFailureReason; @GuardedBy("latch") private int mErrorCode; public StorageTranscodingSession(TranscodingSession session, CountDownLatch latch, String srcPath, String dstPath) { this.session = session; this.latch = latch; this.mSrcPath = srcPath; this.mDstPath = dstPath; this.mStartTime = LocalDateTime.now(); mErrorCode = TranscodingSession.ERROR_NONE; mFailureReason = TRANSCODING_DATA__FAILURE_CAUSE__CAUSE_UNKNOWN; } public void addBlockedUid(int uid) { session.addClientUid(uid); } public boolean isUidBlocked(int uid) { return session.getClientUids().contains(uid); } public void setAnr() { synchronized (latch) { mHasAnr = true; } } public boolean hasAnr() { synchronized (latch) { return mHasAnr; } } public void notifyFinished(int failureReason, int errorCode) { synchronized (latch) { mFinishTime = LocalDateTime.now(); mFailureReason = failureReason; mErrorCode = errorCode; } } @Override public String toString() { String startTime = mStartTime.format(DATE_FORMAT); String finishTime = "NONE"; String durationMs = "NONE"; boolean hasAnr; int failureReason; int errorCode; synchronized (latch) { if (mFinishTime != null) { finishTime = mFinishTime.format(DATE_FORMAT); durationMs = String.valueOf(mStartTime.until(mFinishTime, ChronoUnit.MILLIS)); } hasAnr = mHasAnr; failureReason = mFailureReason; errorCode = mErrorCode; } return String.format("<%s. Src: %s. Dst: %s. BlockedUids: %s. DurationMs: %sms" + ". Start: %s. Finish: %sms. HasAnr: %b. FailureReason: %d. ErrorCode: %d>", session.toString(), mSrcPath, mDstPath, session.getClientUids(), durationMs, startTime, finishTime, hasAnr, failureReason, errorCode); } } private static class TranscodeUiNotifier { private static final int PROGRESS_MAX = 100; private static final int ALERT_DISMISS_DELAY_MS = 1000; private static final int SHOW_PROGRESS_THRESHOLD_TIME_MS = 1000; private static final String TRANSCODE_ALERT_CHANNEL_ID = "native_transcode_alert_channel"; private static final String TRANSCODE_ALERT_CHANNEL_NAME = "Native Transcode Alerts"; private static final String TRANSCODE_PROGRESS_CHANNEL_ID = "native_transcode_progress_channel"; private static final String TRANSCODE_PROGRESS_CHANNEL_NAME = "Native Transcode Progress"; // Related to notification settings private static final String TRANSCODE_NOTIFICATION_SYS_PROP_KEY = "persist.sys.fuse.transcode_notification"; private static final boolean NOTIFICATION_ALLOWED_DEFAULT_VALUE = false; private final Context mContext; private final NotificationManagerCompat mNotificationManager; private final PackageManager mPackageManager; // Builder for creating alert notifications. private final NotificationCompat.Builder mAlertBuilder; // Builder for creating progress notifications. private final NotificationCompat.Builder mProgressBuilder; private final SessionTiming mSessionTiming; TranscodeUiNotifier(Context context, SessionTiming sessionTiming) { mContext = context; mNotificationManager = NotificationManagerCompat.from(context); mPackageManager = context.getPackageManager(); createAlertNotificationChannel(context); createProgressNotificationChannel(context); mAlertBuilder = createAlertNotificationBuilder(context); mProgressBuilder = createProgressNotificationBuilder(context); mSessionTiming = sessionTiming; } void start(TranscodingSession session, String filePath) { if (!notificationEnabled()) { return; } ForegroundThread.getHandler().post(() -> { mAlertBuilder.setContentTitle(getString(mContext, R.string.transcode_processing_started)); mAlertBuilder.setContentText(FileUtils.extractDisplayName(filePath)); final int notificationId = session.getSessionId(); mNotificationManager.notify(notificationId, mAlertBuilder.build()); }); } void stop(TranscodingSession session, String filePath) { if (!notificationEnabled()) { return; } endSessionWithMessage(session, filePath, getResultMessageForSession(mContext, session)); } void denied(int uid) { String appName = getAppName(uid); if (appName == null) { Log.w(TAG, "Not showing denial, no app name "); return; } final Handler handler = ForegroundThread.getHandler(); handler.post(() -> { Toast.makeText(mContext, mContext.getResources().getString(R.string.transcode_denied, appName), Toast.LENGTH_LONG).show(); }); } void setProgress(TranscodingSession session, String filePath, @IntRange(from = 0, to = PROGRESS_MAX) int progress) { if (!notificationEnabled()) { return; } if (shouldShowProgress(session)) { mProgressBuilder.setContentText(FileUtils.extractDisplayName(filePath)); mProgressBuilder.setProgress(PROGRESS_MAX, progress, /* indeterminate= */ false); final int notificationId = session.getSessionId(); mNotificationManager.notify(notificationId, mProgressBuilder.build()); } } private boolean shouldShowProgress(TranscodingSession session) { return (System.currentTimeMillis() - mSessionTiming.getSessionStartTime(session)) > SHOW_PROGRESS_THRESHOLD_TIME_MS; } private void endSessionWithMessage(TranscodingSession session, String filePath, String message) { final Handler handler = ForegroundThread.getHandler(); handler.post(() -> { mAlertBuilder.setContentTitle(message); mAlertBuilder.setContentText(FileUtils.extractDisplayName(filePath)); final int notificationId = session.getSessionId(); mNotificationManager.notify(notificationId, mAlertBuilder.build()); // Auto-dismiss after a delay. handler.postDelayed(() -> mNotificationManager.cancel(notificationId), ALERT_DISMISS_DELAY_MS); }); } private String getAppName(int uid) { String name = mPackageManager.getNameForUid(uid); if (name == null) { Log.w(TAG, "Couldn't find name"); return null; } final ApplicationInfo aInfo; try { aInfo = mPackageManager.getApplicationInfo(name, 0); } catch (PackageManager.NameNotFoundException e) { Log.w(TAG, "unable to look up package name", e); return null; } // If the label contains new line characters it may push the security // message below the fold of the dialog. Labels shouldn't have new line // characters anyways, so we just delete all of the newlines (if there are any). return aInfo.loadSafeLabel(mPackageManager, MAX_APP_NAME_SIZE_PX, TextUtils.SAFE_STRING_FLAG_SINGLE_LINE).toString(); } private static String getString(Context context, int resourceId) { return context.getResources().getString(resourceId); } private static void createAlertNotificationChannel(Context context) { NotificationChannel channel = new NotificationChannel(TRANSCODE_ALERT_CHANNEL_ID, TRANSCODE_ALERT_CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH); NotificationManager notificationManager = context.getSystemService( NotificationManager.class); notificationManager.createNotificationChannel(channel); } private static void createProgressNotificationChannel(Context context) { NotificationChannel channel = new NotificationChannel(TRANSCODE_PROGRESS_CHANNEL_ID, TRANSCODE_PROGRESS_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW); NotificationManager notificationManager = context.getSystemService( NotificationManager.class); notificationManager.createNotificationChannel(channel); } private static NotificationCompat.Builder createAlertNotificationBuilder(Context context) { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, TRANSCODE_ALERT_CHANNEL_ID); builder.setAutoCancel(false) .setOngoing(true) .setSmallIcon(R.drawable.thumb_clip); return builder; } private static NotificationCompat.Builder createProgressNotificationBuilder( Context context) { NotificationCompat.Builder builder = new NotificationCompat.Builder(context, TRANSCODE_PROGRESS_CHANNEL_ID); builder.setAutoCancel(false) .setOngoing(true) .setContentTitle(getString(context, R.string.transcode_processing)) .setSmallIcon(R.drawable.thumb_clip); return builder; } private static String getResultMessageForSession(Context context, TranscodingSession session) { switch (session.getResult()) { case TranscodingSession.RESULT_CANCELED: return getString(context, R.string.transcode_processing_cancelled); case TranscodingSession.RESULT_ERROR: return getString(context, R.string.transcode_processing_error); case TranscodingSession.RESULT_SUCCESS: return getString(context, R.string.transcode_processing_success); default: return getString(context, R.string.transcode_processing_error); } } private static boolean notificationEnabled() { return SystemProperties.getBoolean(TRANSCODE_NOTIFICATION_SYS_PROP_KEY, NOTIFICATION_ALLOWED_DEFAULT_VALUE); } } private static class TranscodeDenialController implements OnUidImportanceListener { private final int mMaxDurationMs; private final ActivityManager mActivityManager; private final TranscodeUiNotifier mUiNotifier; private final Object mLock = new Object(); @GuardedBy("mLock") private final Set mActiveDeniedUids = new ArraySet<>(); @GuardedBy("mLock") private final Set mDroppedUids = new ArraySet<>(); TranscodeDenialController(ActivityManager activityManager, TranscodeUiNotifier uiNotifier, int maxDurationMs) { mActivityManager = activityManager; mUiNotifier = uiNotifier; mMaxDurationMs = maxDurationMs; } @Override public void onUidImportance(int uid, int importance) { if (importance != IMPORTANCE_FOREGROUND) { synchronized (mLock) { if (mActiveDeniedUids.remove(uid) && mActiveDeniedUids.isEmpty()) { // Stop the uid listener if this is the last uid triggering a denial UI mActivityManager.removeOnUidImportanceListener(this); } } } } /** @return {@code true} if file access should be denied, {@code false} otherwise */ boolean checkFileAccess(int uid, long durationMs) { boolean shouldDeny = false; synchronized (mLock) { shouldDeny = durationMs > mMaxDurationMs || mDroppedUids.contains(uid); } if (!shouldDeny) { // Nothing to do return false; } synchronized (mLock) { if (!mActiveDeniedUids.contains(uid) && mActivityManager.getUidImportance(uid) == IMPORTANCE_FOREGROUND) { // Show UI for the first denial while foreground mUiNotifier.denied(uid); if (mActiveDeniedUids.isEmpty()) { // Start a uid listener if this is the first uid triggering a denial UI mActivityManager.addOnUidImportanceListener(this, IMPORTANCE_FOREGROUND); } mActiveDeniedUids.add(uid); } } return true; } void onTranscodingDropped(int uid) { synchronized (mLock) { mDroppedUids.add(uid); } // Notify about file access, so we might show a denial UI checkFileAccess(uid, 0 /* duration */); } } private static final class SessionTiming { // This should be accessed only in foreground thread. private final SparseArray mSessionStartTimes = new SparseArray<>(); // Call this only in foreground thread. private long getSessionStartTime(MediaTranscodingManager.TranscodingSession session) { return mSessionStartTimes.get(session.getSessionId()); } private void logSessionStart(MediaTranscodingManager.TranscodingSession session) { ForegroundThread.getHandler().post( () -> mSessionStartTimes.append(session.getSessionId(), System.currentTimeMillis())); } private void logSessionEnd(MediaTranscodingManager.TranscodingSession session) { ForegroundThread.getHandler().post( () -> mSessionStartTimes.remove(session.getSessionId())); } } }