blob: 8017a5904778dde96fe575056dafb9114cb10f96 [file] [log] [blame]
/*
* 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<String, StorageTranscodingSession> 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<StorageTranscodingSession, Boolean> mSuccessfulTranscodeSessions =
createFinishedTranscodingSessionMap();
@GuardedBy("mLock")
private final Map<StorageTranscodingSession, Boolean> mCancelledTranscodeSessions =
createFinishedTranscodingSessionMap();
@GuardedBy("mLock")
private final Map<StorageTranscodingSession, Boolean> mErroredTranscodeSessions =
createFinishedTranscodingSessionMap();
private final TranscodeUiNotifier mTranscodingUiNotifier;
private final TranscodeDenialController mTranscodeDenialController;
private final SessionTiming mSessionTiming;
@GuardedBy("mLock")
private final Map<String, Integer> 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<StorageTranscodingSession> 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<Long, Integer> 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:
* <ul>
* <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_DEFAULT=1
* <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__SYSTEM_CONFIG=2
* <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_MANIFEST=3
* <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_COMPAT=4
* <li>MediaProviderStatsLog.TRANSCODING_DATA__ACCESS_REASON__APP_EXTRA=5
* </ul>
*
*/
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<Integer, Long> 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<Integer, Integer> flags = capabilitiesToMediaFormatFlags(capabilities);
Optional<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<Boolean> 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<Integer, Long> 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<Boolean> 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<Boolean> 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<Integer, Integer> 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<Integer, Integer> 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<Long, Integer> 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<Long, Integer> 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<String> 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<String> 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<String> getTranscodeCompatStale() {
Set<String> 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<StorageTranscodingSession, Boolean>() {
@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<Integer> 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<Integer> mActiveDeniedUids = new ArraySet<>();
@GuardedBy("mLock")
private final Set<Integer> 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<Long> 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()));
}
}
}