0

TVPause : 接打电话时自动暂停电视

 2 years ago
source link: https://blog.andiedie.cn/posts/cb98/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

TVPause : 接打电话时自动暂停电视

发表于

2019-02-11

|

更新于 2019-05-10

| 分类于 Development

| 评论数: 0

| 阅读次数:

最近家里有个小的需求,就是希望在接打电话的时候能够自动暂停电视节目。于是我就写了一个简单的工具,这篇博客主要记录实现的过程、遇到的问题以及一些小的心得。开源地址:TVPause

image-20190211184923522

也许有人会奇怪为什么会有“接打电话的时候自动暂停电视节目”这种伪需求,直接按个遥控器上静音或者暂停按钮不就行了吗?事情并没有那么简单。

  • 首先,家里有一个习惯是,吃饭的时候看电视,如果吃饭的时候来电话,得先放下碗筷,静音/暂停电视,然后接听电话,步骤很多
  • 其次,爸爸做小本生意,晚上经常会有订货的电话,因此这个需求出现的频次很高
  • 最后,家里用的是小米电视,小米电视遥控器,没有静音按钮!🤨

因此我决定着手解决这个问题,理想的状态时,一旦来电,如果正在播放点播节目,就直接暂停;如果正在收看电视直播,就静音。

不过后来发现,想要获取小米电视正在播放点播节目还是电视直播并不容易,于是就选择了一种“又不是不能用”的实现方法:一旦来电,模拟按下确认按钮(如果是点播节目会暂停,直播则无效果),并静音;电话结束后,再次模拟按下确认按钮(点播节目会继续,直播则无效果),恢复音量。

这样的好处是,实现简单。坏处是,模拟点击确认按钮这个过程不够可靠,会误触一些其他按钮。例如此时电视其实并没有在播放节目,而是在主界面待机,此时来电点击确认按钮的话,就会选中光标内容。

无伤大雅,只要使用上略加注意就行了,开发效率万岁。🎉

最终流程:

接打电话 → 保存当前音量值 → 模拟点击确认 → 调整音量为 0

电话结束 → 读取备份音量值 → 模拟点击确认 → 恢复备份音量

2. 小米电视协议分析

家里的手机都没有红外接口,且红外对物理要求太高,所以我直接放弃了红外这条路。下一条路就是分析小米电视的 APP “小米投屏神器” 的协议。

2.1. 初战 HTTP 协议

最容易想到和分析的就是 HTTP 协议,按照 移动设备抓包 这篇博客中的介绍,使用 Charles 抓包,结果如下:

image-20190211124903539

打开 APP 第一步必然是先找到小米电视的服务地址,这里发现了一条功能类似的 API,提供了小米电视非常详细的信息。但是这个 API 虽然使用的是 HTTPS,但是证书确实私自签发的,直接请求会报错:

image-20190211125140930

且里面有一些参数例如,deviceidkey 还暂时不知道来源。

另外最重要的是,这个 API 是向公网请求的设备信息,而服务发现应该是在局域网就能完成的事情,所以暂时放弃该 API。

在进入手机遥控器的时候,手机会发起一个获取音量的请求:

image-20190211125439899

这个 API 就非常友好:

  • 向内网的小米电视发送请求
  • HTTP 协议
  • 没有乱七八糟的参数

可以直接拿来用

在 APP 上调整音量有两个方式,一个是通过手机音量键步进调整,一个是使用 UI 上的拖动条直接一步到位。

步进调整的请求使用 Charles 无法获取,下面是拖动调整的 API:

image-20190211125754298

这里的 API 和上面的获取音量的非常相似,有如下参数

  • actionsetVolum 表示设置音量
  • volum:目标音量
  • ts:时间戳
  • sign:签名

问题就出在这个签名上,签名一般是将参数以某种顺序拼接后,附带盐值,然后使用 Hash 算法计算。这里不知道拼接顺序,也不知道是否有盐值,更不知道算法是什么,所以使用这个 API 非常困难。

接着发现包括步进调整音量、点击确认、上下左右等所有 APP 上的点按按键都无法使用 Charles 获取请求,很明显这里使用的不是 HTTP 请求,再加上之前服务发现和设置音量的 API 不可用,所以第一次挑战 HTTP API 失败。

2.2. TCP / UDP 协议

既然 Charles 不行,那就上 Wireshark。按照 移动设备抓包 这篇博客中介绍的,使用 Wireshark 监听 iPhone 上的数据包。

开启 Wireshark,点击“确认”按钮后,拦截到数据包,适当过滤后:

image-20190211131501834

两个包的内容分别为

image-20190211131253854

