2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言
在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 API 的方式录制屏幕。
开始实现
技术原理
在安卓 5.0 之前,我们是无法通过常规的方式来录制屏幕或者截图的,要么只能 ROOT,要么就是只能用一些很 Hack 的方式来实现。
不过在安卓 5.0 后,安卓开放了 MediaProjectionManager 、 VirtualDisplay 等 API,使得普通应用录屏成为了可能。
简单来说,录屏的流程如下:
- 拿到
MediaProjectionManager对象 - 通过
MediaProjectionManager.createScreenCaptureIntent()拿到请求权限的Intent,然后用这个Intent去请求权限并拿到一个权限许可令牌(resultData,本质上还是个 Intent)。 - 通过拿到的 resultData 创建
VirtualDisplay投影。 VirtualDisplay将图像数据渲染至Surface中,最终,我们可以将Surface的数据流写入并编码至视频文件。(Surface可以由MediaCodec创建,而MediaMuxer可以将MediaCodec的数据编码至视频文件中)
从上面的流程可以看出,其实核心思想就是通过 VirtualDisplay 拿到当前屏幕的数据,然后绕一圈将这个数据写入视频文件中。
而 VirtualDisplay 顾名思义,其实是用来做虚拟屏幕或者说投影的,但是这里并不妨碍我们通过它来录屏啊。
不过由于我们是通过虚拟屏幕来实现录屏的,所以如果应用声明了禁止投屏或使用虚拟屏幕,那么我们录制的内容将是空白的(黑屏)。
准备工作
明白了实现原理之后,我们需要来做点准备工作。
首先是做好界面布局,在主入口编写布局:
override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContent {val context = LocalContext.currentScreenRecordTheme {// A surface container using the 'background' color from the themeSurface(modifier = Modifier.fillMaxSize(),color = MaterialTheme.colors.background) {Column(modifier = Modifier.fillMaxSize(),verticalArrangement = Arrangement.Center,horizontalAlignment = Alignment.CenterHorizontally) {Button(onClick = {startServer(context)}) {Text(text = "启动")}}}}}
}
布局很简单,就是居中显示一个启动按钮,点击按钮后启动录屏服务(Server),这里因为我们的需求是需要录制所有应用界面,而非本APP的界面,所以需要使用一个前台服务并显示一个悬浮按钮用于控制录屏开始与结束。
所以我们需要添加悬浮窗权限,并动态申请:
添加权限: <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
检查并申请权限:
if (Settings.canDrawOverlays(context)) {// ……// 已有权限
}
else {// 跳转到系统设置手动授予权限(这里其实可以直接跳转到当前 APP 的设置页面,但是不同的定制 ROM 设置页面路径不一样,需要适配,所以我们直接跳转到系统通用设置让用户自己找去)Toast.makeText(context, "请授予“显示在其他应用上层”权限后重试", Toast.LENGTH_LONG).show()val intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,Uri.parse("package:${context.packageName}"))context.startActivity(intent)
}
悬浮界面权限拿到后就是申请投屏权限。
首先,定义 Activity Result Api,并在获取到权限后将 ResultData 传入 Server,最后启动 Server:
private lateinit var requestMediaProjectionLauncher: ActivityResultLauncher<Intent>override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)// ……requestMediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {if (it.resultCode == Activity.RESULT_OK && it.data != null) {OverlayService.setData(it.data!!)startService(Intent(this, OverlayService::class.java))}else {Toast.makeText(this, "未授予权限", Toast.LENGTH_SHORT).show()}}
}
然后,在按钮的点击回调中启动这个 Launcher:
val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
requestMediaProjectionLauncher.launch(mediaProjectionManager.createScreenCaptureIntent()
)
在这里我们通过 getSystemService 方法拿到了 MediaProjectionManager ,并通过 mediaProjectionManager.createScreenCaptureIntent() 拿到请求权限的 Intent。
最终在授予权限后启动录屏 Server。
但是,这里有一点需要特别注意,由于安卓系统限制,我们必须使用前台 Server 才能投屏,并且还需要为这个前台 Server 显式设置一个通知用于指示 Server 正在运行中,否则将会抛出异常。
所以,添加前台服务权限:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
然后在我们的录屏服务中声明前台服务类型:
<serviceandroid:name=".overlay.OverlayService"android:enabled="true"android:exported="false"android:foregroundServiceType="mediaProjection" />
最后,我们需要为这个服务绑定并显示一个通知:
private fun initRunningTipNotification() {val builder = Notification.Builder(this, "running")builder.setContentText("录屏运行中").setSmallIcon(R.drawable.ic_launcher_foreground)val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManagerval channel = NotificationChannel("running","显示录屏状态",NotificationManager.IMPORTANCE_DEFAULT)notificationManager.createNotificationChannel(channel)builder.setChannelId("running")startForeground(100, builder.build())
}
需要注意的是,这里我们为了方便讲解,直接将创建和显示通知都放到了点击悬浮按钮后,并且停止录屏后也没有销毁通知。
各位在使用的时候需要根据自己需求改一下。
自此,准备工作完成。
哦,对了,关于如何使用 Compose 显示悬浮界面,因为不是本文重点,而且我也是直接套大佬的模板,所以这里就不做讲解了,感兴趣的可以自己看源码。
下面开始讲解如何录屏。
开始录屏
首先,我们编写了一个简单的帮助类 ScreenRecorder:
class ScreenRecorder(private var width: Int,private var height: Int,private val frameRate: Int,private val dpi: Int,private val mediaProjection: MediaProjection?,private val savePath: String
) {private var encoder: MediaCodec? = nullprivate var surface: Surface? = nullprivate var muxer: MediaMuxer? = nullprivate var muxerStarted = falseprivate var videoTrackIndex = -1private val bufferInfo = MediaCodec.BufferInfo()private var virtualDisplay: VirtualDisplay? = nullprivate var isStop = false/*** 停止录制* */fun stop() {isStop = true}/*** 开始录制* */fun start() {try {prepareEncoder()muxer = MediaMuxer(savePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)virtualDisplay = mediaProjection!!.createVirtualDisplay("$TAG-display",width,height,dpi,DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC,surface,null,null)recordVirtualDisplay()} finally {release()}}private fun recordVirtualDisplay() {while (!isStop) {val index = encoder!!.dequeueOutputBuffer(bufferInfo, TIMEOUT_US.toLong())if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {resetOutputFormat()} else if (index == MediaCodec.INFO_TRY_AGAIN_LATER) {//Log.d(TAG, "retrieving buffers time out!");//delay(10)} else if (index >= 0) {check(muxerStarted) { "MediaMuxer dose not call addTrack(format) " }encodeToVideoTrack(index)encoder!!.releaseOutputBuffer(index, false)}}}private fun encodeToVideoTrack(index: Int) {var encodedData = encoder!!.getOutputBuffer(index)if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {bufferInfo.size = 0}if (bufferInfo.size == 0) {encodedData = null}if (encodedData != null) {encodedData.position(bufferInfo.offset)encodedData.limit(bufferInfo.offset + bufferInfo.size)muxer!!.writeSampleData(videoTrackIndex, encodedData, bufferInfo)}}private fun resetOutputFormat() {check(!muxerStarted) { "output format already changed!" }val newFormat = encoder!!.outputFormatvideoTrackIndex = muxer!!.addTrack(newFormat)muxer!!.start()muxerStarted = true}private fun prepareEncoder() {val format = MediaFormat.createVideoFormat(MIME_TYPE, width, height)format.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)format.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, IFRAME_INTERVAL)encoder = MediaCodec.createEncoderByType(MIME_TYPE)encoder!!.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)surface = encoder!!.createInputSurface()encoder!!.start()}private fun release() {if (encoder != null) {encoder!!.stop()encoder!!.release()encoder = null}if (virtualDisplay != null) {virtualDisplay!!.release()}mediaProjection?.stop()if (muxer != null) {muxer?.stop()muxer?.release()muxer = null}}companion object {private const val TAG = "el, In ScreenRecorder"private const val MIME_TYPE = "video/avc" // H.264 Advanced Video Codingprivate const val IFRAME_INTERVAL = 10 // 10 seconds between I-framesprivate const val BIT_RATE = 6000000private const val TIMEOUT_US = 10000}
}
在这个类中,接收以下构造参数:
- width: Int, 创建虚拟屏幕以及写入的视频宽度
- height: Int, 创建虚拟屏幕以及写入的视频高度
- frameRate: Int, 写入的视频帧率
- dpi: Int, 创建虚拟屏幕的 DPI
- mediaProjection: MediaProjection?, 用于创建虚拟屏幕的 mediaProjection
- savePath: String, 写入的视频文件路径
我们可以通过调用 start() 方法开始录屏;调用 stop() 方法停止录屏。
调用 start() 后,会首先调用 prepareEncoder() 方法。该方法主要用途是按照给定参数创建 MediaCodec ,并通过 encoder!!.createInputSurface() 创建一个 Surface 以供后续接收虚拟屏幕的图像数据。
预先设置完成后,按照给定路径创建 MediaMuxer;将参数和之前创建的 surface 传入,创建一个新的虚拟屏幕,并开始接受图像数据。
最后,循环从上面创建的 MediaCodec 中逐帧读出有效图像数据并写入 MediaMuxer 中,即写入视频文件中。
看起来可能比较绕,但是理清楚之后还是非常简单的。
接下来就是如何去调用这个帮助类。
在调用之前,我们需要预先准备好需要的参数:
val savePath = File(externalCacheDir, "${System.currentTimeMillis()}.mp4").absolutePath
val screenSize = getScreenSize()
val mediaProjection = getMediaProjection()
savePath表示写入的视频文件路径,这里我偷懒直接写成了 APP 的缓存目录,如果想要导出到其他地方,记得处理好运行时权限。screenSize表示的是当前设备的屏幕尺寸mediaProjection表示请求权限后获取到的权限“令牌”
在 getScreenSize() 中,我获取了设备的屏幕分辨率:
private fun getScreenSize(): IntSize {val windowManager = getSystemService(WINDOW_SERVICE) as WindowManagerval screenHeight = windowManager.currentWindowMetrics.bounds.height()val screenWidth = windowManager.currentWindowMetrics.bounds.width()return IntSize(screenWidth, screenHeight)
}
但是如果我直接把这个分辨率传给帮助类创建 MediaCodec 的话会报错:
java.lang.IllegalArgumentExceptionat android.media.MediaCodec.native_configure(Native Method)at android.media.MediaCodec.configure(MediaCodec.java:2214)at android.media.MediaCodec.configure(MediaCodec.java:2130)
不过,这个问题只在某些分辨率较高的设备上出现,猜测是不支持高分辨率视频写入吧,所以我实际上使用时是直接写死一个较小的分辨率,而不是使用设备的分辨率。
然后,在 getMediaProjection() 中,我们通过申请到的权限令牌生成 MediaProjection :
private fun getMediaProjection(): MediaProjection? {if (resultData == null) {Toast.makeText(this, "未初始化!", Toast.LENGTH_SHORT).show()} else {try {val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManagerreturn mediaProjectionManager.getMediaProjection(Activity.RESULT_OK, resultData!!)} catch (e: IllegalStateException) {Log.e(TAG, "getMediaProjection: ", e)Toast.makeText(this, "ERR: ${e.stackTraceToString()}", Toast.LENGTH_LONG).show()}catch (e: NullPointerException) {Log.e(TAG, "getMediaProjection: ", e)}catch (tr: Throwable) {Log.e(TAG, "getMediaProjection: ", tr)Toast.makeText(this, "ERR: ${tr.stackTraceToString()}", Toast.LENGTH_LONG).show()}}return null
}
最后,通过上面生成的这两个参数初始化录屏帮助类,然后调用 start() :
// 这里如果直接使用屏幕尺寸会报错 java.lang.IllegalArgumentException
recorder = ScreenRecorder(886, // screenSize.width,1920, // screenSize.height,24,1,mediaProjection,savePath
)CoroutineScope(Dispatchers.IO).launch {try {recorder.start()} catch (tr: Throwable) {Log.e(TAG, "startScreenRecorder: ", tr)recorder.stop()withContext(Dispatchers.Main) {Toast.makeText(this@OverlayService, "录制失败", Toast.LENGTH_LONG).show()}}
}
这里我把开始录屏放到了协程中,实际上由于我们的程序是运行在 Server 中,所以并不是必须在协程中运行。
总结
自此,在安卓中录屏的方法已经全部介绍完毕。
实际上,同样的原理我们也可以用于实现截图。
截图和录屏不同的地方在于,创建虚拟屏幕时改为使用 ImageReader 创建,然后就可以从 ImageReader 获取到 Bitmap。
最后附上完整的 demo 地址: ScreenRecord
相关文章:
2022年都快结束了,还有人不会安卓录屏?在安卓上录制屏幕的的实现方式
前言 在我之前的文章 《以不同的形式在安卓中创建GIF动图》 中,我挖了一个坑,可以通过录制屏幕后转为 GIF 的方式来创建 GIF。只是当时我只是提了这么一个思路,并没有给出录屏的方式,所以本文的内容就是教大家如何通过调用系统 A…...
px rem em rpx 区别 用法
任意浏览器的默认字体高都是16px。所有未经调整的浏览器都符合: 1em16px。那么12px0.75em,10px0.625em。为了简化font-size的换算,需要在css中的body选择器中声明Font-size62.5%,这就使em值变为 16px*62.5%10px, 这样12px1.2em, 10px1em, 也就是说只需要…...
忆享聚焦|ChatGPT、AI、网络数字、游戏……近期热点资讯一览
“忆享聚焦”栏目第十四期来啦!本栏目汇集近期互联网最新资讯,聚焦前沿科技,关注行业发展动态,筛选高质量讯息,拓宽用户视野,让您以最低的时间成本获取最有价值的行业资讯。 目录 行业资讯 1.科技部部长王志…...
[Daimayuan] 树(C++,动态规划,01背包方案数)
有一棵 n n n 个节点的以 1 1 1 号点为根的有根树。现在可以对这棵树进行若干次操作,每一次操作可以选择树上的一个点然后删掉连接这个点和它的儿子的所有边。 现在我们想知道对于每一个 k k k ( 1 ≤ k ≤ n 1≤k≤n 1≤k≤n),最少需要多少次操作能…...
如何选择源代码加密软件
(SDC沙盒)和DLP、文档加密、云桌面等,其优缺点做客观比较如下: 比较内容安全容器(SDC沙盒)DLP文档加密云桌面代表厂家*信达卖咖啡、赛门贴科亿*通、IP噶德、*盾、*途四杰、深*服设计理念以隔离容器加准入技术为基础,构…...
TO-B类软件产品差异化
产品差异化,是在市场众多同质化产品中,突出自身产品亮点的重要方式。对于客户来讲其选择是多种多样的,与其花费大量的时间研究每一家产品的特点,还不如直接选择品牌更大、价格更低的产品来的直接,因此显而易见的突出产…...
设计模式之美-实战一(上):业务开发常用的基于贫血模型的MVC架构违背OOP吗?
领域驱动设计(Domain Driven Design,简称DDD)盛行之后,这种基于贫血模型的传统的开发模式就更加被人诟病。而基于充血模型的DDD开发模式越来越被人提倡。所以,我打算用两节课的时间,结合一个虚拟钱包系统的…...
ChatGPT如何训练自己的模型
ChatGPT是一种自然语言处理模型,它的任务是生成自然流畅的对话。如果想要训练自己的ChatGPT模型,需要进行大量的数据收集、预处理、配置训练环境、模型训练、模型评估等过程。本文将详细介绍这些过程,帮助读者了解如何训练一个高品质的ChatGP…...
springboot使用线程池的实际应用(一)
在实际Spring Boot项目中,我们可以使用Java的原生多线程或者使用Spring自带的线程池进行多线程编程。多线程的好处在于能够提高应用程序的运行效率,特别是在某些计算密集型场景下。以下是一些使用多线程的典型场景: 并发处理请求:…...
ESP-8266学习笔记
1、学习地址 【XMF09F系列资源】基于MicroPython的ESP8266物联网应用开发-赛教资源目录汇总-小蜜蜂笔记 Quick reference for the ESP8266 — MicroPython latest documentation 2、MicroPython及相关开发资源 3、固件烧录与uPyLoader的使用 烧录教程参考: https://www.…...
Java泛型简单的使用
前言 Java里面的泛型在实际开发中运用的很多,学过C的同学一定知道C的模板,而Java中的泛型,一定程度上和它还是挺像的。 相信写Java的人,大都有用过List的实现类ArrayList。在Java没有泛型之前,它的内部是一个Object的…...
深度探索:Qt CMake工程编译后的自动打包策略
深度探索:Qt CMake工程编译后的自动打包策略 1. 引言(Introduction)1.1 Qt和CMake的基本概念(Basic Concepts of Qt and CMake)1.2 自动打包的重要性(Importance of Automatic Packaging) 2. Qt…...
2.7 编译型和解释型
2.7 编译型和解释型 前面我们使用java和javac命令把Hello,World!在控制台输出。那为什么输出,这里我们需要掌握两个知识点。编译型语言和解释型语言。在计算机的高级编程语言就分为编译型语言和解释型语言。而我们的Java既有编译型的特点也有…...
校园网自动登陆(河南科技学院)
1. 介绍 河南科技学院校园网自动登陆(新乡的很多系统相似,可能也可以用?),java版。可以实现电脑,路由器,软路由的自动认证wifi,后续会上传docker版本的。 源码地址 github:https://…...
C++11 override和final关键字
C11中的override和final关键字是为了增强代码的编译时类型检查和面向对象设计中的继承机制。 override关键字用于显示地表明派生类中的成员函数覆盖了基类中的虚函数。当派生类中的函数与基类中的虚函数签名不同或者没有使用override关键字时,编译器会给出警告或错…...
kafka的log存储解析
kafka的log存储解析——topic的分区partition分段segment以及索引等 引言Kafka中的Message是以topic为基本单位组织的,不同的topic之间是相互独立的。每个topic又可以分成几个不同的partition(每个topic有几个partition是在创建topic时指定 的),每个…...
4.文件系统
组成 Linux:一切皆文件 索引节点(I-node) I-node(Index Node):文件系统的内部数据结构,用于管理文件的元数据和数据块。 文件的元数据:包括文件的权限、拥有者、大小、时间戳、索引…...
Shell脚本case in esac分支语句应用
记录:434 场景:Shell脚本case in esac分支语句应用。 版本:CentOS Linux release 7.9.2009。 1.case in esac格式 格式: case 值 in 模式1)expression;; 模式2)expression;; 模式n)expression;; esac 解析:case…...
【线性dp必学四道题】线性dp四道经典例题【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列(maxv的由来)】【最长公共子串】
【最长上升子序列】、【最长公共子序列】、【最长公共上升子序列】 最长上升子序列f[i] 表示以i结尾的最长子序列 最长公共子序列f[i][j] 表示 a前i 和 b前j个 最长公共长度 最长公共上升子序列f[i][j]代表所有a[1 ~ i]和b[1 ~ j]中以b[j]结尾的公共上升子序列的集合 最长公共子…...
追寻幸福:探索幸福的关键特征和行为
目录 1. 积极的心态 2. 良好的人际关系 3. 自我接纳和自尊 4. 追求意义和目标 5. 健康的身心状态 6. 感知和实现个人价值 幸福是一个主观的感受,因此不同的人对于幸福的定义和追求方式可能会有所不同。然而,有一些共同的特点和行为模式,…...
RocketMQ延迟消息机制
两种延迟消息 RocketMQ中提供了两种延迟消息机制 指定固定的延迟级别 通过在Message中设定一个MessageDelayLevel参数,对应18个预设的延迟级别指定时间点的延迟级别 通过在Message中设定一个DeliverTimeMS指定一个Long类型表示的具体时间点。到了时间点后…...
[ICLR 2022]How Much Can CLIP Benefit Vision-and-Language Tasks?
论文网址:pdf 英文是纯手打的!论文原文的summarizing and paraphrasing。可能会出现难以避免的拼写错误和语法错误,若有发现欢迎评论指正!文章偏向于笔记,谨慎食用 目录 1. 心得 2. 论文逐段精读 2.1. Abstract 2…...
CocosCreator 之 JavaScript/TypeScript和Java的相互交互
引擎版本: 3.8.1 语言: JavaScript/TypeScript、C、Java 环境:Window 参考:Java原生反射机制 您好,我是鹤九日! 回顾 在上篇文章中:CocosCreator Android项目接入UnityAds 广告SDK。 我们简单讲…...
C# 类和继承(抽象类)
抽象类 抽象类是指设计为被继承的类。抽象类只能被用作其他类的基类。 不能创建抽象类的实例。抽象类使用abstract修饰符声明。 抽象类可以包含抽象成员或普通的非抽象成员。抽象类的成员可以是抽象成员和普通带 实现的成员的任意组合。抽象类自己可以派生自另一个抽象类。例…...
MySQL 8.0 OCP 英文题库解析(十三)
Oracle 为庆祝 MySQL 30 周年,截止到 2025.07.31 之前。所有人均可以免费考取原价245美元的MySQL OCP 认证。 从今天开始,将英文题库免费公布出来,并进行解析,帮助大家在一个月之内轻松通过OCP认证。 本期公布试题111~120 试题1…...
AI编程--插件对比分析:CodeRider、GitHub Copilot及其他
AI编程插件对比分析:CodeRider、GitHub Copilot及其他 随着人工智能技术的快速发展,AI编程插件已成为提升开发者生产力的重要工具。CodeRider和GitHub Copilot作为市场上的领先者,分别以其独特的特性和生态系统吸引了大量开发者。本文将从功…...
CMake控制VS2022项目文件分组
我们可以通过 CMake 控制源文件的组织结构,使它们在 VS 解决方案资源管理器中以“组”(Filter)的形式进行分类展示。 🎯 目标 通过 CMake 脚本将 .cpp、.h 等源文件分组显示在 Visual Studio 2022 的解决方案资源管理器中。 ✅ 支持的方法汇总(共4种) 方法描述是否推荐…...
Java线上CPU飙高问题排查全指南
一、引言 在Java应用的线上运行环境中,CPU飙高是一个常见且棘手的性能问题。当系统出现CPU飙高时,通常会导致应用响应缓慢,甚至服务不可用,严重影响用户体验和业务运行。因此,掌握一套科学有效的CPU飙高问题排查方法&…...
网站指纹识别
网站指纹识别 网站的最基本组成:服务器(操作系统)、中间件(web容器)、脚本语言、数据厍 为什么要了解这些?举个例子:发现了一个文件读取漏洞,我们需要读/etc/passwd,如…...
C++.OpenGL (20/64)混合(Blending)
混合(Blending) 透明效果核心原理 #mermaid-svg-SWG0UzVfJms7Sm3e {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-SWG0UzVfJms7Sm3e .error-icon{fill:#552222;}#mermaid-svg-SWG0UzVfJms7Sm3e .error-text{fill…...
