Gradle自动化之自动打包并上传到fir测试网站

15 篇文章 0 订阅
8 篇文章 0 订阅

前言

每个项目都需要测试,没有测试的项目是无法发布到线上的

而由于安卓的碎片化,公司里测试需要测几种不同版本的系统和不同厂商(型号)的手机,所以我平时发的测试包必须放到某个服务器或网站上,通过二维码的方式给测试,这样才能让测试流程更方便

之前的流程都是,先打包,然后将包上传到fir测试网站上,然后将二维码发给测试们,感觉很麻烦,还是写一个自动化的插件比较好,而Android开发的管理工具Gradle正好也是支持自动化的,所以就用Gradle来做一个自动打包上传测试的功能

正文

首先我们创建一个Task任务,这个Task任务就是自动打包上传测试的任务,可以在app或者跟目录的build.gradle(.kts)文件中加入task

Groovy语言:

task firDebug(type: Task) {
}

Kotlin(kts)语言:现在Gradle也支持用Kotlin脚本(kts)来写了,我不太会Groovy语法,所以就直接用的kts写的2333

tasks.register<Task>("firDebug") {
}

然后在根项目下创建buildSrc目录,来用Kotlin编写脚本代码,参考:快速迁移 Gradle 脚本至 KTS  和  如何为 Gradle 的 KTS 脚本添加扩展?  (感谢Benny Huo老师的文章)

目录长这个样子

在build.gradle.kts中加入如下代码:

plugins {
    `kotlin-dsl`//可以使用kotlin写Gradle
}

repositories {
    maven("https://mirrors.tencent.com/nexus/repository/maven-public/")
}

dependencies {
    implementation("com.google.code.gson:gson:2.8.6")//这几个是一会需要用到的库
    implementation("net.dongliu:apk-parser:2.6.9")
    implementation("dom4j:dom4j:1.6.1")
    implementation("com.squareup.okio:okio:2.10.0")
    implementation("javax.activation:activation:1.1.1")//ps:jdk11后需要手动引用
}

然后直接在kotlin目录下写相应的代码,先写一个入口代码,文件:Fir.kt

/**
 * 打包并上传到测试平台
 * [module]表示那个module文件夹,比如app
 * [channel]表示打哪个渠道,比如google
 * [type]表示打什么版本的包(正式,debug),比如debug
 */
fun Task.uploadToFir(module: String, channel: String, type: String) {
}

然后修改task任务:

Groovy

task firDebug(type: Task) {
    FirKt.uploadToFir(it,"app",你的渠道,"debug")//用java的方式调用kt的扩展函数
}

Kotlin(kts)

tasks.register<Task>("firDebug") {//这里的this就是task,所以下面不需要显式声明receiver
    uploadToFir("app", 你的渠道, "debug")
}

然后填充uploadToFir方法,上面写的有注释:

/**
 * 打包并上传到测试平台
 * [module]表示那个module文件夹,比如app
 * [channel]表示打哪个渠道,比如google
 * [type]表示打什么版本的包(正式,debug),比如debug
 */
fun Task.uploadToFir(module: String, channel: String, type: String) {
    // TODO by lt 修改fir的api_token
    val firApiToken = ""
    val path =
        ":$module:assemble${channel[0].toUpperCase()}${channel.substring(1)}${type[0].toUpperCase()}${
            type.substring(1)
        }"//拼一下需要执行的打包的代码,类似这样子:app:assembleGoogleDebug
    "fir.uploadToFir: start assemble. path=$path".println()
    //执行打包
    dependsOn(path)
    doLast {//在该任务其他代码执行完毕后,在执行该lambda中的代码
        //获取apk包
        val inputFile = getApkFile(module, channel, type)//去指定目录下找到刚才打出来的apk包
        "fir.uploadToFir: get apk file success. file=${inputFile.absolutePath}".println()
        val apkInfo =
            parseApkFile(inputFile.absolutePath) ?: throw FileNotFoundException("找不到打出来的apk包")//获取到apk的信息
        "fir.uploadToFir: get apk info=${apkInfo.toJson()}".println()
        //下面的两个网络请求是fir网站上提供的api
        val tokenResult = post(
            "http://api.bq04.com/apps", mapOf(
                "type" to "android",
                "bundle_id" to apkInfo.packageName!!,
                "api_token" to firApiToken
            ), null, "application/octet-stream"
        )//post请求的方法(从网上找的一个java原生网络请求)
        val tokenBean = tokenResult.jsonToAny<FirTokenBean>()
        "fir.uploadToFir: get token success. result=$tokenResult".println()
        val binary =
            tokenBean?.cert?.binary ?: throw RuntimeException("网络数据有误:${tokenBean.toJson()}")
        post(
            binary.upload_url!!,
            mapOf(
                "key" to binary.key!!,
                "token" to binary.token!!,
                "x:name" to apkInfo.appName!!,
                "x:version" to apkInfo.versionName!!,
                "x:build" to apkInfo.versionCode.toString()
            ),
            mapOf("file" to inputFile.absolutePath),
            "application/octet-stream"
        ).println()
    }
}

