前阵子在思考如何提高开发效率,以获得更多的摸鱼时间,在App提测阶段,发现了这样一个现象:
Jenkins打包测试APK,上传到Fir,往钉钉群丢一个下载地址。测试崽看到消息后,点开下载地址,把APK安装到测试机上,有两种安装方式:
- ① 电脑直接下载,然后再想办法安装到手机上;
- ② 手机扫码下载,然后在手机上完成安装;
众所周知,一个测试崽一般都会随身配备多台手机,所以每次发新包,他们都要在覆盖安装APK上,浪费不少的时间。
不得不夸测试崽有耐心啊,孜孜不倦做了这么久这种单调重复的安装工作,也没半点怨言。乐(xi)于(huan)助(zhuang)人(bi) 的杰哥知道了,肯定是要出手相助的,整点活,帮他们也提高下工作效率,毕竟 独摸鱼,不如众摸鱼!
思路的话,分别从 电脑安装 和 手机安装 两个角度入手,直接开搞~
0x1、电脑快速安装
拆解电脑安装APK的流程:浏览器打开下载URL → 点击Download → 通过下述方式的一种安装到手机上:
- 手机登录微信、QQ或其它带文件传送的APP,发送APK到手机上安装。
- 手机接电脑,打开 文件传输,把文件拷贝到手机上,再在手机上点安装。
- 手机接电脑,电脑安装XX手机助手,右键APK直接安装。
上述是普通人的安装方法,安卓崽有自己独特的安装方式 → 使用adb命令来安装:
adb install -r xxx.apk
使用adb命令前要开启 USB调试模式,开启方式如下(部分机型可能有差异,可善用搜索引擎):
- 打开手机设置;
- 找到关于手机中的版本号;
- 连续点击5次,进入开发者模式;
- 找到 开发者选项,打开USB调试开关即可;
安卓崽一般都会配Android SDK的环境变量,就不需要另外整个ADB了,没有的童鞋可以到官网下个:platform-tools工具包,解压后可以看到adb.exe等文件:
接着自己配个环境变量,可以键入:adb --version 来验证是否配置成功。
准备就绪,开始整脚本~
① 写一个拖拽安装APK的bat脚本
这里没啥难度,直接给出可用脚本代码,注释写的很详尽,应该能看懂,看不懂的部分可以评论区留言~
:: 让当前批处理窗口支持UTF-8,就是避免中文乱码
chcp 65001
:: 从下一行开始关闭回显
@echo off
:: 1、拖拽的apk路径
set apk_path=%1
:: 2、判断APK路径是否有传入,比如用户直接运行脚本就不会传这个参数
:check_apk
if not exist "%apk_path%" (
echo "APK文件不存在,请把APK文件拖拽到这个脚本上!"
goto :error
)
:: 3、检查adb命令是否可用,2>&1 代表将stderr重定向到stdout(标准输出流)
:check_adb
adb --version >nul 2>&1
:: 如果命令执行成功会返回0,neq代表:不等于
if %ERRORLEVEL% neq 0 (
echo "adb.exe不存在,请先添加此文件到该目录下,或者配置PATH环境变量!"
goto :error
)
:: 4、安装APK
:install
echo "安装 %apk_path%..."
adb install -r "%apk_path%"
if %ERRORLEVEL% neq 0 (
echo "安装失败..."
goto :error
)
echo "安装成功,你可以在安卓设备上打开此APP啦~"
:error
pause
脚本使用方法:(把apk直接拖到bat脚本上等待安装成功即可~)
② 写一个自动下载APK的bat脚本
因为没有fir的账号,就没去看有是否有提供下载API了,直接看接口请求规则,捣鼓出APK的下载URL。F12直接抓包:
触发APK下载前会调这个接口,302重定向,然后响应头 Location 指向的就是APK的下载地址:
再往前走,看哪里可以凑齐这些参数,可以看到掉这个接口可以获得对应的参数:
http://download.appmeta.cn/短链?referer=下载域名
接着打两个包,看下载url的区别:
http://download.appmeta.cn/apps/xxx/install?short=xxx&download_token=xxx&release_id=6478xxx
http://download.appmeta.cn/apps/xxx/install?short=xxx&download_token=xxx&release_id=6476xxx
不难看出只有release_id是变化的,其它都是写死的,那要做的事情其实就两步:
- 访问下载参数的URL,然后解析json提取release_id;
- 拼接下载url,访问并下载APK
这个解析提取release_id可难倒我了,因为BAT脚本中,原生并不支持使用正则表达式进行字符串提取。
试了好一会儿的findstr正则匹配和for循环,都没有把它给抠出来...
遇事不决ChatGPT,在它的建议下,尝试借助 VBScript来完成子串的提取:
然后就遇到 json里包含双引号,导致切割成多个参数的问题,运行一直报错:Microsoft VBScript 编译器错误: 缺少语句
调了N久也没解决,索性直接把双引号干掉算了:
成功把release_id给抠出来了,接着拼接下载url又出问题了,因为url里有 &
,这玩意是 特殊字符,需要加上一个 ^ 转义字符修饰,然后又因为我在前面设置 setlocal enabledelayedexpansion 开启了变量延迟,所以字符串拼接变成这样:
然后就是 curl 请求这个download_url,抠出请求头 Location 指向的 apk的真实下载地址 了:
最后使用 call 命令调用下前面写的安装APK的脚本,传入apk文件名,然后做一些文件清理工作~
运行看下实际效果:
一键完成下载安装,简直不要太爽,此处可以有掌声~
另外,如果需要同时安装到多个手机上的需求,可以改下安装部分的脚本,adb devices 获得连接设备的序列号,然后循环调用adb install 就好啦。顺带给出脚本文件,大家可根据自己的实际情况改着玩:
chcp 65001
@echo off
:: 开启变量延迟
setlocal enabledelayedexpansion
:: 下载配置文件
set config_file=response.json
:: VBScript临时文件
set vb_script=vbscript.vbs
:: 提取released_id的正则
set regex=master:\{id:(\w+),
:: apk文件名
set apk_file=test.apk
:: 1、获得请求配置信息
set config_url=http://download.appmeta.cn/短链?referer=下载域名
curl %config_url% > %config_file%
::2、读取json内容
set "content="
for /f "usebackq delims=" %%i in (%config_file%) do (
set "content=!content!!%%i"
)
:: 将双引号干掉,不然下述传参会有问题
set "content=%content:"=%"
::3、创建VBScript临时文件使用正则提取released_id
echo Set objRegEx = New RegExp > %vb_script%
echo objRegEx.Pattern = "%regex%" >> %vb_script%
echo Set matches = objRegEx.Execute("%content%") >> %vb_script%
echo For Each match in matches >> %vb_script%
echo Wscript.Echo match.SubMatches(0) >> %vb_script%
echo Next >> %vb_script%
:: 运行VBScript并捕获输出结果
for /f "delims=" %%i in ('cscript //nologo %vb_script%') do (
set "release_id=%%i"
)
::4、拼接下载url,这里&是特殊字符,需要在它的前面加上^转义字符
set base_url=http://download.appmeta.cn/apps/xxx/install?short=xxx^&download_token=xxx^&release_id=
set download_url=!base_url!!release_id!
echo !download_url!
::5、请求下载地址,提取请求头Location中的下载地址
for /f "delims=" %%a in ('curl -I "!download_url!" ^| find "Location"') do set location=%%a
::
echo !location:~10!
::6、保存apk文件,这里~10用于截断前面的Location:得到正确的下载地址
curl !location:~10! -o %apk_file%
::7、下载完毕,调用安装脚本
call adb_install.bat %apk_file%
::8、清理产生的文件
echo 安装完毕,清理生成的垃圾文件...
echo %config_file%
del %config_file%
del %vb_script%
del %apk_file%
endlocal
Tips:Bat脚本最大的优点就是不需要另外安装环境,windows直接执行,缺点就是有些语法枯燥难懂,写起来比较繁琐,远不如Python这类脚本语言写起来舒服。比如这里的代码,我折腾了将近一天,而且还是在ChatGPT的加持下,如果换成Python,10分钟不用就给你肝出来了~
0x2、手机快速安装
虽然上面提供了一键下载安装的脚本,但对于普通人来说,并不算友好,得:下载adb配置环境变量 + 手机开启USB调试,而且手机还得接着数据线。帮人帮到底,接着看下 手机安装 这个角度怎么搞。
拆解手机安装APK的流程:手机浏览器扫码 → 点击Download按钮下载 → 等待下载完成点击APK安装。
优化思路:
- 1、浏览器扫码完全没必要,因为其实都是指向固定的URL,直接写死就好
- 2、能否实现 自动点击Download按钮 下载APK?
- 3、能否 监听APK下载进度,下载完成 自动触发安装?
2333,完全可以写一个 内置WebView浏览器的APP 来实现上面的操作啊,先搭个简单的UI:
非常简洁,按需选择环境,点击下载安装,可以看到包的版本信息,然后开始自动下载。
WebView加载URL就不用说了,调下 loadUrl() 就完事了,难点是怎么提取页面上的版本信息,以及点击Download按钮~
① 获取页面版本信息&点击Download按钮
通过 evaluateJavascript() 方法调用下js就好了,电脑打开APK下载地址,F12看下页面源码:
可以通过css选择器快速定位到版本信息相关的两个元素,直接在控制台执行下述代码:
两个span就被抠出来了,而这里只关心它的innerText,转成Array调下map方法生成字符串数组:
可以,拿到需要的版本信息了,在 evaluateJavascript() 的回调里打个断点:
可以看到数据是String类型,而非字符串数组,这里不用处理直接展示就好。
然后是点击Download按钮,也简单,css选择定位到结点,然后调下click()方法就好,直接补全代码~
运行看看效果(顶部也可以看到下载进度~):
② 监听APK下载完成
监听下载完成的话简单,动态注册个下载完成的广播就好:
然后还需要调用WebView的 setDownloadListener()
对下载行为进行一些定制,比如设置APK的下载位置及名称,是否显示下载完成的通知等:
另外,保存到Downloads目录,需要申请下读写权限,否则安装的时候部分手机会提示文件损坏:
运行后再次点击下载,可以监听到下载完成,文件下载到对应的位置,接着就是触发自动安装了~
③ 触发自动安装APK
其实就是发起 安装APK的Intent,顺带把APK的路径也传过去,这里有个 版本适配的小坑:
Android 7.0(API 24) 后强制启用了所谓的StrictMode的策略,我们的APP无法直接对外暴露 file:// 类型的Uri,如果还是通过 Uri.fromFile(file) 方式来暴露的话就会引发 FileUriExposedException。官方提供了 FileProvider,它生成的Uri会以 content:// 的形式分享给其它APP使用,这种形式的Uri可以让其它APP临时获得读取(Read)和写入(Write)权限文件的权限。
这个不是什么新东西了,网上资料一堆,感兴趣的自己搜下,这里直接一笔带过~
1)配置访问路径信息的xml → 在res/xml目录下直接新建xml文件,如:file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- 外部存储空间根目录,等同于 Environment.getExternalStorageDirectory() 所获取的目录路径 -->
<external-path name="apk_file_path" path="." />
</paths>
2)AndroidManifest.xml添加FileProvider → 定义一个authorities,添加一个meta-data指向上面的xml文件:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="cp.provider.authority"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
接着在下载完成广播的onReceive()补全跳转安装APK的代码:
然后,应用内安装APK还需要一个权限 REQUEST_INSTALL_PACKAGES,它是签名权限,不能在应用中请求这个权限,只需在AndroidManifest.xml清单文件中声明。
安装时系统会弹出这样的授权对话框:
点击设置或继续会进入授权页:
授权后,后续就不会弹这个了,下载完直接触发系统安装APK的页面:
同样是 一键完成下载安装,而且不用手机连着电脑,更舒服了,此处可以有掌声~
当然,还需要点一下更新,要连这一步都省去的话,可以再写一个 AccessibilityService 无障碍服务,监听执行系统安装包名,识别到更新、确定等按钮时,触发自动点击,这是偷懒到极致了啊!!!
最后给出完整代码,感兴趣的朋友可以没事的时候自己动手改着玩,感觉也能算个KPI?哈哈哈~
class MainActivity : AppCompatActivity() {
companion object {
private const val TEST_APK_URL = "测试环境APK的下载地址"
private const val RELEASE_APK_URL = "正式环境APK的下载地址"
}
private lateinit var mTypeRg: RadioGroup
private lateinit var mDownloadBt: Button
private lateinit var mContentWv: WebView
private lateinit var mApkInfoTv: TextView
private var mApkUrl = TEST_APK_URL
private var mApkFile: String? = null
private lateinit var dm: DownloadManager
private val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
private val mDownloadCompleteReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
mDownloadBt.text = "下载"
mDownloadBt.isEnabled = true
val installIntent: Intent
val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), mApkFile)
// 兼容
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // 7.0+以上版本
installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
val apkUri = FileProvider.getUriForFile(this@MainActivity, "cp.provider.authority", file)
installIntent.data = apkUri
installIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION // 临时权限
} else {
installIntent = Intent(Intent.ACTION_VIEW)
installIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
installIntent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
}
startActivity(installIntent)
}
}
private val mPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { callback ->
var isGrantAll = true
callback.entries.forEach {
isGrantAll = isGrantAll and it.value
}
if (isGrantAll) {
Toast.makeText(this.applicationContext, "文件读写权限授权成功", Toast.LENGTH_SHORT).show()
mDownloadBt.text = "下载中..."
mDownloadBt.isEnabled = false
mContentWv.loadUrl(mApkUrl)
} else {
Toast.makeText(this.applicationContext, "为了软件正常使用,请给予相关权限", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
registerReceiver(mDownloadCompleteReceiver, filter)
dm = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
mTypeRg = findViewById<RadioGroup?>(R.id.rg_type).apply {
setOnCheckedChangeListener { _, checkedId ->
mApkUrl = if (checkedId == R.id.rb_test) TEST_APK_URL else RELEASE_APK_URL
}
}
mDownloadBt = findViewById<Button?>(R.id.bt_download_install).apply {
setOnClickListener {
mPermissionLauncher.launch(arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE))
}
}
mApkInfoTv = findViewById(R.id.tv_apk_info)
mContentWv = findViewById<WebView?>(R.id.wv_content).apply {
settings.apply {
javaScriptEnabled = true
javaScriptCanOpenWindowsAutomatically = true
allowFileAccess = true
setSupportZoom(true)
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
mContentWv.evaluateJavascript("Array.from(document.querySelectorAll('div.release-info > p > span')).map(element => element.innerText)") {
mApkInfoTv.text = it.replace("\\n", "")
mContentWv.evaluateJavascript("document.querySelector('#actions > button').click()", null)
}
}
}
setDownloadListener { url, _, _, mimetype, _ ->
mApkFile = "${if(mApkUrl == TEST_APK_URL) "Test_" else "Release_"}${SimpleDateFormat("yyyyMMdd_hh_mm_ss").format(Date())}.apk"
dm.enqueue(DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimetype)
setVisibleInDownloadsUi(true) // 显示下载UI
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) // 下载完成后会显示相应的通知
allowScanningByMediaScanner() // 允许被系统扫描到
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, mApkFile) // 设置文件保存路径
})
}
}
}
}
Tips:其实在电脑安装那里我们已经把下载接口给抠出来了,这里可以直接用OkHttp等库来模拟请求下载APK。而笔者还故意用 WebView自动化 的方案来实现下载,是因为很多时候官方不一定会提供API接口,这就需要我们自行逆向API接口,一般都是费时费力的,投入产出比较低,毕竟花那么多时间破解一个接口又能怎样。还不如直接自动化,两三下就搞完,比如适配 蒲公英 只需要修改下js~