image-20190211131301793

其中,蓝色的部分就是 TCP 的数据字段。经过多次拦截和分析后,总结出的协议格式如下:

0000    04 00 41 01 00 00 00 45 00 3a 01 00 00 00 00 02
0010 00 00 00 01 03 00 00 00 42 04 00 00 00 1c 05 00
0020 00 00 00 06 00 00 00 08 07 00 00 00 00 00 00 00
0030 00 08 00 00 00 00 00 00 00 00 0a ff ff ff ff 0b
0040 00 00 03 01
  • 0004 ~ 0007 是序号,每次发送 +1
  • 0010 ~ 0013 每次按键时会发送两次几乎一模一样的 TCP 包,第一次该字段为 0, 第二次为 1。推测应该是按键状态,0 表示按下,1 表示松开。
  • 0018 ~ 001f 是按键码,详见下表
  • 其他位保持变即可

按键码表:

按键功能 按键码

电源 0x1a04000000740500

上 0x1304000000670500

下 0x14040000006c0500

左 0x1504000000690500

右 0x16040000006a0500

确认 0x42040000001c0500

主页 0x0304000000660500

返回 0x04040000009e0500

菜单 0x05040000008b0500

音量增 0x1804000000730500

音量减 0x1904000000720500

在使用 Wireshark 监听时发现,除了上述的 TCP 包用于发送指令,APP 还会在刚启动时使用 mDNS 协议寻找服务:

image-20190211141728625

如上图,前 4 个包是 APP 刚启动时,向所有网段广播寻找 _rc._tcp 类型的服务。之后小米电视收到广播,回复了服务的端口和地址,并附带了小米电视的详细信息。

在命令行可以使用下列命令模拟:

# 搜索服务
dns-sd -B _rc._tcp local
# 获取服务相信信息
dns-sd -L 客厅的小米盒子 _rc._tcp.

image-20190211142039290

2.3. 再战 HTTP 协议

其实到之前的步骤已经可以实现整个应用了:

接打电话 → 通过 mDNS 协议搜索小米电视 → 通过 HTTP 协议获取当前音量 → 保存当前音量 → 通过 TCP 协议按下确认键并调节音量至0

电话结束 → 通过 mDNS 协议搜索小米电视 → 通过 HTTP 协议获取当前音量 → 读取存档音量 → 通过 TCP 协议按下确认键并恢复音量

实际上我也实现另一个这样的版本,但是遇到了几个问题:

  • 在使用 TCP 连接上小米电视后,第一次连接总是会被服务端关闭 Socket。原因不明,应该不是我的实现问题,因为 APP 也会出现这个问题。
  • 如果当前音量是 50,那么调整音量为 0 需要至少 50 个 TCP 包。当然理论上是 100 个,包括 50 次音量下键每次两个包,但是测试发现只发送第一个包也没什么问题,因此是 50 个。这样速度慢,并且非常不稳定。

上述两个问题出现一个还好解决,但是总是一起出现,比如调节音量的包发了几个,Socket 被关了,处理起来非常麻烦。

所以我放弃了这个方案,决定再战一次 HTTP 协议,毕竟有个 setVolum 的 API 实在非常诱人。

3. 反编译

无法使用 setVolum 的原因是不知道如何计算 sign,既然如此,就直接反编译 APP 看看,这个签名究竟是怎么算出来的。关于反编译的简易教程可以查看这篇博客

3.1. 查看源码

让我们列一个任务表,更加清晰地追踪源码:

任务 完成

计算签名的输入

计算签名的算法

分析设置音量的函数

使用 jd-gui 查看投屏神器的源码,找到设置音量的对应函数:

// com.xiaomi.mitv.phone.tvassistant.b.a.a(int) : void
public void a(int paramInt)
{
String str1 = String.valueOf(System.currentTimeMillis());
String str2 = a(String.valueOf(paramInt), this.b, str1);
new c(this.d, String.format("http://%s:6095/general?action=setVolum&volum=%d&ts=%s&sign=%s", new Object[] { this.a, Integer.valueOf(paramInt), str1, str2 }), new c.a()
{
public void a(int paramAnonymousInt, String paramAnonymousString) {}
}).d();
}

可以看到,第 6 行发出了请求,根据 String.format可以得知一些变量对应的信息:

  • paramInt: 目标音量值
  • str1:当前时间
  • str2:签名

显然,如何计算 str2 是最令人感兴趣的部分。

来到第 5 行,发现 str2 使用函数 a 计算,函数 a 就是计算签名的函数。

函数 a 的参数分别为:

  • String.valueOf(paramInt):目标音量
  • this.b:暂时不明
  • str1:当前时间

