使用Compose Desktop开发一款适用于安卓开发的桌面工具

前言

由于政策的改动,现在的App必须要经过备案才能上架应用商店,备案需要获取签名的md5modules ,刚开始都是在使用jadx这款工具来获取,后来在使用中发现,他会先把apk解析出来,当我点击Apk signature时才开始签名的校验,步骤过于繁琐,并且解析apk还需要时间。后来就想着能不能自己做一款桌面端工具出来,将我想要的功能都集成进去呢。

说干就干,由于本人是Android开发,寻找解决方案时发现了compose-multiplatform,由于工作繁忙,没有学习过compose,但是对compose又非常感兴趣,就想着借着这次机会好好的学一学,于是,AndroidToolKit就诞生了。

功能一览

AndroidToolKit是支持windows和mac的,并且支持深色和浅色模式,下面的截图都是在浅色模式下。

签名信息

该工具的主功能,也是本人最常用的功能之一。

上传APK文件后使用ApkVerifier进行签名校验,并拿到X509Certificate,从中获取到modules、md5、sha-1、sha-256等信息。

当然,图中可以看到是支持上传签名文件的,使用KeyStore获取签名的证书,并将获取到的证书转成X509Certificate类型,后续的信息获取就与上面一致了(当上传签名文件时是需要输入签名密码的)。

APK信息

使用aapt工具解析apk的AndroidManifest.xml文件,提取部分信息,这个没什么好说的,网上一大堆教程。支持自定义aapt,内置的也有,可以直接用。命令如下:

cmd 复制代码
aapt dump badging 文件路径

APK签名

顾名思义,对单个APK进行签名,使用的是ApkSigner,与ApkVerifier在同一个包中。大概用法如下:

kotlin 复制代码
val signerBuild = ApkSigner.Builder()
val apkSigner = signerBuild
                ...
                .build()
apkSigner.sign() // 开始签名

签名生成

目前的最后一个功能(后续还会继续更新,增加新功能)。使用keytool工具生成签名,用的也是命令的方式,支持自定义keytool,支持选择目标密钥类型。这个大家应该都很熟悉,具体命令如下

cmd 复制代码
keytool -genkeypair -keyalg RSA
		-keystore 输出签名路径
		-storepass 密钥密码
		-alias 密钥别名
		-keypass 别名密码(当指定目标密钥类型为PKCS12时,-keypass的值会被忽略,别名密码将与-storepass保持一致)
		-validity 有效期,单位:天
		-dname CN=?,OU=?,O=?,L=?,S=?, C=? 依次对应作者名称、组织单位、组织、城市、省份、国家编码
		-deststoretype 目标密钥类型(JKS/PKCS12)
		-keysize 密钥大小(1024/2048)

开发

下面说一说开发过程吧,因为边做边学的缘故,进度很慢,做了好几个月。参考的大部分文档是compose-multiplatformcompsoe

文件拖拽

本应用是支持文件拖拽的,就不演示,大家懂得都懂。使用的是官方的API,具体代码如下:

kotlin 复制代码
    var isDragging by remember { mutableStateOf(false) }
    Box(
        modifier = modifier.padding(6.dp).onExternalDrag(
            onDragStart = { isDragging = true },
            onDragExit = { isDragging = false },
            onDrop = { state ->
                val dragData = state.dragData
                if (dragData is DragData.FilesList) {
                    dragData.readFiles().first().let {
                        if (it.endsWith(".apk")) {
                            val path = File(URI.create(it)).path
                            // 逻辑处理
                        } else if (it.endsWith(".jks") || it.endsWith(".keystore")) {
                            val path = File(URI.create(it)).path
                            // 逻辑处理
                        } else {
                        }
                    }
                }
                isDragging = false
            }),
        contentAlignment = Alignment.TopCenter
    )

可以用isDragging标识判断当前有没有选中文件拖拽到窗口的正上方,来做一些UI的调整。onExternalDrag目前是在实验期。

文件选择

具体方法如下:

kotlin 复制代码
/**
 * 显示文件选择器
 * @param isApk 是APK还是签名
 * @param isAll 可选APK或签名
 * @param onFileSelected 选择回调
 */
fun showFileSelector(
    isApk: Boolean = true,
    isAll: Boolean = false,
    onFileSelected: (String) -> Unit
) {
    val fileDialog = FileDialog(ComposeWindow())
    fileDialog.isMultipleMode = false
    fileDialog.setFilenameFilter { file, name ->
        val sourceFile = File(file, name)
        sourceFile.isFile && if (isAll) {
            sourceFile.name.endsWith(".apk") || sourceFile.name.endsWith(".keystore") || sourceFile.name.endsWith(".jks")
        } else {
            if (isApk) sourceFile.name.endsWith(".apk") else (sourceFile.name.endsWith(".keystore") || sourceFile.name.endsWith(".jks"))
        }
    }
    fileDialog.isVisible = true
    val directory = fileDialog.directory
    val file = fileDialog.file
    if (directory != null && file != null) {
        onFileSelected("$directory$file")
    }
}

至于文件夹选择,FileDialog是不支持的,但是在mac端可以通过apple.awt.fileDialogForDirectories来使FileDialog选择文件夹。用法如下:

kotlin 复制代码
/**
 * 显示文件夹选择器
 * @param onFolderSelected 选择回调
 */
fun showFolderSelector(
    onFolderSelected: (String) -> Unit
) {
    System.setProperty("apple.awt.fileDialogForDirectories", "true")
    val fileDialog = FileDialog(ComposeWindow())
    fileDialog.isMultipleMode = false
    fileDialog.isVisible = true
    val directory = fileDialog.directory
    val file = fileDialog.file
    if (directory != null && file != null) {
        onFolderSelected("$directory$file")
    }
    System.setProperty("apple.awt.fileDialogForDirectories", "false")
}

compose-multiplatform-file-picker就不多说了,大家可以看他自己的文档,里面说的都很详细。

数据库

使用sqldelight方案对数据进行保存,他是支持Android、Native、JVM、JS等客户端的,选择他的原因也是在官方示例demo内看到大部分项目都是用的此方案,具体使用下来还是很方便的。使用方法:

引入依赖

kotlin 复制代码
plugins {
  id("app.cash.sqldelight") version "2.0.1"
}

repositories {
  google()
  mavenCentral()
}

sqldelight {
    databases {
        create("ToolsKitDatabase") {
            packageName.set("kit")
        }
    }
}

kotlin {
    jvm("desktop")

    sourceSets {
        val desktopMain by getting

        commonMain.dependencies {
            ...
            implementation(libs.sqlDelight.coroutine)
            implementation(libs.sqlDelight.runtime)
            implementation(libs.slf4j.api)
            implementation(libs.slf4j.simple)
        }
        desktopMain.dependencies {
            ...
            implementation(libs.sqlDelight.driver)
        }
    }
}

创建sq文件

目录:commonMain/sqldelight/kit/Config.sq

sqlite 复制代码
import kotlin.Boolean;

CREATE TABLE IF NOT EXISTS Config (
   id INTEGER NOT NULL PRIMARY KEY,
   dark_mode INTEGER NOT NULL,
   aapt_path TEXT NOT NULL,
   flag_delete INTEGER AS Boolean NOT NULL,
   signer_suffix TEXT NOT NULL,
   output_path TEXT NOT NULL,
   is_align_file_size INTEGER AS Boolean NOT NULL,
   keytool_path TEXT NOT NULL DEFAULT '',
   dest_store_type TEXT NOT NULL DEFAULT 'JKS'
);

INSERT INTO Config(id, dark_mode, aapt_path, flag_delete, signer_suffix, output_path, is_align_file_size)
SELECT 0, 0, "", 1, "_sign", "", 1
WHERE (SELECT COUNT(*) FROM Config WHERE id = 0) = 0;

initInternal:
UPDATE Config
SET aapt_path = CASE WHEN aapt_path = '' THEN ? ELSE aapt_path END
WHERE id = 0;

...

实例化驱动程序

kotlin 复制代码
actual fun createDriver(): SqlDriver {
    val dbFile = getDatabaseFile()
    return JdbcSqliteDriver(
        url = "jdbc:sqlite:${dbFile.absolutePath}",
        properties = Properties(),
        schema = ToolsKitDatabase.Schema,
        migrateEmptySchema = dbFile.exists(),
    ).also {
        ToolsKitDatabase.Schema.create(it)
    }
}

方法调用

通过dbQuery就可以调用到sq文件中命名的方法,还是很方便的。

kotlin 复制代码
    private val database = createDatabase(createDriver())

    private val dbQuery = database.configQueries

    internal fun initInternal(aapt: String) {
        dbQuery.initInternal(aapt)
    }

迁移

上面的sq文件,Config表中有两个字段keytool_pathdest_store_type为后续升级数据后添加的。具体升级方法官方文档中说明的也很详细。

创建commonMain/sqldelight/migrations/1.sqm 文件,在1.sqm中增加迁移语句

sqlite 复制代码
ALTER TABLE Config ADD COLUMN keytool_path TEXT NOT NULL DEFAULT '';
ALTER TABLE Config ADD COLUMN dest_store_type TEXT NOT NULL DEFAULT 'JKS';

Json动画

本来打算使用lottie来实现的,后来发现并不支持多端,后来在官方的Issues中发现可以使用skiko来加载动画。具体用法如下:

引入依赖

kotlin 复制代码
val osName: String = System.getProperty("os.name")
val targetOs = when {
    osName == "Mac OS X" -> "macos"
    osName.startsWith("Win") -> "windows"
    osName.startsWith("Linux") -> "linux"
    else -> error("Unsupported OS: $osName")
}

var targetArch = when (val osArch = System.getProperty("os.arch")) {
    "x86_64", "amd64" -> "x64"
    "aarch64" -> "arm64"
    else -> error("Unsupported arch: $osArch")
}

val target = "${targetOs}-${targetArch}"


kotlin {
    sourceSets {
      	...
        desktopMain.dependencies {
          	...
            implementation("org.jetbrains.skiko:skiko-awt-runtime-$target:0.7.9")
        }
    }
}

使用

kotlin 复制代码
@OptIn(ExperimentalResourceApi::class)
@Composable
fun LottieAnimation(scope: CoroutineScope, path: String, modifier: Modifier = Modifier) {
    var animation by remember { mutableStateOf<Animation?>(null) }
    scope.launch {
        val json = Res.readBytes(path).decodeToString()
        animation = Animation.makeFromString(json)
    }
    animation?.let { InfiniteAnimation(it, modifier.fillMaxSize()) }
}

@Composable
private fun InfiniteAnimation(animation: Animation, modifier: Modifier) {
    val infiniteTransition = rememberInfiniteTransition()
    val time by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = animation.duration,
        animationSpec = infiniteRepeatable(
            animation = tween((animation.duration * 1000).roundToInt(), easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        )
    )
    val invalidationController = remember { InvalidationController() }
    animation.seekFrameTime(time, invalidationController)
    Canvas(modifier) {
        drawIntoCanvas {
            animation.render(
                canvas = it.nativeCanvas,
                dst = Rect.makeWH(size.width, size.height)
            )
        }
    }
}

调用

kotlin 复制代码
LottieAnimation(scope, "files/lottie_main_1.json", modifier)

打包

最后说一下打包吧,用的是github的action实现的,通过./gradlew packageReleaseDistributionForCurrentOS命令就可以将当前环境的release包打出来。部分配置如下:

kotlin 复制代码
val kitVersion by extra("1.3.0")
val kitPackageName = "AndroidToolsKit"
val kitDescription = "Desktop tools for Android development, supports Windows and Mac"
val kitCopyright = "Copyright (c) 2024 LazyIonEs"
val kitVendor = "LazyIonEs"
val kitLicenseFile = project.rootProject.file("LICENSE")

compose.desktop {
    application {
        mainClass = "MainKt"
        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = kitPackageName
            packageVersion = kitVersion
            description = kitDescription
            copyright = kitCopyright
            vendor = kitVendor
            licenseFile.set(kitLicenseFile)

            modules("jdk.unsupported", "java.sql")

            outputBaseDir.set(project.layout.projectDirectory.dir("output"))
            appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))

            linux {
                debPackageVersion = packageVersion
                rpmPackageVersion = packageVersion
                iconFile.set(project.file("launcher/icon.png"))
            }
            macOS {
                dmgPackageVersion = packageVersion
                pkgPackageVersion = packageVersion

                packageBuildVersion = packageVersion
                dmgPackageBuildVersion = packageVersion
                pkgPackageBuildVersion = packageVersion
                bundleID = "org.apk.tools"

                dockName = kitPackageName
                iconFile.set(project.file("launcher/icon.icns"))
            }
            windows {
                msiPackageVersion = packageVersion
                exePackageVersion = packageVersion
                menuGroup = packageName
                perUserInstall = true
                shortcut = true
                upgradeUuid = "2B0C6D0B-BEB7-4E64-807E-BEE0F91C7B04"
                iconFile.set(project.file("launcher/icon.ico"))
            }
        }
        buildTypes.release.proguard {
            obfuscate.set(true)
            configurationFiles.from(project.file("compose-desktop.pro"))
        }
    }
}

配置什么的,参考了从 0 到 1 搞一个 Compose Desktop 版本的天气应用(附源码),感兴趣的可以去看一下。

总结

说实话,第一次使用compose,给了我很多惊喜,当然,对于multiplatform来说,compose-multiplatform现在还并不算完善,但是官方解决问题的速度很快,并且会给到解决方案等,希望compose-multiplatform越来越好。

源码地址

AndroidToolsKit

releases中提供了安装文件,欢迎体验支持

参考:

compose-multiplatform

从 0 到 1 搞一个 Compose Desktop 版本的天气应用(附源码)

使用ComposeDesktop开发一款桌面端多功能APK工具

Compose for Desktop桌面端简单的APK工具

相关推荐
氤氲息27 分钟前
Android 底部tab,使用recycleview实现
android
tmacfrank39 分钟前
Coroutine 基础二 —— 结构化并发(一)
kotlin
Clockwiseee1 小时前
PHP之伪协议
android·开发语言·php
小林爱1 小时前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
小何开发2 小时前
Android Studio 安装教程
android·ide·android studio
开发者阿伟2 小时前
Android Jetpack LiveData源码解析
android·android jetpack
weixin_438150993 小时前
广州大彩串口屏安卓/linux触摸屏四路CVBS输入实现同时显示!
android·单片机
CheungChunChiu3 小时前
Android10 rk3399 以太网接入流程分析
android·framework·以太网·eth·net·netd
木头没有瓜4 小时前
ruoyi 请求参数类型不匹配,参数[giftId]要求类型为:‘java.lang.Long‘,但输入值为:‘orderGiftUnionList
android·java·okhttp
键盘侠0074 小时前
springboot 上传图片 转存成webp
android·spring boot·okhttp