都 2020 年了,听说 Android 还没有自带录屏功能,当项目经理把你一顿吊打的时候问你为啥 IOS 本身就自带这个功能,
你竟然无言以对,你说再等等,最近看到 android11 预览版已经发布,看介绍说明之前砍掉的录屏功能将在此版本正式回归,
就自带了。
经理说现在大多用的还是android8、9的版本,不能再等了,现在就开始盘它。
好嘛,既然要求搞,那就要弄得优雅些,想到优雅,那必须是这样。
来康康顺着这种优雅的思路最终实现的效果,上图走起
恩哼哼,竟然有人说静图不够优雅,满足你,上个 gif 镇楼
那么要实现这么优雅的效果,你需要怎么做呢?来来来,先康康实现流程图
思路如此清奇,拢共也就修改了10来个文件,不多不多,再来康康修改文件清单,也许你的 SystemUI 是在 framework目录下的,没关系代码也是一样的,继续看就是了。
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/AndroidManifest.xml
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values-zh-rCN/strings.xml
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values/config.xml
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/res/values/strings.xml
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/SystemUIApplication.java
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/qs/tileimpl/QSFactoryImpl.java
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/PanelView.java
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/SharedConfig.java
modified: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable-xhdpi/ic_rs_on.png
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable/message_dialog_bg.xml
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/drawable/oval_count_bg.xml
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/layout/countdown_layout.xml
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/layout/stoprecordtip_layout.xml
add: vendor/mediatek/proprietary/packages/apps/SystemUI/res/xml/file_paths.xml
add: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/qs/tiles/ScreenRecordTile.java
add: vendor/mediatek/proprietary/packages/apps/SystemUI/src/com/android/systemui/screenrecord/
完整修改代码已经同步至 github
一、QSPanel 快捷控制开关
快捷开关添加可以参照 AirplaneModeTile.java 写法,
第一步、在 qs/tiles 路径下新增 ScreenRecordTile.java 处理快捷开关点击逻辑
第二步、在 res/values/config.xml 中增加 quick_settings_tiles_default 默认初始化项 recordscreen,
这个名字可以自己定,因为在接下来会判断是否 equals
第三步、在 qs/tileimpl/QSFactoryImpl.java 中新增 else if 条件实例化 ScreenRecordTile
<!-- The default tiles to display in QuickSettings -->
<string name="quick_settings_tiles_default" translatable="false">
wifi,bt,dnd,battery,cell,airplane,cast,recordscreen
</string>
else if (tileSpec.equals("nfc")) return new NfcTile(mHost);
else if (tileSpec.equals("recordscreen")) return new ScreenRecordTile(mHost);
ScreenRecordTile 中核心的两个方法,handleClick() 和 handleUpdateState(BooleanState state, Object arg)
点击 icon 时都会触发 handleClick,我们就在这调用开始/结束录屏的方法,并保存一个录屏状态 KEY_SCREEN_RECORDING bool 值,用于每次 Panel 收起后展开都会触发 handleUpdateState 刷新 icon 状态,根据 bool 确定当前图标是否需要显示切割切线。
@Override
public void handleClick() {
Log.d(TAG, "handleClick, RecordScreen enable = " + mState.value);
boolean newState = !mState.value;
refreshState(newState);
}
private boolean getRecordStatus(){
return SharedConfig.getInstance(mContext).readBoolean(SharedConfig.KEY_SCREEN_RECORDING, false);
}
@Override
protected void handleUpdateState(BooleanState state, Object arg) {
Log.i(TAG, "handleUpdateState arg = " + arg);
final boolean running = arg instanceof Boolean ? (Boolean)arg : getRecordStatus();
state.value = running;
state.label = mContext.getString(R.string.quick_settings_screenrecord_label);
state.icon = ResourceIcon.get(R.drawable.ic_rs_on);
if (state.slash == null) {
state.slash = new SlashState();
}
state.slash.isSlashed = !running;
state.state = running ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE;
state.contentDescription = state.label;
state.expandedAccessibilityClassName = Switch.class.getName();
}
二、开始录制
界面显示部分暂时先这样,接下来就得来真格的了。通过后台 Service 监听开始/结束广播,这样既可以给 QSPanel 调用,也可作为接口提供给客户调用。(嘿嘿,没想到吧,这思路清奇不) 那么应该何时候启动 Service 呢,我们都知道 SystemUI 启动是非常早的,早在 Launcher 出现前,基本还处在开机动画阶段就已经启动完成,首先我们能想到的就是开机广播,在项目中全局搜索找到 SystemUIApplication.java,在 onCreat() 中监听了开机广播,那我们就加在这吧。
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\SystemUIApplication.java
@Override
public void onCreate() {
super.onCreate();
setTheme(R.style.Theme_SystemUI);
SystemUIFactory.createFromConfig(this);
if (Process.myUserHandle().equals(UserHandle.SYSTEM)) {
IntentFilter filter = new IntentFilter(Intent.ACTION_BOOT_COMPLETED);
filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY);
registerReceiver(new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (mBootCompleted) return;
//cczheng add for ScreenRecordService
Log.d("ScreenRecordAP", "BOOT_COMPLETED received,start RecordService");
startService(new Intent(SystemUIApplication.this, com.android.systemui.screenrecord.RecordService.class));
if (DEBUG) Log.v(TAG, "BOOT_COMPLETED received");
unregisterReceiver(this);
mBootCompleted = true;
同时别忘记在 AndroidManifest.xml 中进行配置 RecordService
1、权限相关
1.1、录屏动态权限处理
录屏动态权限申请是啥呢?喏,就是下面这货
正常流程通过 projectionManager.createScreenCaptureIntent(),在 onActivityResult() 接收授权结果,授权成功获取 data 中的 IBinder 对象从而得到 MediaProjection。
frameworks\base\media\java\android\media\projection\MediaProjectionManager.java
/**
* Returns an Intent that <b>must</b> passed to startActivityForResult()
* in order to start screen capture. The activity will prompt
* the user whether to allow screen capture. The result of this
* activity should be passed to getMediaProjection.
*/
public Intent createScreenCaptureIntent() {
Intent i = new Intent();
i.setClassName("com.android.systemui",
"com.android.systemui.media.MediaProjectionPermissionActivity");
return i;
}
public MediaProjection getMediaProjection(int resultCode, @NonNull Intent resultData) {
if (resultCode != Activity.RESULT_OK || resultData == null) {
return null;
}
IBinder projection = resultData.getIBinderExtra(EXTRA_MEDIA_PROJECTION);
if (projection == null) {
return null;
}
return new MediaProjection(mContext, IMediaProjection.Stub.asInterface(projection));
}
通过 Layout Inspetor 工具或者看上面的 createScreenCaptureIntent() 源码发现这货就在 SystemUI 中,对应 MediaProjectionPermissionActivity,只是设置了 Dialog 的主题,那我们就来康康它的源码,简化授权过程。
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\media\MediaProjectionPermissionActivity.java
IBinder b = ServiceManager.getService(MEDIA_PROJECTION_SERVICE);
mService = IMediaProjectionManager.Stub.asInterface(b);
try {
if (mService.hasProjectionPermission(mUid, mPackageName)) {
setResult(RESULT_OK, getMediaProjectionIntent(mUid, mPackageName,
false /*permanentGrant*/));
finish();
return;
}
} catch (RemoteException e) {
Log.e(TAG, "Error checking projection permissions", e);
finish();
return;
}
@Override
public void onClick(DialogInterface dialog, int which) {
try {
if (which == AlertDialog.BUTTON_POSITIVE) {
setResult(RESULT_OK, getMediaProjectionIntent(
mUid, mPackageName, mPermanentGrant));
}
} catch (RemoteException e) {
Log.e(TAG, "Error granting projection permission", e);
setResult(RESULT_CANCELED);
} finally {
if (mDialog != null) {
mDialog.dismiss();
}
finish();
}
}
private Intent getMediaProjectionIntent(int uid, String packageName, boolean permanentGrant)
throws RemoteException {
IMediaProjection projection = mService.createProjection(uid, packageName,
MediaProjectionManager.TYPE_SCREEN_CAPTURE, permanentGrant);
Intent intent = new Intent();
intent.putExtra(MediaProjectionManager.EXTRA_MEDIA_PROJECTION, projection.asBinder());
return intent;
}
可以看到 setResult() 对应的 Intent 都来自 getMediaProjectionIntent(),这下找到源头了,既然我们本身就在源码里修改那
就别绕弯子,拿来就用。所以综合上面的两块源码最终获取 MediaProjection 对象是介个亚子的
private void initMediaProjection(){
try {
IBinder b = ServiceManager.getService(Context.MEDIA_PROJECTION_SERVICE);
IMediaProjectionManager mService = IMediaProjectionManager.Stub.asInterface(b);
String mPackageName = "com.android.systemui";
ApplicationInfo aInfo = getPackageManager().getApplicationInfo(mPackageName, 0);
IMediaProjection projection = mService.createProjection(aInfo.uid, mPackageName,
MediaProjectionManager.TYPE_SCREEN_CAPTURE, true);
mediaProjection = new MediaProjection(RecordService.this,
IMediaProjection.Stub.asInterface(projection.asBinder()));
} catch (Exception e) {
Log.e(TAG, "initMediaProjection happen some Exception", e);
e.printStackTrace();
}
//projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
//mediaProjection = mProjectionManager.getMediaProjection(mResultCode, mResultData);
}
1.2、录音动态权限处理
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
录屏的同时也需要采集 MIC 的声音,所以需要在 AndroidManifest.xml 中声明权限
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
采用之前 Android9.0/8.1/6.0 默认给系统 app 授予所有权限 方案,在 pms_sysapp_grant_permission_list.txt 添加包名 com.android.systemui 即可
2、显示相关
2.1、3秒倒计时动画(缩放+透明)
通过 WindowManager 添加类型为 TYPE_APPLICATION_OVERLAY 的窗体
private void showCountDownWindow() {
if (isShowCountDownView || inflateWindow != null || running) return;
WindowManager.LayoutParams layoutParams = new WindowManager.LayoutParams();
layoutParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
layoutParams.format = PixelFormat.RGBA_8888;
layoutParams.gravity = Gravity.CENTER;
layoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
layoutParams.width = 500;
layoutParams.height = 500;
inflateWindow = LayoutInflater.from(RecordService.this).inflate(R.layout.countdown_layout, null);
TextView animNumberTv = (TextView) inflateWindow.findViewById(R.id.tv_number_anim);
mWindowManager.addView(inflateWindow, layoutParams);
isShowCountDownView = true;
doCountDownAnim(animNumberTv);
}
View inflateWindow;
boolean isShowCountDownView;
int sCurCount = 3;
int repeatCount = 2;
private void doCountDownAnim(final TextView animationViewTv){
animationViewTv.setText(String.valueOf(sCurCount));
animationViewTv.setVisibility(View.VISIBLE);
AlphaAnimation alphaAnimation = new AlphaAnimation(1, 0);
ScaleAnimation scaleAnimation = new ScaleAnimation(
0.1f, 1.3f, 0.1f, 1.3f,
Animation.RELATIVE_TO_SELF, 0.5f,
Animation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setRepeatCount(repeatCount);
alphaAnimation.setRepeatCount(repeatCount);
alphaAnimation.setDuration(1000);
scaleAnimation.setDuration(1000);
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
animationViewTv.setVisibility(View.GONE);
startRecord();
sCurCount = 3;
}
@Override
public void onAnimationRepeat(Animation animation) {
animationViewTv.setText(String.valueOf(--sCurCount));
}
});
AnimationSet animationSet = new AnimationSet(true);
animationSet.addAnimation(alphaAnimation);
animationSet.addAnimation(scaleAnimation);
animationViewTv.startAnimation(animationSet);
}
2.2、statusBar背景修改为红色,表示正在录屏中
在 Activity 中通过 getWindow().setStatusBarColor(Color.RED); 就能修改 statusBar 背景色为空色,但这仅仅只属于顶部窗体,当你切换其它app时,将不再显示红色。我们的需求是只要在录屏中,不管如何操作,statusBar 就得保持红色。这是个问题?
经过布局文件等一顿分析后,在 StatusBar.java 中通过 mStatusBarView 可达到我们想要的效果。
private static final String OP_STATUSBAR_COLOR = "com.android.action.SET_STATUSBAR_COLOR";
private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (DEBUG) Log.v(TAG, "onReceive: " + intent);
String action = intent.getAction();
if (Intent.ACTION_SCREEN_OFF.equals(action)) {
finishBarAnimations();
resetUserExpandedStates();
}else if (OP_STATUSBAR_COLOR.equals(action)) {
Log.e("StatusBar", "OP_STATUSBAR_COLOR red: ");
if(intent.hasExtra("is_recording")){
boolean result = intent.getBooleanExtra("is_recording", false);
if (mStatusBarView != null)
mStatusBarView.setBackgroundColor(result
? android.graphics.Color.RED : android.graphics.Color.TRANSPARENT);
}
}
2.3、statusBar 点击弹出 IOS 风格对话框是否停止录制
IOS 风格对话框网上一大推,随便征用一个吧。还记得上面录屏权限那货吧,这里我们仿照它使用 Dialog 主题,便于在 PanelView 通过 startActivity 方式展示对话框。那么,PanelView 是怎么出来的呢?
只能说熟悉 StatusBar 的盆友们一看就知道,不知道的我给你说下,反手就是一个三连。
PhoneStatusBarView.java PanelBar.java PanelView.java
简单来说,PhoneStatusBarView 对应顶部的状态栏,PanelBar 对应状态栏下拉面板,PanelView 对应面板具体内容
触摸事件就是按照上面的顺序来传递的,以往我们屏蔽下拉最简单办法就在 PhoneStatusBarView 拦截,但现在我们得到事件处理的最底端
PanelView 中
vendor\mediatek\proprietary\packages\apps\SystemUI\src\com\android\systemui\statusbar\phone\PanelView.java
boolean justClick;
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean isRecording = SharedConfig.getInstance(mContext).readBoolean(SharedConfig.KEY_SCREEN_RECORDING, false);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
//cczheng
if (!isRecording) {
startExpandMotion(x, y, false /* startTracking */, mExpandedHeight);
}
justClick = true;
log("ACTION_DOWN");
.....
break;
......
case MotionEvent.ACTION_MOVE:
....
justClick = false;
android.util.Log.d("cpanel","ACTION_MOVE");
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
if (justClick && isRecording) {
log("showdialog");
mContext.startActivity(new android.content.Intent("com.android.systemui.stoprecrodtipactivity")
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK));
}
log("ACTION_CANCEL =="+justClick);
trackMovement(event);
endMotionEvent(event, x, y, false /* forceCancel */);
break;
}
return !mGestureWaitForTouchSlop || mTracking;
}
正常情况下,快速点击抬起手指,PanelView 会有个向下伸张回弹的动画,很显然在录屏模式下我们要屏蔽这个操作,
case MotionEvent.ACTION_DOWN isRecording 情况下不走 startExpandMotion() 即可
case MotionEvent.ACTION_MOVE justClick = false,有拖动则正常下拉
case MotionEvent.ACTION_UP justClick && isRecording 弹出是否停止录屏对话框
三、结束录制
1、显示相关
3.1、statusBar 恢复默认背景
private void setStatusBarColor(){
sendBroadcast(new Intent("com.android.action.SET_STATUSBAR_COLOR").putExtra("is_recording", false));
}
3.2、悬挂通知刚刚录制成功的视频,更新媒体库数据
需要注意的几个地方,悬挂通知类型需要设置为 NotificationManager.IMPORTANCE_HIGH,不然不能正常显示
7.0 以上访问文件uri时需要通过 FileProvider 来访问,并在 AndroidManif.xml 中配置,不然会报 FileUriExposedException异常
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="com.android.systemui.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
private void showHeadUpNotification(){
final NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
String channelId = java.util.UUID.randomUUID().toString().replaceAll("-", "");
NotificationChannel notificationChannel = new NotificationChannel(channelId,
"recordScreen", NotificationManager.IMPORTANCE_HIGH);
notificationChannel.setSound(null, null);//mute
mNotificationManager.createNotificationChannel(notificationChannel);
Notification.Builder builder = new Notification.Builder(this, channelId);
builder.setSmallIcon(R.drawable.ic_rs_on);
builder.setSubText(getResources().getString(R.string.record_notification_subtext));
builder.setAutoCancel(true);
builder.setContentText(getResources().getString(R.string.record_notification_contentext));
//for goto gallery
Intent intent = new Intent(Intent.ACTION_VIEW);
File recordFile = new File(recordFilePath);
Uri uriForFile = FileProvider.getUriForFile(this,"com.android.systemui.fileprovider", recordFile);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uriForFile, "video/*");
//scan media file to gallery
MediaScannerConnection.scanFile(this, new String[]{recordFile.getAbsolutePath()},
new String[]{"video/*"}, null);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, 0);
builder.setContentIntent(pendingIntent);
builder.setFullScreenIntent(pendingIntent, true);
final int notifyId = 100;
mNotificationManager.notify(notifyId, builder.build());
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
mNotificationManager.cancel(notifyId);
}
},4500);
}
3.3、点击通知跳转至系统媒体库自动播放
//scan media file to gallery
Intent intent = new Intent(Intent.ACTION_VIEW);
File recordFile = new File(recordFilePath);
Uri uriForFile = FileProvider.getUriForFile(this,"com.android.systemui.fileprovider", recordFile);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uriForFile, "video/*");