任务 完成

计算签名的输入 ❓

计算签名的算法 ❓

this.b 是什么 ❓

分析签名的输入

查看函数 a 的逻辑:

// com.xiaomi.mitv.phone.tvassistant.b.a.a(String, String, String) : String
private String a(String paramString1, String paramString2, String paramString3)
{
return g.a("mitvsignsalt" + paramString1 + paramString2 + paramString3.substring(paramString3.length() - 5));
}

很明显,这就是计算 sign 的函数,计算逻辑如下:

  • 将盐值 mitvsignsalt、目标音量 paramString1paramString2(即this.b)和当前时间 paramString3 拼接在一起
  • 调用函数 g.a 计算

至此,我们搞懂了计算签名的输入,就是上述的字符串拼接。

任务 完成

计算签名的输入 ✅

计算签名的算法 ❓

this.b 是什么 ❓

分析签名的算法

让我们看看 g.a 干了什么:

// com.xiaomi.mitv.socialtv.common.e.g.a(String) : String
public static String a(String paramString)
{
if (paramString == null) {}
for (paramString = "";; paramString = a(paramString.getBytes())) {
return paramString;
}
}

g.a 调用了一个重载的函数:

// com.xiaomi.mitv.socialtv.common.e.g.a(byte[]) : String
public static String a(byte[] paramArrayOfByte)
{
if (paramArrayOfByte == null) {
paramArrayOfByte = "";
}
for (;;)
{
return paramArrayOfByte;
try
{
MessageDigest localMessageDigest = MessageDigest.getInstance("MD5");
localMessageDigest.reset();
localMessageDigest.update(paramArrayOfByte);
paramArrayOfByte = e.a(localMessageDigest.digest());
}
catch (Exception paramArrayOfByte)
{
paramArrayOfByte = "";
}
}
}

很明显了,签名的算法是 MD5

任务 完成

计算签名的输入 ✅

计算签名的算法 ✅

this.b 是什么 ❓

this.b 是什么

只要搞清楚 this.b 是什么,我们就可以计算签名了。而且根据经验,它很可能是一个 key。由服务端(小米电视)和客户端(手机 APP)共同拥有。

然而很遗憾,以我的功力,没能够在代码里面找到 this.b 的来源,所以我换了一个新方法。

3.2. 重编译 APP

既然没办法直接把 this.b 是什么看出来,我就想着干脆用日志把它的值输出出来,看看有什么猫腻。

这就涉及到反编译 APP,修改源码,重新打包和签名,具体过程可以查看这篇博客

唯一修改的文件 smali/com/xiaomi/mitv/phone/tvassistant/b/a.smali

.method public a(I)V
# 额外定义 4 个寄存器用于存储 TAG
.locals 12

.prologue
.line 55

# 设置初始化TAG
const-string v8, "AndiedieHack.currentTimeMillis"

const-string v9, "AndiedieHack.param"

const-string v10, "AndiedieHack.b"

const-string v11, "AndiedieHack.result"

invoke-static {}, Ljava/lang/System;→currentTimeMillis()J

move-result-wide v0

invoke-static {v0, v1}, Ljava/lang/String;→valueOf(J)Ljava/lang/String;

move-result-object v0
# 第一个 Log,输出 v0 的值,即系统当前时间
invoke-static {v8, v0}, Landroid/util/Log;→d(Ljava/lang/String;Ljava/lang/String;)I

.line 57
invoke-static {p1}, Ljava/lang/String;→valueOf(I)Ljava/lang/String;

move-result-object v1
# 第二个 Log,输出 v1 的值,即目标音量
invoke-static {v9, v1}, Landroid/util/Log;→d(Ljava/lang/String;Ljava/lang/String;)I

iget-object v2, p0, Lcom/xiaomi/mitv/phone/tvassistant/b/a;→b:Ljava/lang/String;
# 第三个 Log,输出 v2 的值,即 this.b
invoke-static {v10, v2}, Landroid/util/Log;→d(Ljava/lang/String;Ljava/lang/String;)I

