【效率提升】🍺快速安装APK的两个 "骚操作"

5,939 阅读12分钟

前阵子在思考如何提高开发效率,以获得更多的摸鱼时间,在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~