前言 由于政策的改动,现在的App必须要经过备案才能上架应用商店,备案需要获取签名的md5 和modules ,刚开始都是在使用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
,内置的也有,可以直接用。命令如下:
APK签名 顾名思义,对单个APK进行签名,使用的是ApkSigner
,与ApkVerifier
在同一个包中。大概用法如下:
1 2 3 4 5 val signerBuild = ApkSigner.Builder()val apkSigner = signerBuild ... .build() apkSigner.sign()
签名生成 目前的最后一个功能(后续还会继续更新,增加新功能)。使用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 )
开发 下面说一说开发过程吧,因为边做边学的缘故,进度很慢,做了好几个月。参考的大部分文档是compose-multiplatform 和compsoe 。
文件拖拽 本应用是支持文件拖拽的,就不演示,大家懂得都懂。使用的是官方的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 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 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.configQueriesinternal fun initInternal (aapt: String ) { dbQuery.initInternal(aapt) }
迁移 上面的sq文件,Config表中有两个字段keytool_path
和dest_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工具