invoke-direct {p0, v1, v2, v0}, Lcom/xiaomi/mitv/phone/tvassistant/b/a;→a(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

move-result-object v1
# 第四个 Log,输出 v1 的值,即签名结果
invoke-static {v11, v1}, Landroid/util/Log;→d(Ljava/lang/String;Ljava/lang/String;)I

运行修改后的 APP,尝试修改音量,出现以下日志:

image-20190211175802805

这个 this.b 的值是 3c:bd:3e:84:35:31

乍一看就是一个 mac 地址,很有可能就是小米电视的 mac 地址,于是我兴奋地打开路由器界面查询:

image-20190211184428074

不一样🤕!!!

正当我心灰意冷之时,我猛然发现,这个 mac 地址和刚刚 mDNS 里面获取的 mac 地址一模一样:

image-20190211184706127

之后才发现,3c:bd:3e:84:35:31 是小米电视的以太网口的 mac 地址。

也就是说,this.b 的值可以通过 mDNS 获取。

任务 完成

计算签名的输入 ✅

计算签名的算法 ✅

this.b 是什么 ✅

3.3. 验证

接下来对签名算法进行验证:

image-20190211184923521

OK,通过

经过上面的折腾,最终的方案定为:

接打电话

通过 mDNS 协议搜索小米电视
┣→ 通过 TCP 协议按下确认键
┗→ 通过 HTTP 协议获取当前音量
┣→ 保存当前音量
┗→ 通过 HTTP 设置音量为 0

电话结束

通过 mDNS 协议搜索小米电视
┣→ 通过 TCP 协议按下确认键
┗→ 读取保存音量 → 通过 HTTP 恢复音量

4.1. mDNS 实现

利用 mDNS 获取小米电视信息,这里借助 Android 提供的 NsdManager 实现:

val mNsdManager = getSystemService(Context.NSD_SERVICE) as NsdManager
val mDiscoveryListener = object : NsdManager.DiscoveryListener {
val listen = this
override fun onServiceFound(serviceInfo: NsdServiceInfo?) {
Log.d(TAG, "ServiceFound: $serviceInfo")
// 发现服务后,获取服务详细信息
mNsdManager.resolveService(serviceInfo, object: NsdManager.ResolveListener {
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
Log.e(TAG, "Resolve failed: $errorCode")
}

override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
Log.d(TAG, "Resolve Succeeded. $serviceInfo")
if (serviceInfo == null) return
// !!服务详细信息!!
serviceInfo
mNsdManager.stopServiceDiscovery(listen)
}

})
}

override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {
Log.e(TAG, "Stop DNS-SD discovery failed: Error code:$errorCode")
mNsdManager.stopServiceDiscovery(this)
}

override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
Log.e(TAG, "Start DNS-SD discovery failed: Error code:$errorCode")
mNsdManager.stopServiceDiscovery(this)
}

override fun onServiceLost(serviceInfo: NsdServiceInfo?) {
Log.e(TAG, "DNS-SD service lost: $serviceInfo")
}

override fun onDiscoveryStarted(serviceType: String?) {
Log.d(TAG, "DNS-SD discovery started")
}

override fun onDiscoveryStopped(serviceType: String?) {
Log.d(TAG, "DNS-SD discovery stopped: $serviceType")
}
}
// 注意服务类型是 _rc._tcp
mNsdManager.discoverServices("_rc._tcp", NsdManager.PROTOCOL_DNS_SD, mDiscoveryListener)

4.2. Socket 实现

借助 Socket 发送 TCP 包,实现暂停:

val CONFIRM_BYTES = byteArrayOf(0x04, 0x00, 0x41, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3a, 0x01, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x42, 0x04, 0x00, 0x00, 0x00, 0x1c, 0x05, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x08, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x03, 0x01)

val socket = Socket(serviceInfo.host, serviceInfo.port)
val oStream = socket.getOutputStream()
val press = CONFIRM_BYTES
val up = CONFIRM_BYTES.copyOf()
up[0x13] = 0x01
oStream.write(press)
oStream.write(up)
oStream.flush()

4.3. 监听电话状态

<uses-permission android:name="android.permission.READ_PHONE_STATE" />

注意这个权限必须运行时请求:

private const val PERMISSIONS_REQUEST = 852
private fun hasPermissions(permissions: List<String>): Boolean {
for (permission in permissions) {
if (ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) {
return false
}
}
return true
}

private fun checkPermissions() {
val permissions = mutableListOf<String>(Manifest.permission.READ_PHONE_STATE)
if (!hasPermissions(permissions)) {
Log.d(TAG, "Permissions missing")
ActivityCompat.requestPermissions(this, permissions.toTypedArray(), PERMISSIONS_REQUEST)
} else {
Log.d(TAG, "Permissions granted")
permission = true
}
}

override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (grantResults.isEmpty()) return
when (requestCode) {
PERMISSIONS_REQUEST -> {
var flag = true
for (result in grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
flag = false
break
}
}
permission = if (flag) {
Log.d(TAG, "Request permissions success")
true
} else {
Log.d(TAG, "Request permissions failed")
false
}
}
}
}

