前言

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

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

功能一览

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

签名信息

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

screenshot_signature_information

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

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

screenshot_signature_information_2

APK信息

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

1
aapt dump badging 文件路径

screenshot_apk_information_1

APK签名

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

1
2
3
4
5
val signerBuild = ApkSigner.Builder()
val apkSigner = signerBuild
...
.build()
apkSigner.sign() // 开始签名

screenshot_apk_signature_1

签名生成

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

1
2
3
4
5
6
7
8
9
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

screenshot_signature_generation

开发

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

文件拖拽

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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目前是在实验期。

文件选择

具体方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 显示文件选择器
* @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选择文件夹。用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 显示文件夹选择器
* @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内看到大部分项目都是用的此方案,具体使用下来还是很方便的。使用方法:

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;

...

实例化驱动程序

1
2
3
4
5
6
7
8
9
10
11
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)
}
}

方法调用

1
2
3
4
5
6
7
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中增加迁移语句

1
2
ALTER TABLE Config ADD COLUMN keytool_path TEXT NOT NULL DEFAULT '';
ALTER TABLE Config ADD COLUMN dest_store_type TEXT NOT NULL DEFAULT 'JKS';

lottie动画

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

引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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")
}
}
}

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@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)
)
}
}
}

调用

1
LottieAnimation(scope, "files/lottie_main_1.json", modifier)

打包

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
val kitVersion by extra("1.3.0")
val kitPackageName = "AndroidToolKit"
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越来越好。

源码地址

AndroidToolKit

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

参考:

compose-multiplatform

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

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

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