ps:最后会放出完整代码

其实很简单,就是执行一下打包命令,然后找到打出来的apk包,并获取apk包的信息,最后通过接口将apk上传到fir网站上,然后就ok了

我这里省略了将二维码或者网址发给测试的功能,如果需要的话可以建一个钉钉群,将测试拉进去,然后使用钉钉的机器人功能直接每次打完包将二维码发到群里就好了

完整代码如下: 

/**
 * 打包并上传到测试平台
 * [module]表示那个module文件夹,比如app
 * [channel]表示打哪个渠道,比如google
 * [type]表示打什么版本的包(正式,debug),比如debug
 */
fun Task.uploadToFir(module: String, channel: String, type: String) {
    // TODO by lt 修改fir的api_token
    val firApiToken = ""
    val path =
        ":$module:assemble${channel[0].toUpperCase()}${channel.substring(1)}${type[0].toUpperCase()}${
            type.substring(1)
        }"//拼一下需要执行的打包的代码,类似这样子:app:assembleGoogleDebug
    "fir.uploadToFir: start assemble. path=$path".println()
    //执行打包
    dependsOn(path)
    doLast {//在该任务其他代码执行完毕后,在执行该lambda中的代码
        //获取apk包
        val inputFile = getApkFile(module, channel, type)//去指定目录下找到刚才打出来的apk包
        "fir.uploadToFir: get apk file success. file=${inputFile.absolutePath}".println()
        val apkInfo =
            parseApkFile(inputFile.absolutePath) ?: throw FileNotFoundException("找不到打出来的apk包")//获取到apk的信息
        "fir.uploadToFir: get apk info=${apkInfo.toJson()}".println()
        //下面的两个网络请求是fir网站上提供的api
        val tokenResult = post(
            "http://api.bq04.com/apps", mapOf(
                "type" to "android",
                "bundle_id" to apkInfo.packageName!!,
                "api_token" to firApiToken
            ), null, "application/octet-stream"
        )//post请求的方法(从网上找的一个java原生网络请求)
        val tokenBean = tokenResult.jsonToAny<FirTokenBean>()
        "fir.uploadToFir: get token success. result=$tokenResult".println()
        val binary =
            tokenBean?.cert?.binary ?: throw RuntimeException("网络数据有误:${tokenBean.toJson()}")
        post(
            binary.upload_url!!,
            mapOf(
                "key" to binary.key!!,
                "token" to binary.token!!,
                "x:name" to apkInfo.appName!!,
                "x:version" to apkInfo.versionName!!,
                "x:build" to apkInfo.versionCode.toString()
            ),
            mapOf("file" to inputFile.absolutePath),
            "application/octet-stream"
        ).println()
    }
}

/**
 * 获取apk文件中的一些数据
 */
fun parseApkFile(path: String): UpdateInfo? {
    try {
        ApkFile(path).use { file ->
            val updateInfo = UpdateInfo()
            val meta = file.apkMeta
            updateInfo.androidManifest = file.manifestXml
            updateInfo.versionName = meta.versionName
            updateInfo.versionCode = meta.versionCode
            updateInfo.packageName = meta.packageName
            updateInfo.appName = meta.name
            updateInfo.channel = getChannelName(file.manifestXml)
            updateInfo.path = path
            return updateInfo
        }
    } catch (e: Exception) {
        return null
    }
}

/**
 * 根据渠道,type,module取到包
 */
fun getApkFile(module: String, channel: String, type: String): File =
    File("$module/build/outputs/apk/$channel/$type")
        .listFiles()!!
        .toList()
        .filter { it.name.endsWith(".apk") }
        .sortedWith { s1, s2 ->
            if (checkVersion(s1.name, s2.name)) 1 else -1
        }.last()

data class UpdateInfo(
    var androidManifest: String? = null,
    var versionName: String? = null,
    var versionCode: Long = 0,
    var channel: String? = null,
    var packageName: String? = null,
    var appName: String? = null,
    var path: String? = null
)

private fun getManifestMetaData(xml: String): List<Pair<String?, String?>> {
    val datas: MutableList<Pair<String?, String?>> = ArrayList()
    val reader = SAXReader()
    try {
        val document = reader.read(ByteArrayInputStream(xml.toByteArray(StandardCharsets.UTF_8)))
        val rootElement = document.rootElement
        val iterator = rootElement.elementIterator("application")
        while (iterator.hasNext()) {
            val next = iterator.next() as Element
            val temp = next.elementIterator("meta-data")
            while (temp.hasNext()) {
                val meta = temp.next() as Element
                val metaName = meta.attributeValue("name")
                val metaValue = meta.attributeValue("value")
                datas.add(Pair<String?, String?>(metaName, metaValue))
            }
        }
    } catch (e: DocumentException) {
        e.printStackTrace()
    }
    return datas
}

private fun getChannelName(xml: String): String? {
    val metaData = getManifestMetaData(xml)
    val umeng_channels: List<Pair<String?, String?>> =
        metaData.stream().filter { (first) -> first == "UMENG_CHANNEL" }
            .collect(Collectors.toList())
    return if (!umeng_channels.isEmpty()) {
        umeng_channels[0].second
    } else null
}

/**
 * 判断两个版本号哪个大
 * @return true 表示前面大或相等
 */
private fun checkVersion(version1: String?, version2: String?): Boolean {
    try {
        if (version1 == version2)
            return true
        version1 ?: return true
        version2 ?: return true
        val split = version1.split(".")
        val split2 = version2.split(".")
        if (split.size > split2.size)
            return true
        else if (split.size < split2.size)
            return false
        for (i in split.indices) {
            if (split[i].toIntOrNull() ?: 0 > split2[i].toIntOrNull() ?: 0)
                return true
            else if (split[i].toIntOrNull() ?: 0 < split2[i].toIntOrNull() ?: 0)
                return false
        }
        return true
    } catch (e: Exception) {
        return true
    }
}

fun Any?.println() = println(this)

fun Any?.toJson(): String? = Gson().toJson(this)

inline fun <reified T> String?.jsonToAny(): T? = Gson().fromJson(this, T::class.java)

data class FirTokenBean(
    val app_user_id: String? = null,
    val cert: Cert? = null,
    val download_domain: String? = null,
    val download_domain_https_ready: Boolean = false,
    val form_method: String? = null,
    val id: String? = null,
    val short: String? = null,
    val storage: String? = null,
    val type: String? = null,
    val user_system_default_download_domain: String? = null
) {

    data class Cert(
        val binary: Binary? = null,
        val icon: Icon? = null,
        val mqc: Mqc? = null,
        val prefix: String? = null,
        val support: String? = null
    )

    data class Binary(
        val custom_headers: CustomHeaders? = null,
        val key: String? = null,
        val token: String? = null,
        val upload_url: String? = null
    )

    data class Icon(
        val custom_callback_data: CustomCallbackData? = null,
        val custom_headers: CustomHeadersX? = null,
        val key: String? = null,
        val token: String? = null,
        val upload_url: String? = null
    )

    data class Mqc(
        val is_mqc_availabled: Boolean = false,
        val total: Int = 0,
        val used: Int = 0
    )

    class CustomHeaders(
    )

    data class CustomCallbackData(
        val original_key: String? = null
    )

    class CustomHeadersX(
    )
}

/**
 * 上传图片
 * @param urlStr
 * @param textMap
 * @param fileMap
 * @param contentType 没有传入文件类型默认采用application/octet-stream
 * contentType非空采用filename匹配默认的图片类型
 * @return 返回response数据
 */