服务实现:

private val phoneStateReceiver = object : BroadcastReceiver() {
private var lastIdle = true
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) return
when (intent.action) {
TelephonyManager.ACTION_PHONE_STATE_CHANGED -> {
val tManager = context.getSystemService(TELEPHONY_SERVICE) as TelephonyManager
if (tManager.callState == TelephonyManager.CALL_STATE_IDLE) {
lastIdle = true
// 电话结束
} else if (lastIdle) {
lastIdle = false
// 接打电话
}
}
}
}
}

private fun register() {
this.registerReceiver(phoneStateReceiver, IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED))
}

5.1. 刚连接 WiFi 时 Socket Timeout

如果在刚刚连接 WiFi 的一瞬间连接 Socket,会出现 connect failed: ETIMEDOUT 的错误

为了避免这样的错误,得进行自动重连:

while (true) {
try {
socket = Socket(serviceInfo.host, serviceInfo.port)
Log.d(TAG, "Socket connected: ${serviceInfo.host}:${serviceInfo.port}")
break
} catch (err : ConnectException) {
Thread.sleep(1000)
Log.e(TAG, err.toString())
Log.d(TAG, "Retrying")
}
}

5.2. RxJava 自动回收

RxJava 中一些任务如果没有回收,会有一些潜在的问题。

例如,如果在某个 Activity 中使用了一个用于网络请求的 RxJava,此时如果 Activity 被系统关闭,会造成内存泄漏。

以下代码可以实现自动回收:

val compositeDisposable = new CompositeDisposable();

val disposable = Observable.just(1)
.subscribeOn(Schedulers.io())
.subscribe(number -> Log.d(number));

compositeDisposable.add(disposable);

// 在 onDestroy 时调用
compositeDisposable.dispose();

更多详情,参照相关问题

5.3. 服务始终保持运行

本来可以直接用普通的 Service 来搞定,但是 Service 会不定时被系统回收,且国内安卓市场混乱,很容易被进制后台,所以这里采用前台服务 ForegroundService 来保持服务始终运行。

要注意的是,前台服务是在服务运行过程中启动的,而非启动服务的时候;另外前台服务运行中,通知栏会有无法关闭的通知来告知用户服务仍然在运行。

声明服务:

<service android:name=".PhoneStateReceiverService" android:exported="false" />

声明权限:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

注意这个权限必须运行时请求,同上述 android.permission.READ_PHONE_STATE 权限。

服务实现细节:

private val TAG = "TVPause." + PhoneStateReceiverService::class.java.simpleName

class PhoneStateReceiverService : Service() {
companion object {
private const val CHANNEL_ID = "cn.andiedie.TVPause.PhoneStateReceiverService.CHANNEL"
private const val NOTIFICATION_ID = 19210
}
override fun onCreate() {
super.onCreate()
// Android O 开始需要管理通知的频道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = getText(R.string.channel_name)
val description = getText(R.string.channel_description).toString()
val importance = NotificationManager.IMPORTANCE_DEFAULT
val mChannel = NotificationChannel(CHANNEL_ID, name, importance)
mChannel.description = description
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(mChannel)
}
Log.d(TAG, "PhoneStateReceiverService create")
}

private fun register() {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
val notification = builder.setContentTitle(getText(R.string.notification_title))
.setContentText(getText(R.string.notification_message))
.setSmallIcon(R.mipmap.ic_launcher)
.build()
startForeground(NOTIFICATION_ID, notification)
// 从这开始 服务就保持一直运行
// 将需要长时间运行的服务,例如电话状态接收函数放在这里注册
}

private fun unregister() {
unregisterReceiver(phoneStateReceiver)
}

override fun onBind(intent: Intent?): IBinder? { return null }
}

5.4. Android P 明文传输错误

Android 9.0 (API level 28) 开始默认禁止明文传输,因此 HTTP 会出错。

需要手动开启:

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application
...
android:networkSecurityConfig="@xml/network_security_config"
...>
...
</application>
</manifest>

更多详情,参照相关问题

5.5. 关于屏幕旋转

Android 在屏幕旋转的过程中,默认会销毁当前 Activity,然后重新渲染一个新的 Activity。可以通过配置修改这个表现。

保持竖屏:

<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application ...>
<activity
android:screenOrientation="portrait"
>
</activity>
</application>
</manifest>

更多详情,参考这个链接

image-20190211184923522

请无视电视剧内容和手机来电头像🤠。

7. 下一步

下一步可以考虑在电视装一个服务端,这样就可以非常详细地获得当前电视的运行情况,各种连接、操作也可以自定义搞定。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK