提示:本文将对framework收到截屏按键到保存图片流程进行分析,并分享一个有趣的案例
前言
本文是基于android 12.0源码上进行分析的
1. 流程图
先上流程图,截屏事件是从PhoneWindowManager->interceptKeyBeforeDispatching()接收到KEYCODE_SYSRQ按键开始
2. 代码详解
frameworks/base/services/core/java/com/android/server/policy/PhoneWindowManager.java
先接收到截屏按键KEYCODE_SYSRQ
public long interceptKeyBeforeDispatching(IBinder focusedToken, KeyEvent event,
int policyFlags) {
******
case KeyEvent.KEYCODE_SYSRQ:
if (down && repeatCount == 0) {
mScreenshotRunnable.setScreenshotType(TAKE_SCREENSHOT_FULLSCREEN);
mScreenshotRunnable.setScreenshotSource(SCREENSHOT_KEY_OTHER);
mHandler.post(mScreenshotRunnable);
}
******
}
private class ScreenshotRunnable implements Runnable {
******
@Override
public void run() {
mDefaultDisplayPolicy.takeScreenshot(mScreenshotType, mScreenshotSource);
}
}
frameworks/base/services/core/java/com/android/server/wm/DisplayPolicy.java
public void takeScreenshot(int screenshotType, int source) {
if (mScreenshotHelper != null) {
mScreenshotHelper.takeScreenshot(screenshotType,
getStatusBar() != null && getStatusBar().isVisible(),
getNavigationBar() != null && getNavigationBar().isVisible(),
source, mHandler, null /* completionConsumer */);
}
}
frameworks/base/core/java/com/android/internal/util/ScreenshotHelper.java
public void takeScreenshot(final int screenshotType, final boolean hasStatus,
final boolean hasNav, int source, @NonNull Handler handler,
@Nullable Consumer<Uri> completionConsumer) {
ScreenshotRequest screenshotRequest = new ScreenshotRequest(source, hasStatus, hasNav);
takeScreenshot(screenshotType, SCREENSHOT_TIMEOUT_MS, handler, screenshotRequest,
completionConsumer);
}
private void takeScreenshot(final int screenshotType, long timeoutMs, @NonNull Handler handler,
ScreenshotRequest screenshotRequest, @Nullable Consumer<Uri> completionConsumer) {
******
if (mScreenshotConnection == null || mScreenshotService == null) {
final ComponentName serviceComponent = ComponentName.unflattenFromString(
mContext.getResources().getString(
com.android.internal.R.string.config_screenshotServiceComponent));
final Intent serviceIntent = new Intent();
serviceIntent.setComponent(serviceComponent);
ServiceConnection conn = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
synchronized (mScreenshotLock) {
if (mScreenshotConnection != this) {
return;
}
mScreenshotService = service;
Messenger messenger = new Messenger(mScreenshotService);
try {
messenger.send(msg);
} catch (RemoteException e) {
******
}
这里binder上com.android.systemui/com.android.systemui.screenshot.TakeScreenshotService服务,再send msg过去。
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/TakeScreenshotService.java
private boolean handleMessage(Message msg) {
******
switch (msg.what) {
case WindowManager.TAKE_SCREENSHOT_FULLSCREEN:
if (DEBUG_SERVICE) {
Log.d(TAG, "handleMessage: TAKE_SCREENSHOT_FULLSCREEN");
}
mScreenshot.takeScreenshotFullscreen(uriConsumer, requestCallback);
break;
******
}
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/ScreenshotController.java
void takeScreenshotFullscreen(Consumer<Uri> finisher, RequestCallback requestCallback) {
******
takeScreenshotInternal(
finisher,
new Rect(0, 0, displayMetrics.widthPixels, displayMetrics.heightPixels));
}
private void takeScreenshotInternal(Consumer<Uri> finisher, Rect crop) {
******
Bitmap screenshot = captureScreenshot(crop);//进行抓图生成Bitmap,生成过程这里就不做分析了
******
saveScreenshot(screenshot, finisher, screenRect, Insets.NONE, true);
//把Bitmap信息保存起来,我们重点看下保存过程。
}
private void saveScreenshot(Bitmap screenshot, Consumer<Uri> finisher, Rect screenRect,
Insets screenInsets, boolean showFlash) {
******
saveScreenshotInWorkerThread(finisher, this::showUiOnActionsReady,
this::showUiOnQuickShareActionReady);
******
}
private void saveScreenshotInWorkerThread(Consumer<Uri> finisher,
@Nullable ScreenshotController.ActionsReadyListener actionsReadyListener,
@Nullable ScreenshotController.QuickShareActionReadyListener
quickShareActionsReadyListener) {
ScreenshotController.SaveImageInBackgroundData
data = new ScreenshotController.SaveImageInBackgroundData();
******
mSaveInBgTask = new SaveImageInBackgroundTask(mContext, mImageExporter,
mScreenshotSmartActions, data, getActionTransitionSupplier());
mSaveInBgTask.execute();
}
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java
protected Void doInBackground(Void... paramsUnused) {
******
ListenableFuture<ImageExporter.Result> future =
mImageExporter.export(Runnable::run, requestId, image);
ImageExporter.Result result = future.get();
******
}
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java
ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap) {
return export(executor, requestId, bitmap, ZonedDateTime.now());
}
ListenableFuture<Result> export(Executor executor, UUID requestId, Bitmap bitmap,
ZonedDateTime captureTime) {
final Task task = new Task(mResolver, requestId, bitmap, captureTime, mCompressFormat,
mQuality, /* publish */ true);
return CallbackToFutureAdapter.getFuture(
(completer) -> {
executor.execute(() -> {
try {
completer.set(task.execute());
} catch (ImageExportException | InterruptedException e) {
******
}
private static class Task {
public Result execute() throws ImageExportException, InterruptedException {
******
uri = createEntry(mResolver, mFormat, mCaptureTime, mFileName);
throwIfInterrupted();
writeImage(mResolver, mBitmap, mFormat, mQuality, uri);
throwIfInterrupted();
int width = mBitmap.getWidth();
int height = mBitmap.getHeight();
writeExif(mResolver, uri, mRequestId, width, height, mCaptureTime);
throwIfInterrupted();
if (mPublish) {
publishEntry(mResolver, uri);
result.published = true;
}
******
}
}
private static Uri createEntry(ContentResolver resolver, CompressFormat format,
ZonedDateTime time, String fileName) throws ImageExportException {
******
final ContentValues values = createMetadata(time, format, fileName);
Uri uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
******
}
至此截屏文件信息第一次插入到媒体数据库里。
frameworks/base/core/java/android/content/ContentResolver.java
public final @Nullable Uri insert(@RequiresPermission.Write @NonNull Uri url,
@Nullable ContentValues values) {
return insert(url, values, null);
}
public final @Nullable Uri insert(@RequiresPermission.Write @NonNull Uri url,
@Nullable ContentValues values, @Nullable Bundle extras) {
Objects.requireNonNull(url, "url");
try {
if (mWrapped != null) return mWrapped.insert(url, values, extras);
} catch (RemoteException e) {
return null;
}
******
}
这里的mWrapped是调用ContentResolver.wrap()静态函数得到的。因为我们这是media数据,所以是MediaProvider。
packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java
public @Nullable Uri insert(@NonNull Uri uri, @Nullable ContentValues values,
@Nullable Bundle extras) {
******
return insertInternal(uri, values, extras);
******
}
private @Nullable Uri insertInternal(@NonNull Uri uri, @Nullable ContentValues initialValues,
@Nullable Bundle extras) throws FallbackException {
******
switch (match) {
case IMAGES_MEDIA: {
maybePut(initialValues, FileColumns.OWNER_PACKAGE_NAME, ownerPackageName);
final boolean isDownload = maybeMarkAsDownload(initialValues);
newUri = insertFile(qb, helper, match, uri, extras, initialValues,
FileColumns.MEDIA_TYPE_IMAGE);
break;
}
******
}
private Uri insertFile(@NonNull SQLiteQueryBuilder qb, @NonNull DatabaseHelper helper,
int match, @NonNull Uri uri, @NonNull Bundle extras, @NonNull ContentValues values,
int mediaType) throws VolumeArgumentException, VolumeNotFoundException {
******
rowId = insertAllowingUpsert(qb, helper, values, path);
******
}
private long insertAllowingUpsert(@NonNull SQLiteQueryBuilder qb,
@NonNull DatabaseHelper helper, @NonNull ContentValues values, String path)
throws SQLiteConstraintException {
return helper.runWithTransaction((db) -> {
Long parent = values.getAsLong(FileColumns.PARENT);
if (parent == null) {
if (path != null) {
final long parentId = getParent(db, path);
values.put(FileColumns.PARENT, parentId);
}
}
try {
return qb.insert(helper, values);
} catch (SQLiteConstraintException e) {
******
}
private final OnFilesChangeListener mFilesListener = new OnFilesChangeListener() {
@Override
public void onInsert(@NonNull DatabaseHelper helper, @NonNull String volumeName, long id,
int mediaType, boolean isDownload) {
handleInsertedRowForFuse(id);
acceptWithExpansion(helper::notifyInsert, volumeName, id, mediaType, isDownload);
helper.postBackground(() -> {
if (helper.isExternal()) {
// Update the quota type on the filesystem
Uri fileUri = MediaStore.Files.getContentUri(volumeName, id);
updateQuotaTypeForUri(fileUri, mediaType);
}
// Tell our SAF provider so it knows when views are no longer empty
MediaDocumentsProvider.onMediaStoreInsert(getContext(), volumeName, mediaType, id);
});
}
}
这里就已经保存到sql数据库,后面就不继续追了,感兴趣的小伙伴可以往下看。
下面分享下案例
3. 案例
3.1 需求
截屏文件默认优先保存在外设usb中,如果没有则保存在内部external里
3.2 分析思路
其实从上面的分析过程中我们可以看到,android 12.0相比以前的低版本android 9.0,MediaProvider有了非常大的改动。
9.0的时候使用MediaStore.Images.ImageColumns.DATA保存image的绝对路径,然后通过ContentValues把路径传给MediaProvider。而12.0可以看到MediaProvider都是通过Uri去访问文件,没有使用绝对路径这个概念了,而Uri是通过设备卷名(即uuid)+文件号转化而成。
所以我们想优先保存到外设,可以在SaveImageInBackgroundTask先获取到外设的卷名,然后转化为对应的Uri传下来。
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/SaveImageInBackgroundTask.java
public static String mVolumeName = "external";//MediaStore.Images.Media.EXTERNAL_CONTENT_URI
SaveImageInBackgroundTask(Context context, ImageExporter exporter,
ScreenshotSmartActions screenshotSmartActions,
ScreenshotController.SaveImageInBackgroundData data,
Supplier<ActionTransition> sharedElementTransition) {
******
mVolumeName = "external";
for (String resolvedVolumeName : MediaStore.getExternalVolumeNames(mContext)) {
if (resolvedVolumeName.startsWith("internal") ||
resolvedVolumeName.startsWith("external")){
continue;//排除内部设备
}
mVolumeName = resolvedVolumeName;
}
******
}
然后insert的时候把这个卷名的Uri传下去
frameworks/base/packages/SystemUI/src/com/android/systemui/screenshot/ImageExporter.java
private static Uri createEntry(ContentResolver resolver, CompressFormat format,
ZonedDateTime time, String fileName) throws ImageExportException {
******
Uri uri = resolver.insert(MediaStore.Images.Media.getContentUri(SaveImageInBackgroundTask.mVolumeName), values);
******
}
至此,需求已完成。SaveImageInBackgroundTask会先去拿外设卷名,如果拿到则保存在外设,如果没拿到则还是保存在内部存储
3.3 有趣的问题
我在某些平台上发现,外设挂载的目录名不是外设卷名。这里补充一下,android原生外设挂载的目录名默认都是卷名。
而android 12.0 MediaProvider拿到的所谓卷名实际是设备挂载目录名。
那么就会造成卷名没找到。
03-21 16:40:28.816 2759 2833 E MediaProvider: Volume sda not found, calling identity: UserHandle{0}, attached volumes: {MediaVolume name: [internal] id: [null] user: [null] path: [null], MediaVolume name: [189a-0881] id: [public:8,0] user: [UserHandle{0}] path: [/storage/sda], MediaVolume name: [external_primary] id: [emulated;0] user: [UserHandle{0}] path: [/storage/emulated/0]}
上面可以看出设备卷名是189a-0881,但MediaProvider拿到的是sda,也就是挂载的目录名/storage/sda
那这种情况下,肯定是基于某种原因改了挂载目录名。如果我们直接还原目录名,可能会造成之前的问题发生,亦或者会造成现有平台某些场景下访问外设的绝对路径失效。改这个风险太高。
另外还有种解决方案,可以直接打开截屏生成的Uri,保存文件到外设。但只能解决这一种场景,其他MediaProvider的movie等文件insert到外设依旧会有问题。
那我们还需要分析MediaProvider为什么拿到的是目录名。
分析代码不难发现,都是通过FileUtils.extractVolumeName()获取卷名的
packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java
public static @Nullable String extractVolumeName(@Nullable String data) {
if (data == null) return null;
final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
if (matcher.find()) {
final String volumeName = matcher.group(1);
if (volumeName.equals("emulated")) {
return MediaStore.VOLUME_EXTERNAL_PRIMARY;
} else {
return normalizeUuid(volumeName);
}
} else {
return MediaStore.VOLUME_INTERNAL;
}
}
private static @Nullable String normalizeUuid(@Nullable String fsUuid) {
return fsUuid != null ? fsUuid.toLowerCase(Locale.ROOT) : null;
}
原因就在于此,那怎么解决呢。
正确的卷名都是通过StorageManager来获取,android 12隐藏了很多获取卷名的方法,无法直接使用,能获取卷名的函数又都需要传递context,而FileUtils类没有自己的context,extractVolumeName()也没有传下来。那只能借助其他类来间接访问了
我们可以用android.os.Environment新增一个静态函数
frameworks/base/core/java/android/os/Environment.java
public static @NonNull StorageVolume getExternalStorageVolume(@NonNull File path) {
final StorageVolume volume = StorageManager.getStorageVolume(path, UserHandle.myUserId());
if (volume != null) {
return volume;
} else {
return null;
}
}
然后修改packages/providers/MediaProvider/src/com/android/providers/media/util/FileUtils.java
public static @Nullable String extractVolumeName(@Nullable String data) {
if (data == null) return null;
final Matcher matcher = PATTERN_VOLUME_NAME.matcher(data);
if (matcher.find()) {
final String volumeName = matcher.group(1);
if (volumeName.equals("emulated")) {
return MediaStore.VOLUME_EXTERNAL_PRIMARY;
} else {
File file = new File(data);
StorageVolume volume = Environment.getExternalStorageVolume(file);
if (volume != null) {
return volume.getMediaStoreVolumeName();
} else {
return normalizeUuid(volumeName);
}
}
} else {
return MediaStore.VOLUME_INTERNAL;
}
}
这样就解决了MediaProvider insert文件到外设失败的问题
总结
截屏保存到外设不难,难点在于过程中遇到的这个卷名拿错的坑。当时想了很多种方法在MediaProvider里去拿卷名,最终都失败了。只能借助Environment类间接访问了。这也给我提供一个思路,有时候没必要纠结一定要直接访问某个类,可能权限或者接口隐藏等问题都行不通,那我们可以借助android.os.xxxx的这些类,暴露一个接口出来去间接访问达到目的。
另外,这个案例也说明一个问题,不要轻易修改外设挂载的目录名,改了以后android高版本ContentProvider类型数据库插入到外设会失败。上面案例也只解决了MediaProvider数据库问题,其他ContentProvider类型的数据库依旧有同样的现象,解决方案类似,这里就不列举了。