fun post(
    urlStr: String, textMap: Map<String, String>?,
    fileMap: Map<String, String>?, contentType: String?
): String {
    var contentType = contentType
    var res = ""
    var conn: HttpURLConnection? = null
    // boundary就是request头和上传文件内容的分隔符
    val BOUNDARY = "---------------------------123821742118716"
    try {
        val url = URL(urlStr)
        conn = url.openConnection() as HttpURLConnection
        conn.setConnectTimeout(5000)
        conn.setReadTimeout(30000)
        conn.setDoOutput(true)
        conn.setDoInput(true)
        conn.setUseCaches(false)
        conn.setRequestMethod("POST")
        conn.setRequestProperty("Connection", "Keep-Alive")
        // conn.setRequestProperty("User-Agent","Mozilla/5.0 (Windows; U; Windows NT 6.1; zh-CN; rv:1.9.2.6)");
        conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
        val out: OutputStream = DataOutputStream(conn.getOutputStream())
        // text
        if (textMap != null) {
            val strBuf = StringBuffer()
            val iter: Iterator<*> = textMap.entries.iterator()
            while (iter.hasNext()) {
                val entry = iter.next() as Map.Entry<*, *>
                val inputName = entry.key as String
                val inputValue = entry.value as String? ?: continue
                strBuf.append("\r\n").append("--").append(BOUNDARY).append("\r\n")
                strBuf.append("Content-Disposition: form-data; name=\"$inputName\"\r\n\r\n")
                strBuf.append(inputValue)
            }
            out.write(strBuf.toString().toByteArray())
        }
        // file
        if (fileMap != null) {
            val iter: Iterator<*> = fileMap.entries.iterator()
            while (iter.hasNext()) {
                val entry = iter.next() as Map.Entry<*, *>
                val inputName = entry.key as String
                val inputValue = entry.value as String? ?: continue
                val file = File(inputValue)
                val filename: String = file.getName()

                //没有传入文件类型,同时根据文件获取不到类型,默认采用application/octet-stream
                contentType = MimetypesFileTypeMap().getContentType(file)
                //contentType非空采用filename匹配默认的图片类型
                if ("" != contentType) {
                    if (filename.endsWith(".png")) {
                        contentType = "image/png"
                    } else if (filename.endsWith(".jpg") || filename.endsWith(".jpeg") || filename.endsWith(
                            ".jpe"
                        )
                    ) {
                        contentType = "image/jpeg"
                    } else if (filename.endsWith(".gif")) {
                        contentType = "image/gif"
                    } else if (filename.endsWith(".ico")) {
                        contentType = "image/image/x-icon"
                    }
                }
                if (contentType == null || "" == contentType) {
                    contentType = "application/octet-stream"
                }
                val strBuf = StringBuffer()
                strBuf.append("\r\n").append("--").append(BOUNDARY).append("\r\n")
                strBuf.append("Content-Disposition: form-data; name=\"$inputName\"; filename=\"$filename\"\r\n")
                strBuf.append("Content-Type:$contentType\r\n\r\n")
                out.write(strBuf.toString().toByteArray())
                val `in` = DataInputStream(FileInputStream(file))
                var bytes = 0
                val bufferOut = ByteArray(1024)
                while (`in`.read(bufferOut).also { bytes = it } != -1) {
                    out.write(bufferOut, 0, bytes)
                }
                `in`.close()
            }
        }
        val endData = "\r\n--$BOUNDARY--\r\n".toByteArray()
        out.write(endData)
        out.flush()
        out.close()
        // 读取返回数据
        val strBuf = StringBuffer()
        val reader = BufferedReader(InputStreamReader(conn.getInputStream()))
        var line: String? = null
        while (reader.readLine().also { line = it } != null) {
            strBuf.append(line).append("\n")
        }
        res = strBuf.toString()
        reader.close()
    } catch (e: Exception) {
        println("发送POST请求出错。$urlStr")
        e.printStackTrace()
    } finally {
        conn?.disconnect()
    }
    return res
}

end

对Kotlin或KMP感兴趣的同学可以进Q群 101786950

如果这篇文章对您有帮助的话

可以扫码请我喝瓶饮料或咖啡(如果对什么比较感兴趣可以在备注里写出来)

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值