├── composeApp
├── proguard-rules-android.pro
├── src
│ ├── commonMain
│ │ ├── kotlin
│ │ │ ├── platform
│ │ │ │ ├── Download.kt
│ │ │ │ ├── Toast.kt
│ │ │ │ ├── HttpClient.kt
│ │ │ │ ├── Clipboard.kt
│ │ │ │ ├── Preferences.kt
│ │ │ │ └── Crypto.kt
│ │ │ ├── data
│ │ │ │ ├── FileInfoHelper.kt
│ │ │ │ ├── DataHelper.kt
│ │ │ │ ├── RomInfoHelper.kt
│ │ │ │ └── DeviceInfoHelper.kt
│ │ │ ├── utils
│ │ │ │ ├── LinkUtils.kt
│ │ │ │ ├── MessageUtils.kt
│ │ │ │ ├── DeviceListUtils.kt
│ │ │ │ ├── ZipFileUtils.kt
│ │ │ │ └── MetadataUtils.kt
│ │ │ ├── Theme.kt
│ │ │ ├── Password.kt
│ │ │ ├── ui
│ │ │ │ ├── LoginCardView.kt
│ │ │ │ ├── AboutDialog.kt
│ │ │ │ ├── DeviceListDialog.kt
│ │ │ │ ├── components
│ │ │ │ │ └── AutoCompleteTextField.kt
│ │ │ │ └── BasicViews.kt
│ │ │ └── Login.kt
│ │ └── composeResources
│ │ │ ├── drawable
│ │ │ └── icon.webp
│ │ │ ├── values-zh-rCN
│ │ │ └── strings.xml
│ │ │ ├── values-zh-rTW
│ │ │ └── strings.xml
│ │ │ ├── values-ja-rJP
│ │ │ └── strings.xml
│ │ │ ├── values
│ │ │ └── strings.xml
│ │ │ └── values-pt-rBR
│ │ │ └── strings.xml
│ ├── androidMain
│ │ ├── kotlin
│ │ │ ├── Main.android.kt
│ │ │ ├── platform
│ │ │ │ ├── HttpClient.android.kt
│ │ │ │ ├── Clipboard.android.kt
│ │ │ │ ├── Toast.android.kt
│ │ │ │ ├── Preferences.android.kt
│ │ │ │ ├── Download.android.kt
│ │ │ │ └── Crypto.android.kt
│ │ │ ├── top
│ │ │ │ └── yukonga
│ │ │ │ │ └── updater
│ │ │ │ │ └── kmp
│ │ │ │ │ ├── AndroidAppContext.kt
│ │ │ │ │ └── MainActivity.kt
│ │ │ └── misc
│ │ │ │ └── KeyStoreUtils.kt
│ │ ├── res
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ ├── values
│ │ │ │ ├── themes.xml
│ │ │ │ └── colors.xml
│ │ │ ├── xml
│ │ │ │ └── locales_config.xml
│ │ │ └── drawable
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_foreground.xml
│ │ └── AndroidManifest.xml
│ ├── webMain
│ │ ├── resources
│ │ │ ├── favicon.ico
│ │ │ ├── MiSans VF.woff2
│ │ │ ├── index.html
│ │ │ └── styles.css
│ │ └── kotlin
│ │ │ └── platform
│ │ │ ├── Toast.web.kt
│ │ │ └── Crypto.web.kt
│ ├── macosMain
│ │ ├── resources
│ │ │ └── Updater.icns
│ │ └── kotlin
│ │ │ ├── platform
│ │ │ ├── Download.macos.kt
│ │ │ └── Clipboard.macos.kt
│ │ │ └── Main.macos.kt
│ ├── desktopMain
│ │ ├── resources
│ │ │ ├── linux
│ │ │ │ └── Icon.png
│ │ │ ├── macos
│ │ │ │ └── Icon.icns
│ │ │ └── windows
│ │ │ │ └── Icon.ico
│ │ └── kotlin
│ │ │ ├── platform
│ │ │ ├── Toast.desktop.kt
│ │ │ ├── HttpClient.desktop.kt
│ │ │ ├── Clipboard.desktop.kt
│ │ │ ├── Download.desktop.kt
│ │ │ ├── Preferences.desktop.kt
│ │ │ └── Crypto.desktop.kt
│ │ │ ├── theme
│ │ │ ├── MacOSThemeManager.kt
│ │ │ ├── WindowsThemeManager.kt
│ │ │ └── LinuxThemeManager.kt
│ │ │ ├── misc
│ │ │ └── KeyStoreUtils.kt
│ │ │ └── Main.desktop.kt
│ ├── appleMain
│ │ └── kotlin
│ │ │ └── platform
│ │ │ ├── Toast.apple.kt
│ │ │ ├── HttpClient.apple.kt
│ │ │ ├── Crypto.apple.kt
│ │ │ └── Preferences.apple.kt
│ ├── iosMain
│ │ └── kotlin
│ │ │ ├── Main.ios.kt
│ │ │ ├── platform
│ │ │ ├── Clipboard.ios.kt
│ │ │ └── Download.ios.kt
│ │ │ └── ResourceEnvironmentFix.kt
│ ├── jsMain
│ │ ├── kotlin
│ │ │ ├── platform
│ │ │ │ ├── HttpClient.js.kt
│ │ │ │ ├── Clipboard.js.kt
│ │ │ │ ├── Preferences.js.kt
│ │ │ │ └── Download.js.kt
│ │ │ └── Main.js.kt
│ │ └── resources
│ │ │ └── app.js
│ └── wasmJsMain
│ │ └── kotlin
│ │ ├── platform
│ │ ├── HttpClient.wasmJs.kt
│ │ ├── Clipboard.wasmJs.kt
│ │ ├── Preferences.wasmJs.kt
│ │ └── Download.wasmJs.kt
│ │ └── Main.wasmJs.kt
├── proguard-rules-jvm.pro
└── webpack.config.d
│ └── config.js
├── .gitattributes
├── iosApp
├── Configuration
│ └── Config.xcconfig
└── iosApp
│ ├── Assets.xcassets
│ ├── Contents.json
│ └── AppIcon.appiconset
│ │ ├── app-icon-1024.png
│ │ └── Contents.json
│ ├── iosApp.swift
│ ├── ContentView.swift
│ └── Info.plist
├── gradle
├── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
└── libs.versions.toml
├── .github
├── dependabot.yml
└── workflows
│ └── Action CI.yml
├── gradle.properties
├── settings.gradle.kts
├── README.md
├── gradlew.bat
├── gradlew
└── .gitignore
/composeApp/proguard-rules-android.pro:
--------------------------------------------------------------------------------
1 | -dontwarn org.slf4j.helpers.SubstituteLogger
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/iosApp/Configuration/Config.xcconfig:
--------------------------------------------------------------------------------
1 | TEAM_ID=
2 | BUNDLE_ID=top.yukonga.updater.kmp
3 | APP_NAME=Updater
4 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/Download.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | expect fun downloadToLocal(url: String, fileName: String)
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/Main.android.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.Composable
2 |
3 | @Composable
4 | fun MainView() = App()
--------------------------------------------------------------------------------
/composeApp/src/webMain/resources/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/webMain/resources/favicon.ico
--------------------------------------------------------------------------------
/composeApp/src/macosMain/resources/Updater.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/macosMain/resources/Updater.icns
--------------------------------------------------------------------------------
/composeApp/src/webMain/resources/MiSans VF.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/webMain/resources/MiSans VF.woff2
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/resources/linux/Icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/desktopMain/resources/linux/Icon.png
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/resources/macos/Icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/desktopMain/resources/macos/Icon.icns
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/resources/windows/Icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/desktopMain/resources/windows/Icon.ico
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/Toast.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | expect fun useToast(): Boolean
4 | expect fun showToast(message: String, duration: Long)
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/HttpClient.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 |
5 | expect fun httpClientPlatform(): HttpClient
6 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/drawable/icon.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/composeApp/src/commonMain/composeResources/drawable/icon.webp
--------------------------------------------------------------------------------
/composeApp/src/webMain/kotlin/platform/Toast.web.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | actual fun useToast(): Boolean = false
4 | actual fun showToast(message: String, duration: Long) {}
--------------------------------------------------------------------------------
/composeApp/src/appleMain/kotlin/platform/Toast.apple.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | actual fun useToast(): Boolean = false
4 | actual fun showToast(message: String, duration: Long) {}
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/Toast.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | actual fun useToast(): Boolean = false
4 | actual fun showToast(message: String, duration: Long) {}
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YuKongA/Updater-KMP/HEAD/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
--------------------------------------------------------------------------------
/iosApp/iosApp/iosApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct iosApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/data/FileInfoHelper.kt:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | class FileInfoHelper {
4 | data class FileInfo(
5 | val offset: Long,
6 | val size: Long,
7 | )
8 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/Clipboard.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.Clipboard
4 |
5 | internal expect suspend fun Clipboard.copyToClipboard(string: String)
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/Main.ios.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.ui.window.ComposeUIViewController
2 |
3 | fun main() = ComposeUIViewController {
4 | ResourceEnvironmentFix {
5 | App()
6 | }
7 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/Preferences.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | expect fun prefSet(key: String, value: String)
4 | expect fun prefGet(key: String): String?
5 | expect fun prefRemove(key: String)
6 |
--------------------------------------------------------------------------------
/composeApp/src/jsMain/kotlin/platform/HttpClient.js.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.js.Js
5 |
6 | actual fun httpClientPlatform(): HttpClient = HttpClient(Js)
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/HttpClient.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.cio.CIO
5 |
6 | actual fun httpClientPlatform(): HttpClient = HttpClient(CIO)
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/HttpClient.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.cio.CIO
5 |
6 | actual fun httpClientPlatform(): HttpClient = HttpClient(CIO)
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/platform/HttpClient.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.js.Js
5 |
6 | actual fun httpClientPlatform(): HttpClient = HttpClient(Js)
7 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 | #3482FF
5 |
--------------------------------------------------------------------------------
/composeApp/src/appleMain/kotlin/platform/HttpClient.apple.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import io.ktor.client.HttpClient
4 | import io.ktor.client.engine.darwin.Darwin
5 |
6 | actual fun httpClientPlatform(): HttpClient = HttpClient(Darwin)
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/platform/Clipboard.ios.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.Clipboard
4 | import platform.UIKit.UIPasteboard
5 |
6 | actual suspend fun Clipboard.copyToClipboard(string: String) {
7 | UIPasteboard.generalPasteboard.string = string
8 | }
--------------------------------------------------------------------------------
/composeApp/src/jsMain/kotlin/platform/Clipboard.js.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.ClipEntry
4 | import androidx.compose.ui.platform.Clipboard
5 |
6 | actual suspend fun Clipboard.copyToClipboard(string: String) {
7 | this.setClipEntry(ClipEntry.withPlainText(string))
8 | }
--------------------------------------------------------------------------------
/composeApp/src/jsMain/resources/app.js:
--------------------------------------------------------------------------------
1 | function jsInt8ArrayToKotlinByteArray(x) {
2 | const size = x.length;
3 | const memBuffer = new ArrayBuffer(size);
4 | const mem8 = new Int8Array(memBuffer);
5 | mem8.set(x);
6 | const byteArray = new Uint8Array(memBuffer);
7 | return byteArray;
8 | }
9 |
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/platform/Clipboard.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.ClipEntry
4 | import androidx.compose.ui.platform.Clipboard
5 |
6 | actual suspend fun Clipboard.copyToClipboard(string: String) {
7 | this.setClipEntry(ClipEntry.withPlainText(string))
8 | }
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/platform/Download.ios.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import platform.Foundation.NSURL
4 | import platform.UIKit.UIApplication
5 |
6 | actual fun downloadToLocal(url: String, fileName: String) {
7 | val openUrl = NSURL(string = url)
8 | UIApplication.sharedApplication.openURL(openUrl)
9 | }
--------------------------------------------------------------------------------
/composeApp/src/macosMain/kotlin/platform/Download.macos.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import platform.AppKit.NSWorkspace
4 | import platform.Foundation.NSURL
5 |
6 | actual fun downloadToLocal(url: String, fileName: String) {
7 | val openUrl = NSURL(string = url)
8 | NSWorkspace.sharedWorkspace().openURL(openUrl)
9 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "app-icon-1024.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/Clipboard.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.ClipEntry
4 | import androidx.compose.ui.platform.Clipboard
5 | import java.awt.datatransfer.StringSelection
6 |
7 | internal actual suspend fun Clipboard.copyToClipboard(string: String) {
8 | setClipEntry(ClipEntry(StringSelection(string)))
9 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/Download.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import java.awt.Desktop
4 | import java.net.URI
5 |
6 | actual fun downloadToLocal(url: String, fileName: String) {
7 | if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
8 | Desktop.getDesktop().browse(URI(url))
9 | }
10 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/xml/locales_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/composeApp/proguard-rules-jvm.pro:
--------------------------------------------------------------------------------
1 | -dontwarn org.slf4j.helpers.SubstituteLogger
2 | -dontwarn okhttp3.internal.platform.**
3 | -dontwarn io.ktor.network.sockets.SocketBase**
4 | -dontwarn kotlinx.datetime.**
5 |
6 | -keep class com.sun.jna.** { *; }
7 | -keep class * implements com.sun.jna.** { *; }
8 | -keep class dev.whyoleg.cryptography.providers.jdk.JdkCryptographyProviderContainer
9 |
10 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/Clipboard.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import android.content.ClipData
4 | import androidx.compose.ui.platform.ClipEntry
5 | import androidx.compose.ui.platform.Clipboard
6 |
7 | actual suspend fun Clipboard.copyToClipboard(string: String) {
8 | val clipData = ClipData.newPlainText("Clipboard", string)
9 | setClipEntry(ClipEntry(clipData))
10 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/composeApp/src/jsMain/kotlin/platform/Preferences.js.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import kotlinx.browser.window
4 |
5 | actual fun prefSet(key: String, value: String) {
6 | window.localStorage.setItem(key, value)
7 | }
8 |
9 | actual fun prefGet(key: String): String? {
10 | return window.localStorage.getItem(key)
11 | }
12 |
13 | actual fun prefRemove(key: String) {
14 | window.localStorage.removeItem(key)
15 | }
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/platform/Preferences.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import kotlinx.browser.window
4 |
5 | actual fun prefSet(key: String, value: String) {
6 | window.localStorage.setItem(key, value)
7 | }
8 |
9 | actual fun prefGet(key: String): String? {
10 | return window.localStorage.getItem(key)
11 | }
12 |
13 | actual fun prefRemove(key: String) {
14 | window.localStorage.removeItem(key)
15 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/utils/LinkUtils.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | object LinkUtils {
4 | private val urlPattern = Regex(
5 | pattern = """https?://[^\s\u4e00-\u9fa5]+""",
6 | options = setOf(RegexOption.IGNORE_CASE)
7 | )
8 |
9 | fun extractLinks(text: String): List> {
10 | return urlPattern.findAll(text).map {
11 | it.value to it.range
12 | }.toList()
13 | }
14 | }
--------------------------------------------------------------------------------
/composeApp/src/macosMain/kotlin/platform/Clipboard.macos.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import androidx.compose.ui.platform.Clipboard
4 | import platform.AppKit.NSPasteboard
5 | import platform.AppKit.NSPasteboardTypeString
6 |
7 | actual suspend fun Clipboard.copyToClipboard(string: String) {
8 | val pasteboard = NSPasteboard.generalPasteboard
9 | pasteboard.clearContents()
10 | pasteboard.setString(string, forType = NSPasteboardTypeString)
11 | }
--------------------------------------------------------------------------------
/composeApp/src/appleMain/kotlin/platform/Crypto.apple.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import dev.whyoleg.cryptography.providers.apple.Apple
5 |
6 | actual suspend fun provider() = CryptographyProvider.Apple
7 |
8 | actual fun ownEncrypt(string: String): Pair = Pair(string, "")
9 |
10 | actual fun ownDecrypt(encryptedText: String, encodedIv: String): String = encryptedText
11 |
12 | actual fun generateKey() = Unit
--------------------------------------------------------------------------------
/composeApp/src/webMain/kotlin/platform/Crypto.web.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import dev.whyoleg.cryptography.providers.webcrypto.WebCrypto
5 |
6 | actual suspend fun provider() = CryptographyProvider.WebCrypto
7 |
8 | actual fun ownEncrypt(string: String): Pair = Pair(string, "")
9 |
10 | actual fun ownDecrypt(encryptedText: String, encodedIv: String): String = encryptedText
11 |
12 | actual fun generateKey() = Unit
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/Toast.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import android.widget.Toast
4 | import top.yukonga.updater.kmp.AndroidAppContext
5 |
6 | private var lastToast: Toast? = null
7 |
8 | actual fun useToast(): Boolean = true
9 |
10 | actual fun showToast(message: String, duration: Long) {
11 | val context = AndroidAppContext.getApplicationContext()
12 | lastToast?.cancel()
13 | lastToast = Toast.makeText(context, message, duration.toInt()).apply { show() }
14 | }
--------------------------------------------------------------------------------
/composeApp/src/appleMain/kotlin/platform/Preferences.apple.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import platform.Foundation.NSUserDefaults
4 |
5 | private val preferences = NSUserDefaults.standardUserDefaults()
6 |
7 | actual fun prefSet(key: String, value: String) {
8 | preferences.setObject(value, key)
9 | }
10 |
11 | actual fun prefGet(key: String): String? {
12 | return preferences.stringForKey(key)
13 | }
14 |
15 | actual fun prefRemove(key: String) {
16 | preferences.removeObjectForKey(key)
17 | }
--------------------------------------------------------------------------------
/composeApp/src/jsMain/kotlin/platform/Download.js.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import kotlinx.browser.document
4 | import org.w3c.dom.HTMLAnchorElement
5 |
6 | actual fun downloadToLocal(url: String, fileName: String) {
7 | val anchorElement = document.createElement("a") as HTMLAnchorElement
8 | anchorElement.href = url
9 | anchorElement.download = fileName
10 | document.body?.appendChild(anchorElement)
11 | anchorElement.click()
12 | document.body?.removeChild(anchorElement)
13 | }
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/platform/Download.wasmJs.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import kotlinx.browser.document
4 | import org.w3c.dom.HTMLAnchorElement
5 |
6 | actual fun downloadToLocal(url: String, fileName: String) {
7 | val anchorElement = document.createElement("a") as HTMLAnchorElement
8 | anchorElement.href = url
9 | anchorElement.download = fileName
10 | document.body?.appendChild(anchorElement)
11 | anchorElement.click()
12 | document.body?.removeChild(anchorElement)
13 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/top/yukonga/updater/kmp/AndroidAppContext.kt:
--------------------------------------------------------------------------------
1 | package top.yukonga.updater.kmp
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 |
6 | @SuppressLint("StaticFieldLeak")
7 | object AndroidAppContext {
8 | private var context: Context? = null
9 |
10 | fun init(context: Context) {
11 | AndroidAppContext.context = context.applicationContext
12 | }
13 |
14 | fun getApplicationContext(): Context? {
15 | return context
16 | }
17 | }
--------------------------------------------------------------------------------
/composeApp/webpack.config.d/config.js:
--------------------------------------------------------------------------------
1 | const TerserPlugin = require("terser-webpack-plugin");
2 |
3 | config.optimization = config.optimization || {};
4 | config.optimization.minimize = true;
5 | config.optimization.minimizer = [
6 | new TerserPlugin({
7 | terserOptions: {
8 | mangle: true, // Note: By default, mangle is set to true.
9 | compress: false, // Disable the transformations that reduce the code size.
10 | output: {
11 | beautify: false,
12 | },
13 | },
14 | }),
15 | ];
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gradle"
9 | directory: "/"
10 | schedule:
11 | interval: "daily"
12 |
13 | - package-ecosystem: "github-actions"
14 | directory: "/"
15 | schedule:
16 | interval: "daily"
17 |
--------------------------------------------------------------------------------
/composeApp/src/webMain/resources/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Updater
8 |
9 |
10 |
11 |
12 |
13 | Loading
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/iosApp/iosApp/ContentView.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 | import shared
4 |
5 | struct ComposeView: UIViewControllerRepresentable {
6 | func makeUIViewController(context: Context) -> UIViewController {
7 | Main_iosKt.main()
8 | }
9 |
10 | func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
11 | }
12 |
13 | struct ContentView: View {
14 | var body: some View {
15 | ComposeView()
16 | .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
17 | .edgesIgnoringSafeArea(.all) // edge to edge
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/top/yukonga/updater/kmp/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package top.yukonga.updater.kmp
2 |
3 | import MainView
4 | import android.os.Build
5 | import android.os.Bundle
6 | import androidx.activity.ComponentActivity
7 | import androidx.activity.compose.setContent
8 | import androidx.activity.enableEdgeToEdge
9 |
10 | class MainActivity : ComponentActivity() {
11 | override fun onCreate(savedInstanceState: Bundle?) {
12 | super.onCreate(savedInstanceState)
13 |
14 | AndroidAppContext.init(this)
15 | enableEdgeToEdge()
16 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
17 | window.isNavigationBarContrastEnforced = false
18 | }
19 | setContent {
20 | MainView()
21 | }
22 | }
23 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/Preferences.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import android.annotation.SuppressLint
4 | import android.content.Context
5 | import android.content.SharedPreferences
6 | import top.yukonga.updater.kmp.AndroidAppContext
7 |
8 | @SuppressLint("StaticFieldLeak")
9 | private val context = AndroidAppContext.getApplicationContext()
10 | private val sharedPreferences: SharedPreferences? = context?.getSharedPreferences("UpdaterKMP", Context.MODE_PRIVATE)
11 |
12 | actual fun prefSet(key: String, value: String) {
13 | sharedPreferences?.edit()?.putString(key, value)?.apply()
14 | }
15 |
16 | actual fun prefGet(key: String): String? {
17 | return sharedPreferences?.getString(key, null)
18 | }
19 |
20 | actual fun prefRemove(key: String) {
21 | sharedPreferences?.edit()?.remove(key)?.apply()
22 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/Theme.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.foundation.ComposeFoundationFlags
2 | import androidx.compose.foundation.ExperimentalFoundationApi
3 | import androidx.compose.runtime.Composable
4 | import top.yukonga.miuix.kmp.theme.MiuixTheme
5 | import top.yukonga.miuix.kmp.theme.darkColorScheme
6 | import top.yukonga.miuix.kmp.theme.lightColorScheme
7 | import top.yukonga.miuix.kmp.utils.Platform
8 | import top.yukonga.miuix.kmp.utils.platform
9 |
10 | @OptIn(ExperimentalFoundationApi::class)
11 | @Composable
12 | fun AppTheme(
13 | isDarkTheme: Boolean,
14 | content: @Composable () -> Unit
15 | ) {
16 | if (platform() != Platform.MacOS) ComposeFoundationFlags.isNewContextMenuEnabled = true
17 | MiuixTheme(
18 | colors = if (isDarkTheme) {
19 | darkColorScheme()
20 | } else {
21 | lightColorScheme()
22 | }
23 | ) {
24 | content()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/Download.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import android.app.DownloadManager
4 | import android.content.Context
5 | import android.os.Environment
6 | import androidx.core.net.toUri
7 | import top.yukonga.updater.kmp.AndroidAppContext
8 |
9 | actual fun downloadToLocal(url: String, fileName: String) {
10 | val request = DownloadManager.Request(url.toUri()).apply {
11 | setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
12 | setTitle(fileName)
13 | setDescription(fileName)
14 | setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
15 | setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
16 | }
17 | val context = AndroidAppContext.getApplicationContext()
18 | val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
19 | downloadManager.enqueue(request)
20 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/theme/MacOSThemeManager.kt:
--------------------------------------------------------------------------------
1 | package theme
2 |
3 | import kotlinx.coroutines.currentCoroutineContext
4 | import kotlinx.coroutines.isActive
5 |
6 | object MacOSThemeManager {
7 | fun isMacOSDarkTheme(): Boolean {
8 | return try {
9 | val process = ProcessBuilder("defaults", "read", "-g", "AppleInterfaceStyle").start()
10 | val result = process.inputStream.bufferedReader().readText().trim()
11 | process.waitFor()
12 | result.equals("Dark", ignoreCase = true)
13 | } catch (_: Exception) {
14 | false
15 | }
16 | }
17 |
18 | suspend fun listenMacOSThemeChanges(onThemeChanged: (Boolean) -> Unit) {
19 | try {
20 | while (currentCoroutineContext().isActive) {
21 | val currentSystemThemeIsDark = isMacOSDarkTheme()
22 | onThemeChanged(currentSystemThemeIsDark)
23 | }
24 | } catch (_: Exception) {
25 | }
26 | }
27 | }
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Gradle
2 | org.gradle.jvmargs=-Xmx8g -Dfile.encoding=UTF-8
3 | org.gradle.parallel=true
4 | org.gradle.caching=true
5 | org.gradle.configureondemand=true
6 | # Android
7 | android.useAndroidX=true
8 | android.nonTransitiveRClass=true
9 | android.r8.maxWorkers=4
10 | # Kotlin
11 | kotlin.code.style=official
12 | # MPP
13 | kotlin.mpp.androidSourceSetLayoutVersion=2
14 | kotlin.mpp.enableCInteropCommonization=true
15 | # Native
16 | kotlin.native.binary.smallBinary=true
17 | kotlin.native.binary.latin1Strings=true
18 | kotlin.native.binary.pagedAllocator=false
19 | kotlin.native.ignoreDisabledTargets=true
20 | # Incremental compilation
21 | kotlin.incremental=true
22 | kotlin.incremental.multiplatform=true
23 | kotlin.incremental.jvm.fir=true
24 | kotlin.incremental.js=true
25 | kotlin.incremental.js.klib=true
26 | kotlin.incremental.wasm=true
27 | kotlin.incremental.native=true
28 | # Experimental target
29 | org.jetbrains.compose.experimental.macos.enabled=true
30 | # Xcode
31 | kotlin.apple.xcodeCompatibility.nowarn=true
32 | xcodeproj=./iosApp
33 |
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/Preferences.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import java.io.File
4 | import java.util.Properties
5 |
6 | private val configFile = File(System.getProperty("user.home"), ".updater-kmp/config.properties")
7 | private val properties = Properties()
8 |
9 | private fun initializeConfig() {
10 | configFile.parentFile?.mkdirs()
11 | if (configFile.exists()) {
12 | properties.load(configFile.inputStream())
13 | }
14 | }
15 |
16 | actual fun prefSet(key: String, value: String) {
17 | if (properties.isEmpty) initializeConfig()
18 | properties.setProperty(key, value)
19 | configFile.outputStream().use { properties.store(it, "Updater Configuration") }
20 | }
21 |
22 | actual fun prefGet(key: String): String? {
23 | if (properties.isEmpty) initializeConfig()
24 | return properties.getProperty(key)
25 | }
26 |
27 | actual fun prefRemove(key: String) {
28 | if (properties.isEmpty) initializeConfig()
29 | properties.remove(key)
30 | configFile.outputStream().use { properties.store(it, "Updater Configuration") }
31 | }
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/platform/Crypto.android.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import dev.whyoleg.cryptography.providers.jdk.JDK
5 | import misc.KeyStoreUtils
6 | import kotlin.io.encoding.Base64
7 | import kotlin.io.encoding.ExperimentalEncodingApi
8 |
9 | actual suspend fun provider() = CryptographyProvider.JDK
10 |
11 | @OptIn(ExperimentalEncodingApi::class)
12 | actual fun ownEncrypt(string: String): Pair {
13 | val cipher = KeyStoreUtils.getEncryptionCipher()
14 | val encrypted = cipher.doFinal(string.toByteArray())
15 | val iv = cipher.iv
16 | return Pair(Base64.encode(encrypted), Base64.encode(iv))
17 | }
18 |
19 | @OptIn(ExperimentalEncodingApi::class)
20 | actual fun ownDecrypt(encryptedText: String, encodedIv: String): String {
21 | val encrypted = Base64.decode(encryptedText)
22 | val iv = Base64.decode(encodedIv)
23 | val cipher = KeyStoreUtils.getDecryptionCipher(iv)
24 | return String(cipher.doFinal(encrypted))
25 | }
26 |
27 | actual fun generateKey() {
28 | KeyStoreUtils.generateKey()
29 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/platform/Crypto.desktop.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import dev.whyoleg.cryptography.providers.jdk.JDK
5 | import misc.KeyStoreUtils
6 | import kotlin.io.encoding.Base64
7 | import kotlin.io.encoding.ExperimentalEncodingApi
8 |
9 | actual suspend fun provider() = CryptographyProvider.JDK
10 |
11 | @OptIn(ExperimentalEncodingApi::class)
12 | actual fun ownEncrypt(string: String): Pair {
13 | val cipher = KeyStoreUtils.getEncryptionCipher()
14 | val encrypted = cipher.doFinal(string.toByteArray())
15 | val iv = cipher.iv
16 | return Pair(Base64.Mime.encode(encrypted), Base64.Mime.encode(iv))
17 | }
18 |
19 | @OptIn(ExperimentalEncodingApi::class)
20 | actual fun ownDecrypt(encryptedText: String, encodedIv: String): String {
21 | val encrypted = Base64.Mime.decode(encryptedText)
22 | val iv = Base64.Mime.decode(encodedIv)
23 | val cipher = KeyStoreUtils.getDecryptionCipher(iv) ?: return ""
24 | return String(cipher.doFinal(encrypted))
25 | }
26 |
27 | actual fun generateKey() {
28 | KeyStoreUtils.generateKey()
29 | }
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | @file:Suppress("UnstableApiUsage")
2 |
3 | rootProject.name = "Updater"
4 |
5 | pluginManagement {
6 | repositories {
7 | google {
8 | mavenContent {
9 | includeGroupAndSubgroups("androidx")
10 | includeGroupAndSubgroups("com.android")
11 | includeGroupAndSubgroups("com.google")
12 | }
13 | }
14 | mavenCentral()
15 | gradlePluginPortal()
16 | }
17 | }
18 |
19 | dependencyResolutionManagement {
20 | repositories {
21 | google {
22 | mavenContent {
23 | includeGroupAndSubgroups("androidx")
24 | includeGroupAndSubgroups("com.android")
25 | includeGroupAndSubgroups("com.google")
26 | }
27 | }
28 | mavenCentral()
29 | }
30 | }
31 |
32 | plugins {
33 | id("com.android.settings") version ("8.13.0")
34 | id("org.gradle.toolchains.foojay-resolver-convention") version ("1.0.0")
35 | }
36 |
37 | android {
38 | compileSdk = 36
39 | targetSdk = 36
40 | minSdk = 26
41 | buildToolsVersion = "36.1.0"
42 | }
43 |
44 | include(":composeApp")
--------------------------------------------------------------------------------
/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
12 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
16 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/composeApp/src/webMain/resources/styles.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2024 The Android Open Source Project
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 |
18 | html,
19 | body {
20 | width: 100%;
21 | height: 100%;
22 | margin: 0;
23 | padding: 0;
24 | overflow: hidden;
25 | }
26 |
27 | #loading {
28 | position: absolute;
29 | top: 50%;
30 | left: 50%;
31 | transform: translate(-50%, -50%);
32 | font-size: 24px;
33 | font-weight: bold;
34 | }
35 |
36 | #loading::after {
37 | content: '';
38 | animation: ellipsis 1.5s infinite;
39 | }
40 |
41 | @keyframes ellipsis {
42 | 0% {
43 | content: '';
44 | }
45 |
46 | 33% {
47 | content: '.';
48 | }
49 |
50 | 66% {
51 | content: '..';
52 | }
53 |
54 | 100% {
55 | content: '...';
56 | }
57 | }
58 |
59 | #composeApplication {
60 | width: 100%;
61 | height: 100%;
62 | margin: 0;
63 | padding: 0;
64 | overflow: hidden;
65 | }
--------------------------------------------------------------------------------
/iosApp/iosApp/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CADisableMinimumFrameDurationOnPhone
6 |
7 | CFBundleDevelopmentRegion
8 | $(DEVELOPMENT_LANGUAGE)
9 | CFBundleExecutable
10 | $(EXECUTABLE_NAME)
11 | CFBundleIdentifier
12 | $(PRODUCT_BUNDLE_IDENTIFIER)
13 | CFBundleInfoDictionaryVersion
14 | 6.0
15 | CFBundleName
16 | $(PRODUCT_NAME)
17 | CFBundlePackageType
18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
19 | CFBundleShortVersionString
20 | 1.6.1
21 | CFBundleVersion
22 | 505
23 | LSRequiresIPhoneOS
24 |
25 | UIApplicationSceneManifest
26 |
27 | UIApplicationSupportsMultipleScenes
28 |
29 |
30 | UILaunchScreen
31 |
32 | UIRequiredDeviceCapabilities
33 |
34 | arm64
35 |
36 | UISupportedInterfaceOrientations
37 |
38 | UIInterfaceOrientationPortrait
39 | UIInterfaceOrientationLandscapeLeft
40 | UIInterfaceOrientationLandscapeRight
41 |
42 | UISupportedInterfaceOrientations~ipad
43 |
44 | UIInterfaceOrientationPortrait
45 | UIInterfaceOrientationPortraitUpsideDown
46 | UIInterfaceOrientationLandscapeLeft
47 | UIInterfaceOrientationLandscapeRight
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/composeApp/src/androidMain/kotlin/misc/KeyStoreUtils.kt:
--------------------------------------------------------------------------------
1 | package misc
2 |
3 | import android.security.keystore.KeyGenParameterSpec
4 | import android.security.keystore.KeyProperties
5 | import java.security.KeyStore
6 | import javax.crypto.Cipher
7 | import javax.crypto.KeyGenerator
8 | import javax.crypto.SecretKey
9 | import javax.crypto.spec.GCMParameterSpec
10 |
11 | object KeyStoreUtils {
12 |
13 | private const val ANDROID_KEY_STORE = "AndroidKeyStore"
14 | private const val UPDATER_KEY_ALIAS = "updater_key_alias"
15 | private const val AES_MODE = "AES/GCM/NoPadding"
16 |
17 | fun generateKey() {
18 | val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE)
19 | keyGenerator.init(
20 | KeyGenParameterSpec.Builder(UPDATER_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
21 | .setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setRandomizedEncryptionRequired(false)
22 | .build()
23 | )
24 | keyGenerator.generateKey()
25 | }
26 |
27 | private fun getSecretKey(): SecretKey {
28 | val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE)
29 | keyStore.load(null)
30 | return keyStore.getKey(UPDATER_KEY_ALIAS, null) as SecretKey
31 | }
32 |
33 | fun getEncryptionCipher(): Cipher {
34 | val cipher = Cipher.getInstance(AES_MODE)
35 | cipher.init(Cipher.ENCRYPT_MODE, getSecretKey())
36 | return cipher
37 | }
38 |
39 | fun getDecryptionCipher(iv: ByteArray): Cipher {
40 | val cipher = Cipher.getInstance(AES_MODE)
41 | cipher.init(Cipher.DECRYPT_MODE, getSecretKey(), GCMParameterSpec(128, iv))
42 | return cipher
43 | }
44 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/data/DataHelper.kt:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | object DataHelper {
6 | @Serializable
7 | data class IconInfoData(
8 | val changelog: String = "",
9 | val iconName: String = "",
10 | val iconLink: String = "",
11 | )
12 |
13 | @Serializable
14 | data class ImageInfoData(
15 | val title: String = "",
16 | val changelog: String = "",
17 | val imageUrl: String = "",
18 | val imageWidth: Int? = null,
19 | val imageHeight: Int? = null
20 | )
21 |
22 | @Serializable
23 | data class LoginData(
24 | val accountType: String? = null,
25 | var authResult: String? = null,
26 | val description: String? = null,
27 | val ssecurity: String? = null,
28 | val serviceToken: String? = null,
29 | val userId: String? = null,
30 | val cUserId: String? = null,
31 | )
32 |
33 | @Serializable
34 | data class RomInfoData(
35 | var type: String = "",
36 | var device: String = "",
37 | var version: String = "",
38 | var codebase: String = "",
39 | var branch: String = "",
40 | var bigVersion: String = "",
41 | var fileName: String = "",
42 | var fileSize: String = "",
43 | var md5: String = "",
44 | var isBeta: Boolean = false,
45 | var isGov: Boolean = false,
46 | var official1Download: String = "",
47 | var official2Download: String = "",
48 | var cdn1Download: String = "",
49 | var cdn2Download: String = "",
50 | var changelog: String = "",
51 | var gentleNotice: String = "",
52 | var fingerprint: String = "",
53 | var securityPatchLevel: String = "",
54 | var timestamp: String = "",
55 | )
56 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/Password.kt:
--------------------------------------------------------------------------------
1 | import platform.generateKey
2 | import platform.ownDecrypt
3 | import platform.ownEncrypt
4 | import platform.prefGet
5 | import platform.prefRemove
6 | import platform.prefSet
7 |
8 | class Password {
9 | /**
10 | * Save Xiaomi's account & password.
11 | *
12 | * @param account: Xiaomi account
13 | * @param password: Password
14 | */
15 | fun savePassword(account: String, password: String) {
16 | generateKey()
17 | val encryptedAccount = ownEncrypt(account)
18 | val encryptedPassword = ownEncrypt(password)
19 | prefSet("savePassword", "1")
20 | prefSet("account", encryptedAccount.first)
21 | prefSet("accountIv", encryptedAccount.second)
22 | prefSet("password", encryptedPassword.first)
23 | prefSet("passwordIv", encryptedPassword.second)
24 | }
25 |
26 | /**
27 | * Delete Xiaomi's account & password.
28 | */
29 | fun deletePassword() {
30 | prefRemove("savePassword")
31 | prefRemove("account")
32 | prefRemove("accountIv")
33 | prefRemove("password")
34 | prefRemove("passwordIv")
35 | }
36 |
37 | /**
38 | * Get Xiaomi's account & password.
39 | *
40 | * @return Pair of Xiaomi's account & password
41 | */
42 | fun getPassword(): Pair {
43 | if (prefGet("account") != null && prefGet("password") != null && prefGet("accountIv") != null && prefGet("passwordIv") != null) {
44 | val encryptedAccount = prefGet("account").toString()
45 | val encodedAccountKey = prefGet("accountIv").toString()
46 | val encryptedPassword = prefGet("password").toString()
47 | val encodedPasswordKey = prefGet("passwordIv").toString()
48 | val account = ownDecrypt(encryptedAccount, encodedAccountKey)
49 | val password = ownDecrypt(encryptedPassword, encodedPasswordKey)
50 | return Pair(account, password)
51 | } else return Pair("", "")
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/composeApp/src/jsMain/kotlin/Main.js.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.LaunchedEffect
2 | import androidx.compose.runtime.mutableStateOf
3 | import androidx.compose.runtime.remember
4 | import androidx.compose.ui.ExperimentalComposeUiApi
5 | import androidx.compose.ui.platform.LocalFontFamilyResolver
6 | import androidx.compose.ui.text.font.FontFamily
7 | import androidx.compose.ui.text.platform.Font
8 | import androidx.compose.ui.window.ComposeViewport
9 | import kotlinx.browser.window
10 | import kotlinx.coroutines.await
11 | import org.khronos.webgl.ArrayBuffer
12 | import org.khronos.webgl.Int8Array
13 |
14 | private const val MiSanVF = "./MiSans VF.woff2"
15 |
16 | @OptIn(ExperimentalComposeUiApi::class)
17 | fun main() {
18 | ComposeViewport(
19 | viewportContainerId = "composeApplication"
20 | ) {
21 | val fontFamilyResolver = LocalFontFamilyResolver.current
22 | val fontsLoaded = remember { mutableStateOf(false) }
23 |
24 | if (fontsLoaded.value) {
25 | hideLoading()
26 | App()
27 | }
28 |
29 | LaunchedEffect(Unit) {
30 | val miSanVFBytes = loadRes(MiSanVF).toByteArray()
31 | val fontFamily = FontFamily(Font("MiSans VF", miSanVFBytes))
32 | fontFamilyResolver.preload(fontFamily)
33 | fontsLoaded.value = true
34 | }
35 | }
36 | }
37 |
38 | suspend fun loadRes(url: String): ArrayBuffer {
39 | return window.fetch(url).await().arrayBuffer().await()
40 | }
41 |
42 | fun ArrayBuffer.toByteArray(): ByteArray {
43 | val source = Int8Array(this, 0, byteLength)
44 | return jsInt8ArrayToKotlinByteArray(source)
45 | }
46 |
47 | @OptIn(ExperimentalWasmJsInterop::class)
48 | @JsFun(
49 | """
50 | function hideLoading() {
51 | document.getElementById('loading').style.display = 'none';
52 | document.getElementById('composeApplication').style.display = 'block';
53 | }
54 | """
55 | )
56 | external fun hideLoading()
57 |
58 | external fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray
59 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/platform/Crypto.kt:
--------------------------------------------------------------------------------
1 | package platform
2 |
3 | import dev.whyoleg.cryptography.CryptographyProvider
4 | import dev.whyoleg.cryptography.DelicateCryptographyApi
5 | import dev.whyoleg.cryptography.algorithms.AES
6 | import kotlin.io.encoding.Base64
7 | import kotlin.io.encoding.ExperimentalEncodingApi
8 |
9 | private val iv = "0102030405060708".encodeToByteArray()
10 |
11 | expect suspend fun provider(): CryptographyProvider
12 |
13 | expect fun generateKey()
14 |
15 | /**
16 | * Generate a Cipher to be used by the xiaomi server.
17 | */
18 | suspend fun miuiCipher(securityKey: ByteArray): AES.IvCipher {
19 | val provider = provider()
20 | val aesCBC = provider.get(AES.CBC) // AES CBC
21 | val key = aesCBC.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, securityKey)
22 | return key.cipher(true) // PKCS5Padding
23 | }
24 |
25 | /**
26 | * Encrypt the JSON used for the request using AES.
27 | *
28 | * @param jsonRequest: JSON used for the request
29 | * @param securityKey: Security key
30 | *
31 | * @return Encrypted JSON text
32 | */
33 | @OptIn(ExperimentalEncodingApi::class, DelicateCryptographyApi::class)
34 | suspend fun miuiEncrypt(jsonRequest: String, securityKey: ByteArray): String {
35 | val cipher = miuiCipher(securityKey)
36 | val encrypted = cipher.encryptWithIv(iv, jsonRequest.encodeToByteArray())
37 | return Base64.UrlSafe.encode(encrypted)
38 | }
39 |
40 | /**
41 | * Decrypt the returned content using AES.
42 | *
43 | * @param encryptedText: Returned content
44 | * @param securityKey: Security key
45 | *
46 | * @return Decrypted return content text
47 | */
48 | @OptIn(DelicateCryptographyApi::class, ExperimentalEncodingApi::class)
49 | suspend fun miuiDecrypt(encryptedText: String, securityKey: ByteArray): String {
50 | val cipher = miuiCipher(securityKey)
51 | val encryptedTextBytes = Base64.Mime.decode(encryptedText)
52 | val decryptedTextBytes = cipher.decryptWithIv(iv, encryptedTextBytes)
53 | return decryptedTextBytes.decodeToString()
54 | }
55 |
56 | expect fun ownEncrypt(string: String): Pair
57 |
58 | expect fun ownDecrypt(encryptedText: String, encodedIv: String): String
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Updater-KMP
2 | This is an app to get Xiaomi official recovery rom information.
3 | Use [Kotlin Multiplatform](https://www.jetbrains.com/kotlin-multiplatform/) + [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/).
4 | **Android** / **Desktop(JVM)** / **iOS** / **macOS** are fully supported.
5 | **Webpage([Js](https://yukonga.github.io/Updater-JsCanvas/)/[WasmJs](https://yukonga.github.io/Updater-WasmJs/))** is also basically supported.
6 |
7 | ## Usage:
8 | When obtaining the release version, system version suffix can be automatically completed using `AUTO`.
9 | For example: `OS2.0.100.0.AUTO` / `V14.0.4.0.AUTO`.
10 |
11 | When obtaining the other version, please enter the complete system version yourself.
12 | For example: `OS1.0.23.12.19.DEV` / `V14.0.23.5.8.DEV`.
13 |
14 | ## Notes:
15 | Only supported `MIUI9` and above versions. The most extreme case is: Redmi 1S (armani), MIUI9, Android4.4.
16 |
17 | Only devices in the list of [device.json](https://github.com/YuKongA/Updater-KMP/blob/device-list/device.json) are supported use `AUTO` to complete automatically, other devices still need to manually enter the full system version.
18 |
19 | When you are not logged in with a Xiaomi account, you can use the miotaV3-v1 interface to obtain any detailed information of the `Pubilc Release Version` of any model.
20 |
21 | After logging in to your Xiaomi account, you will use the miotaV3-v2 interface to obtain detailed information about the `Beta Release Version` or the `Public Development Version`, corresponding to the internal test permissions you have.
22 |
23 | ## Credits:
24 | - [compose-imageloader](https://github.com/qdsfdhvh/compose-imageloader) with MIT License
25 | - [compose-multiplatform](https://github.com/JetBrains/compose-multiplatform) with Apache-2.0 license
26 | - [cryptography-kotlin](https://github.com/whyoleg/cryptography-kotlin) with Apache-2.0 license
27 | - [haze](https://github.com/chrisbanes/haze) with Apache-2.0 license
28 | - [ktor](https://github.com/ktorio/ktor) with Apache-2.0 license
29 | - [kotlinx.serialization](https://github.com/Kotlin/kotlinx.serialization) with Apache-2.0 license
30 | - [miuix](https://github.com/miuix-kotlin-multiplatform/miuix) with Apache-2.0 license
31 |
--------------------------------------------------------------------------------
/composeApp/src/macosMain/kotlin/Main.macos.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.collectAsState
2 | import androidx.compose.runtime.getValue
3 | import androidx.compose.ui.unit.DpSize
4 | import androidx.compose.ui.unit.dp
5 | import androidx.compose.ui.window.Window
6 | import kotlinx.coroutines.flow.MutableStateFlow
7 | import platform.AppKit.NSApp
8 | import platform.AppKit.NSApplication
9 | import platform.AppKit.NSApplicationDelegateProtocol
10 | import platform.Foundation.NSDistributedNotificationCenter
11 | import platform.Foundation.NSNotification
12 | import platform.Foundation.NSOperationQueue
13 | import platform.Foundation.NSUserDefaults
14 | import platform.darwin.NSObject
15 | import kotlin.system.exitProcess
16 |
17 | val isDarkThemeState = MutableStateFlow(false)
18 |
19 | class AppDelegate : NSObject(), NSApplicationDelegateProtocol {
20 | override fun applicationShouldTerminateAfterLastWindowClosed(sender: NSApplication): Boolean {
21 | return true
22 | }
23 |
24 | override fun applicationWillTerminate(notification: NSNotification) {
25 | exitProcess(0)
26 | }
27 |
28 | override fun applicationDidFinishLaunching(notification: NSNotification) {
29 | updateThemeMode()
30 |
31 | NSDistributedNotificationCenter.defaultCenter().addObserverForName(
32 | name = "AppleInterfaceThemeChangedNotification",
33 | `object` = null,
34 | queue = NSOperationQueue.mainQueue,
35 | usingBlock = { _: NSNotification? ->
36 | this.updateThemeMode()
37 | }
38 | )
39 | }
40 |
41 | private fun updateThemeMode() {
42 | val defaults = NSUserDefaults.standardUserDefaults
43 | val interfaceStyle = defaults.stringForKey("AppleInterfaceStyle")
44 | val isCurrentlyDark = interfaceStyle != null && interfaceStyle.equals("Dark", ignoreCase = true)
45 | if (isDarkThemeState.value != isCurrentlyDark) isDarkThemeState.value = isCurrentlyDark
46 | }
47 | }
48 |
49 | fun main() {
50 | NSApplication.sharedApplication()
51 | val delegate = AppDelegate()
52 | NSApp?.setDelegate(delegate)
53 |
54 | Window(
55 | title = "Updater",
56 | size = DpSize(1200.dp, 800.dp),
57 | ) {
58 | val isDarkTheme by isDarkThemeState.collectAsState()
59 | App(isDarkTheme)
60 | }
61 |
62 | NSApp?.run()
63 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/misc/KeyStoreUtils.kt:
--------------------------------------------------------------------------------
1 | package misc
2 |
3 | import java.io.File
4 | import java.io.FileInputStream
5 | import java.io.FileOutputStream
6 | import java.security.KeyStore
7 | import javax.crypto.Cipher
8 | import javax.crypto.KeyGenerator
9 | import javax.crypto.SecretKey
10 | import javax.crypto.spec.GCMParameterSpec
11 |
12 | object KeyStoreUtils {
13 | private const val KEY_STORE_TYPE = "JCEKS"
14 | private const val KEY_ALIAS = "updater_key_alias"
15 | private const val AES_MODE = "AES/GCM/NoPadding"
16 | private const val JVM_KEY_STORE = "JvmKeyStore"
17 | private val UPDATER_DIR = File(System.getProperty("user.home"), ".updater-kmp")
18 | private val KEY_STORE_FILE = File(UPDATER_DIR, "keystore.jks").absolutePath
19 |
20 | fun generateKey() {
21 | val keyGenerator = KeyGenerator.getInstance("AES")
22 | val secretKey = keyGenerator.generateKey()
23 | val secretKeyEntry = KeyStore.SecretKeyEntry(secretKey)
24 |
25 | val keyStore = KeyStore.getInstance(KEY_STORE_TYPE)
26 | keyStore.load(null, null)
27 | val password = KeyStore.PasswordProtection(JVM_KEY_STORE.toCharArray())
28 | keyStore.setEntry(KEY_ALIAS, secretKeyEntry, password)
29 |
30 | UPDATER_DIR.mkdirs()
31 | FileOutputStream(KEY_STORE_FILE).use { keyStore.store(it, JVM_KEY_STORE.toCharArray()) }
32 | }
33 |
34 | private val secretKey: SecretKey?
35 | get() {
36 | val keyStore = KeyStore.getInstance(KEY_STORE_TYPE)
37 |
38 | val file = File(KEY_STORE_FILE)
39 | if (!file.exists()) return null
40 |
41 | FileInputStream(file).use { keyStore.load(it, JVM_KEY_STORE.toCharArray()) }
42 |
43 | val password = KeyStore.PasswordProtection(JVM_KEY_STORE.toCharArray())
44 | val secretKeyEntry = keyStore.getEntry(KEY_ALIAS, password) as KeyStore.SecretKeyEntry
45 | return secretKeyEntry.secretKey
46 | }
47 |
48 | @Throws(Exception::class)
49 | fun getEncryptionCipher(): Cipher {
50 | val cipher = Cipher.getInstance(AES_MODE)
51 | cipher.init(Cipher.ENCRYPT_MODE, secretKey)
52 | return cipher
53 | }
54 |
55 | @Throws(Exception::class)
56 | fun getDecryptionCipher(iv: ByteArray): Cipher? {
57 | if (secretKey == null) return null
58 | val cipher = Cipher.getInstance(AES_MODE)
59 | cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, iv))
60 | return cipher
61 | }
62 | }
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | android-gradle-plugin = "8.13.0"
3 | androidx-activity-compose = "1.11.0"
4 | compose-hotReload = "1.0.0-rc03"
5 | compose-plugin = "1.9.3"
6 | cryptography = "0.5.0"
7 | haze = "1.7.0"
8 | image-loader = "1.10.0"
9 | jna = "5.18.1"
10 | kotlin = "2.2.21"
11 | kotlinx-serialization-json = "1.9.0"
12 | kotlinx-datetime = "0.7.1"
13 | ktor-client = "3.3.2"
14 | miuix = "0.6.1"
15 |
16 | [libraries]
17 | androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
18 | cryptography-core = { module = "dev.whyoleg.cryptography:cryptography-core", version.ref = "cryptography" }
19 | cryptography-provider-apple = { module = "dev.whyoleg.cryptography:cryptography-provider-apple", version.ref = "cryptography" }
20 | cryptography-provider-jdk = { module = "dev.whyoleg.cryptography:cryptography-provider-jdk", version.ref = "cryptography" }
21 | cryptography-provider-webcrypto = { module = "dev.whyoleg.cryptography:cryptography-provider-webcrypto", version.ref = "cryptography" }
22 | haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
23 | image-loader = { module = "io.github.qdsfdhvh:image-loader", version.ref = "image-loader" }
24 | jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
25 | jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
26 | kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
27 | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
28 | ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor-client" }
29 | ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor-client" }
30 | ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor-client" }
31 | ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor-client" }
32 | miuix = { module = "top.yukonga.miuix.kmp:miuix", version.ref = "miuix" }
33 |
34 | [plugins]
35 | android-application = { id = "com.android.application", version.ref = "android-gradle-plugin" }
36 | compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
37 | compose-hotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hotReload" }
38 | jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
39 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
40 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
41 |
--------------------------------------------------------------------------------
/composeApp/src/wasmJsMain/kotlin/Main.wasmJs.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.LaunchedEffect
2 | import androidx.compose.runtime.mutableStateOf
3 | import androidx.compose.runtime.remember
4 | import androidx.compose.ui.ExperimentalComposeUiApi
5 | import androidx.compose.ui.platform.LocalFontFamilyResolver
6 | import androidx.compose.ui.text.font.FontFamily
7 | import androidx.compose.ui.text.platform.Font
8 | import androidx.compose.ui.window.ComposeViewport
9 | import io.ktor.client.fetch.Response
10 | import kotlinx.browser.window
11 | import kotlinx.coroutines.await
12 | import org.khronos.webgl.ArrayBuffer
13 | import org.khronos.webgl.Int8Array
14 | import kotlin.wasm.unsafe.UnsafeWasmMemoryApi
15 | import kotlin.wasm.unsafe.withScopedMemoryAllocator
16 |
17 | private const val MiSanVF = "./MiSans VF.woff2"
18 |
19 | @OptIn(ExperimentalComposeUiApi::class)
20 | fun main() {
21 | ComposeViewport(
22 | viewportContainerId = "composeApplication"
23 | ) {
24 | val fontFamilyResolver = LocalFontFamilyResolver.current
25 | val fontsLoaded = remember { mutableStateOf(false) }
26 |
27 | if (fontsLoaded.value) {
28 | hideLoading()
29 | App()
30 | }
31 |
32 | LaunchedEffect(Unit) {
33 | val miSanVFBytes = loadRes(MiSanVF).toByteArray()
34 | val fontFamily = FontFamily(Font("MiSans VF", miSanVFBytes))
35 | fontFamilyResolver.preload(fontFamily)
36 | fontsLoaded.value = true
37 | }
38 | }
39 | }
40 |
41 | @OptIn(ExperimentalWasmJsInterop::class)
42 | suspend fun loadRes(url: String): ArrayBuffer {
43 | return window.fetch(url).await().arrayBuffer().await()
44 | }
45 |
46 | fun ArrayBuffer.toByteArray(): ByteArray {
47 | val source = Int8Array(this, 0, byteLength)
48 | return jsInt8ArrayToKotlinByteArray(source)
49 | }
50 |
51 | @OptIn(ExperimentalWasmJsInterop::class)
52 | @JsFun(
53 | """
54 | function hideLoading() {
55 | document.getElementById('loading').style.display = 'none';
56 | document.getElementById('composeApplication').style.display = 'block';
57 | }
58 | """
59 | )
60 | external fun hideLoading()
61 |
62 | @OptIn(ExperimentalWasmJsInterop::class)
63 | @JsFun(
64 | """ (src, size, dstAddr) => {
65 | const mem8 = new Int8Array(wasmExports.memory.buffer, dstAddr, size);
66 | mem8.set(src);
67 | }
68 | """
69 | )
70 | external fun jsExportInt8ArrayToWasm(src: Int8Array, size: Int, dstAddr: Int)
71 |
72 | internal fun jsInt8ArrayToKotlinByteArray(x: Int8Array): ByteArray {
73 | val size = x.length
74 | @OptIn(UnsafeWasmMemoryApi::class)
75 | return withScopedMemoryAllocator { allocator ->
76 | val memBuffer = allocator.allocate(size)
77 | val dstAddress = memBuffer.address.toInt()
78 | jsExportInt8ArrayToWasm(x, size, dstAddress)
79 | ByteArray(size) { i -> (memBuffer + i).loadByte() }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 |
74 |
75 | @rem Execute Gradle
76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
77 |
78 | :end
79 | @rem End local scope for the variables with windows NT shell
80 | if %ERRORLEVEL% equ 0 goto mainEnd
81 |
82 | :fail
83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
84 | rem the _cmd.exe /c_ return code!
85 | set EXIT_CODE=%ERRORLEVEL%
86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
88 | exit /b %EXIT_CODE%
89 |
90 | :mainEnd
91 | if "%OS%"=="Windows_NT" endlocal
92 |
93 | :omega
94 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/utils/MessageUtils.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import androidx.compose.foundation.layout.Box
4 | import androidx.compose.foundation.layout.fillMaxSize
5 | import androidx.compose.foundation.layout.systemBarsPadding
6 | import androidx.compose.material3.Snackbar
7 | import androidx.compose.material3.SnackbarDuration
8 | import androidx.compose.material3.SnackbarHost
9 | import androidx.compose.material3.SnackbarHostState
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.LaunchedEffect
12 | import androidx.compose.runtime.mutableStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.runtime.rememberCoroutineScope
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import kotlinx.coroutines.Job
18 | import kotlinx.coroutines.delay
19 | import kotlinx.coroutines.launch
20 | import platform.showToast
21 | import platform.useToast
22 | import top.yukonga.miuix.kmp.theme.MiuixTheme
23 |
24 | class MessageUtils {
25 | companion object {
26 | private val snackbarMessage = mutableStateOf("")
27 | private var snackbarDuration = mutableStateOf(1000L)
28 | private var isSnackbarVisible = mutableStateOf(false)
29 | private var snackbarCoroutineJob: Job? = null
30 | private var snackbarKey = mutableStateOf(0)
31 |
32 | fun showMessage(message: String, duration: Long = 1000L) {
33 | if (useToast()) {
34 | showToast(message, duration)
35 | } else {
36 | snackbarCoroutineJob?.cancel()
37 | snackbarMessage.value = message
38 | snackbarDuration.value = duration
39 | isSnackbarVisible.value = true
40 | snackbarKey.value++
41 | }
42 | }
43 |
44 | @Composable
45 | fun Snackbar() {
46 | val snackbarHostState = remember { SnackbarHostState() }
47 | val snackCoroutineScope = rememberCoroutineScope()
48 |
49 | Box(
50 | modifier = Modifier
51 | .fillMaxSize()
52 | .systemBarsPadding(),
53 | contentAlignment = Alignment.BottomCenter
54 | ) {
55 | SnackbarHost(
56 | hostState = snackbarHostState
57 | ) {
58 | Snackbar(
59 | snackbarData = it,
60 | containerColor = MiuixTheme.colorScheme.onBackground,
61 | contentColor = MiuixTheme.colorScheme.background
62 | )
63 | }
64 | }
65 | if (snackbarMessage.value.isNotEmpty()) {
66 | LaunchedEffect(snackbarKey.value) {
67 | snackbarCoroutineJob = snackCoroutineScope.launch {
68 | snackbarHostState.showSnackbar(message = snackbarMessage.value, duration = SnackbarDuration.Indefinite)
69 | isSnackbarVisible.value = false
70 | }
71 | delay(snackbarDuration.value)
72 | snackbarHostState.currentSnackbarData?.dismiss()
73 | }
74 | }
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/composeApp/src/iosMain/kotlin/ResourceEnvironmentFix.kt:
--------------------------------------------------------------------------------
1 | @file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER")
2 | @file:OptIn(ExperimentalResourceApi::class, InternalResourceApi::class)
3 |
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.CompositionLocalProvider
6 | import org.jetbrains.compose.resources.ComposeEnvironment
7 | import org.jetbrains.compose.resources.ExperimentalResourceApi
8 | import org.jetbrains.compose.resources.InternalResourceApi
9 | import org.jetbrains.compose.resources.LanguageQualifier
10 | import org.jetbrains.compose.resources.LocalComposeEnvironment
11 | import org.jetbrains.compose.resources.RegionQualifier
12 | import org.jetbrains.compose.resources.ResourceEnvironment
13 | import org.jetbrains.compose.resources.getResourceEnvironment
14 | import org.jetbrains.compose.resources.getSystemEnvironment
15 | import platform.Foundation.NSLocale
16 | import platform.Foundation.NSLocaleScriptCode
17 | import platform.Foundation.currentLocale
18 | import platform.Foundation.preferredLanguages
19 |
20 | // https://youtrack.jetbrains.com/issue/CMP-6614/iOS-Localization-strings-for-language-qualifiers-that-are-not-the-same-between-platforms-appear-not-translated
21 |
22 | val resourceEnvironmentFix: Unit = run {
23 | getResourceEnvironment = ::myResourceEnvironment
24 | }
25 |
26 | @Composable
27 | fun ResourceEnvironmentFix(content: @Composable () -> Unit) {
28 | resourceEnvironmentFix
29 |
30 | val default = LocalComposeEnvironment.current
31 | CompositionLocalProvider(
32 | LocalComposeEnvironment provides object : ComposeEnvironment {
33 | @Composable
34 | override fun rememberEnvironment(): ResourceEnvironment {
35 | val environment = default.rememberEnvironment()
36 | return mapEnvironment(environment)
37 | }
38 | }
39 | ) {
40 | content()
41 | }
42 | }
43 |
44 | private fun myResourceEnvironment(): ResourceEnvironment {
45 | val environment = getSystemEnvironment()
46 | return mapEnvironment(environment)
47 | }
48 |
49 | private fun mapEnvironment(environment: ResourceEnvironment): ResourceEnvironment {
50 | val locale = NSLocale.preferredLanguages.firstOrNull()
51 | ?.let { NSLocale(it as String) }
52 | ?: NSLocale.currentLocale
53 | val script = locale.objectForKey(NSLocaleScriptCode) as? String
54 |
55 | return ResourceEnvironment(
56 | language = when (environment.language.language) {
57 | "he" -> LanguageQualifier("iw")
58 | "id" -> LanguageQualifier("in")
59 | else -> environment.language
60 | },
61 | region = when (environment.language.language) {
62 | "en" -> when (environment.region.region) {
63 | "" -> RegionQualifier("")
64 | "US" -> RegionQualifier("")
65 | "AU" -> RegionQualifier("AU")
66 | else -> RegionQualifier("GB")
67 | }
68 |
69 | "zh" -> when (script) {
70 | "Hans" -> RegionQualifier("CN")
71 | "Hant" -> RegionQualifier("TW")
72 | else -> environment.region
73 | }
74 |
75 | else -> environment.region
76 | },
77 | theme = environment.theme,
78 | density = environment.density
79 | )
80 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/theme/WindowsThemeManager.kt:
--------------------------------------------------------------------------------
1 | package theme
2 |
3 | import com.sun.jna.Native
4 | import com.sun.jna.Pointer
5 | import com.sun.jna.platform.win32.Advapi32
6 | import com.sun.jna.platform.win32.Advapi32Util
7 | import com.sun.jna.platform.win32.WinDef
8 | import com.sun.jna.platform.win32.WinDef.HWND
9 | import com.sun.jna.platform.win32.WinError
10 | import com.sun.jna.platform.win32.WinNT
11 | import com.sun.jna.platform.win32.WinReg
12 | import com.sun.jna.win32.StdCallLibrary
13 | import kotlinx.coroutines.currentCoroutineContext
14 | import kotlinx.coroutines.isActive
15 |
16 | object WindowsThemeManager {
17 | private const val REGISTRY_KEY_PATH = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
18 | private const val REGISTRY_VALUE_NAME = "AppsUseLightTheme"
19 |
20 | private interface DwmApi : StdCallLibrary {
21 | fun DwmSetWindowAttribute(
22 | hwnd: HWND,
23 | dwAttribute: Int,
24 | pvAttribute: Pointer,
25 | cbAttribute: Int
26 | ): Int
27 |
28 | companion object {
29 | val INSTANCE: DwmApi by lazy {
30 | Native.load("dwmapi", DwmApi::class.java)
31 | }
32 | const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20
33 | }
34 | }
35 |
36 | fun isWindowsDarkTheme(): Boolean {
37 | return try {
38 | val value = Advapi32Util.registryGetIntValue(
39 | WinReg.HKEY_CURRENT_USER,
40 | REGISTRY_KEY_PATH,
41 | REGISTRY_VALUE_NAME
42 | )
43 | value == 0
44 | } catch (_: Exception) {
45 | false
46 | }
47 | }
48 |
49 | fun setWindowsTitleBarTheme(window: java.awt.Window, isDark: Boolean) {
50 | try {
51 | val hwnd = HWND(Native.getComponentPointer(window))
52 | val darkModeValue = WinDef.BOOLByReference(WinDef.BOOL(isDark))
53 |
54 | DwmApi.INSTANCE.DwmSetWindowAttribute(
55 | hwnd,
56 | DwmApi.DWMWA_USE_IMMERSIVE_DARK_MODE,
57 | darkModeValue.pointer,
58 | 4,
59 | )
60 | } catch (_: Throwable) {
61 | }
62 | }
63 |
64 | suspend fun listenWindowsThemeChanges(onThemeChanged: (isDark: Boolean) -> Unit) {
65 | val advapi32 = Advapi32.INSTANCE
66 | val hKeyByRef = WinReg.HKEYByReference()
67 |
68 | val openResult = advapi32.RegOpenKeyEx(
69 | WinReg.HKEY_CURRENT_USER,
70 | REGISTRY_KEY_PATH,
71 | 0,
72 | WinNT.KEY_NOTIFY,
73 | hKeyByRef,
74 | )
75 |
76 | if (openResult != WinError.ERROR_SUCCESS) return
77 |
78 | val hKey = hKeyByRef.value
79 | try {
80 | while (currentCoroutineContext().isActive) {
81 | val notifyResult = advapi32.RegNotifyChangeKeyValue(
82 | hKey,
83 | false,
84 | WinNT.REG_NOTIFY_CHANGE_LAST_SET,
85 | null,
86 | false
87 | )
88 |
89 | if (!currentCoroutineContext().isActive) break
90 | if (notifyResult == WinError.ERROR_SUCCESS) {
91 | val currentSystemThemeIsDark = isWindowsDarkTheme()
92 | onThemeChanged(currentSystemThemeIsDark)
93 | } else {
94 | break
95 | }
96 | }
97 | } finally {
98 | advapi32.RegCloseKey(hKey)
99 | }
100 | }
101 | }
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/Main.desktop.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.LaunchedEffect
2 | import androidx.compose.runtime.getValue
3 | import androidx.compose.runtime.mutableStateOf
4 | import androidx.compose.runtime.remember
5 | import androidx.compose.runtime.setValue
6 | import androidx.compose.ui.Alignment
7 | import androidx.compose.ui.unit.DpSize
8 | import androidx.compose.ui.unit.dp
9 | import androidx.compose.ui.window.Window
10 | import androidx.compose.ui.window.WindowPosition
11 | import androidx.compose.ui.window.application
12 | import androidx.compose.ui.window.rememberWindowState
13 | import com.sun.jna.Platform.isLinux
14 | import com.sun.jna.Platform.isMac
15 | import com.sun.jna.Platform.isWindows
16 | import kotlinx.coroutines.Dispatchers
17 | import kotlinx.coroutines.withContext
18 | import org.jetbrains.compose.resources.painterResource
19 | import org.jetbrains.compose.resources.stringResource
20 | import theme.LinuxThemeManager
21 | import theme.MacOSThemeManager
22 | import theme.WindowsThemeManager
23 | import updater.composeapp.generated.resources.Res
24 | import updater.composeapp.generated.resources.app_name
25 | import updater.composeapp.generated.resources.icon
26 | import javax.swing.SwingUtilities
27 |
28 | fun main() = application {
29 | val state = rememberWindowState(
30 | size = DpSize(1200.dp, 800.dp),
31 | position = WindowPosition.Aligned(Alignment.Center)
32 | )
33 |
34 | Window(
35 | state = state,
36 | onCloseRequest = ::exitApplication,
37 | title = stringResource(Res.string.app_name),
38 | icon = painterResource(Res.drawable.icon),
39 | ) {
40 | when {
41 | isWindows() -> {
42 | var isDarkTheme by remember { mutableStateOf(WindowsThemeManager.isWindowsDarkTheme()) }
43 | LaunchedEffect(Unit) {
44 | withContext(Dispatchers.IO) {
45 | WindowsThemeManager.listenWindowsThemeChanges { newSystemThemeIsDark ->
46 | if (isDarkTheme != newSystemThemeIsDark) isDarkTheme = newSystemThemeIsDark
47 | }
48 | }
49 | }
50 | LaunchedEffect(isDarkTheme, window) {
51 | SwingUtilities.invokeLater {
52 | WindowsThemeManager.setWindowsTitleBarTheme(window, isDarkTheme)
53 | }
54 | }
55 | App(isDarkTheme)
56 | }
57 |
58 | isMac() -> {
59 | var isDarkTheme by remember { mutableStateOf(MacOSThemeManager.isMacOSDarkTheme()) }
60 | LaunchedEffect(Unit) {
61 | withContext(Dispatchers.IO) {
62 | MacOSThemeManager.listenMacOSThemeChanges { newSystemThemeIsDark ->
63 | if (isDarkTheme != newSystemThemeIsDark) isDarkTheme = newSystemThemeIsDark
64 | }
65 | }
66 | }
67 | App(isDarkTheme)
68 | }
69 |
70 | isLinux() -> {
71 | var isDarkTheme by remember { mutableStateOf(LinuxThemeManager.isLinuxDarkTheme()) }
72 | LaunchedEffect(Unit) {
73 | withContext(Dispatchers.IO) {
74 | LinuxThemeManager.listenLinuxThemeChanges { newSystemThemeIsDark ->
75 | if (isDarkTheme != newSystemThemeIsDark) isDarkTheme = newSystemThemeIsDark
76 | }
77 | }
78 | }
79 | App(isDarkTheme)
80 | }
81 |
82 | else -> {
83 | App()
84 | }
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/utils/DeviceListUtils.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import data.DeviceInfoHelper
4 | import io.ktor.client.request.get
5 | import io.ktor.client.statement.bodyAsText
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.withContext
8 | import kotlinx.serialization.json.Json
9 | import platform.httpClientPlatform
10 | import platform.prefGet
11 | import platform.prefSet
12 |
13 | /**
14 | * Manages device list updates
15 | */
16 | object DeviceListUtils {
17 | private const val DEVICE_LIST_URL = "https://raw.githubusercontent.com/YuKongA/Updater-KMP/device-list/device.json"
18 | private const val DEVICE_LIST_CACHED_KEY = "deviceListCached"
19 | private const val DEVICE_LIST_VERSION_KEY = "deviceListVersion"
20 | private const val DEVICE_LIST_SOURCE_KEY = "deviceListSource"
21 | private val json = Json { ignoreUnknownKeys = true }
22 |
23 | /**
24 | * Get cached device list, or null if no cached data exists
25 | */
26 | fun getCachedDeviceList(): List? {
27 | val cachedData = prefGet(DEVICE_LIST_CACHED_KEY) ?: return null
28 | return try {
29 | val remoteData = json.decodeFromString(cachedData)
30 | remoteData.devices
31 | } catch (_: Exception) {
32 | null
33 | }
34 | }
35 |
36 | /**
37 | * Get cached device list version
38 | */
39 | fun getCachedVersion(): String? = prefGet(DEVICE_LIST_VERSION_KEY)
40 |
41 | /**
42 | * Clean JSON string by removing trailing commas
43 | */
44 | private fun cleanJson(json: String): String {
45 | return json.replace(Regex(""",\s*([}\]])"""), "$1")
46 | }
47 |
48 | /**
49 | * Fetch and cache device list from remote source
50 | * Returns updated device list or null if update failed
51 | */
52 | suspend fun updateDeviceList(): Int {
53 | return try {
54 | withContext(Dispatchers.Default) {
55 | val client = httpClientPlatform()
56 | val response = client.get(DEVICE_LIST_URL)
57 | val jsonContent = cleanJson(response.bodyAsText())
58 | val remoteData = json.decodeFromString(jsonContent)
59 |
60 | val currentVersion = getCachedVersion()
61 | val currentData = getCachedDeviceList()
62 | if (currentVersion != null && currentData != null && currentVersion >= remoteData.version) {
63 | return@withContext 1
64 | }
65 |
66 | prefSet(DEVICE_LIST_VERSION_KEY, remoteData.version)
67 | prefSet(DEVICE_LIST_CACHED_KEY, jsonContent)
68 |
69 | 0
70 | }
71 | } catch (_: Exception) {
72 | 2
73 | }
74 | }
75 |
76 | enum class DeviceListSource { REMOTE, EMBEDDED }
77 |
78 | fun getDeviceListSource(): DeviceListSource {
79 | return when (prefGet(DEVICE_LIST_SOURCE_KEY)) {
80 | "REMOTE" -> DeviceListSource.REMOTE
81 | "EMBEDDED" -> DeviceListSource.EMBEDDED
82 | else -> DeviceListSource.EMBEDDED
83 | }
84 | }
85 |
86 | fun setDeviceListSource(source: DeviceListSource) {
87 | prefSet(DEVICE_LIST_SOURCE_KEY, source.name)
88 | }
89 |
90 | /**
91 | * Get device list based on the selected source
92 | */
93 | fun getDeviceList(embeddedList: List): List {
94 | return when (getDeviceListSource()) {
95 | DeviceListSource.REMOTE -> {
96 | val cachedList = getCachedDeviceList()
97 | if (!cachedList.isNullOrEmpty()) {
98 | return cachedList
99 | }
100 | embeddedList
101 | }
102 |
103 | DeviceListSource.EMBEDDED -> embeddedList
104 | }
105 | }
106 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
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 | 未获取到 ultimate 链接!
32 | 账号
33 | 密码
34 | 保存密码
35 | 取消
36 | 正在登录…
37 | 已登录
38 | 登录验证失败!
39 | 登录已过期
40 | 建议重新登录
41 | 登录成功
42 | 账号或密码为空!
43 | 未登录
44 | 正在使用 v1 接口
45 | 正在使用 v2 接口
46 | 复制成功
47 | 开始下载
48 | 获取密钥失败
49 | 退出
50 | 确定要退出登录么?
51 | 确定
52 | 退出成功
53 | 区域代号
54 | 运营商代号
55 | 全球账号
56 | 扩展设置
57 | 显示模式
58 | 跟随系统
59 | 浅色模式
60 | 深色模式
61 | 设备列表设置
62 | 来源
63 | 远程
64 | 内置
65 | 正在更新…
66 | 立即更新
67 | 更新成功
68 | 更新失败
69 | 无可用更新
70 | 版本
71 | 检测到二次认证,请点击获取验证码按钮
72 | 需要输入验证码,请点击获取验证码按钮
73 | 验证码
74 | 发送邮箱验证码
75 | 发送手机验证码
76 | 发送验证码失败,请尝试其他验证方式!
77 | 验证中…
78 | 版权所有 © 2024-2025 YuKongA
79 |
80 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml:
--------------------------------------------------------------------------------
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 | 未獲取到 ultimate 連結!
32 | 帳號
33 | 密碼
34 | 儲存密碼
35 | 取消
36 | 登入中…
37 | 已登入
38 | 登入驗證失敗!
39 | 登入已過期
40 | 建議重新登入
41 | 登入成功
42 | 帳號或密碼為空!
43 | 未登入
44 | 正在使用 v1 埠
45 | 正在使用 v2 埠
46 | 複製成功
47 | 開始下載
48 | 取得金鑰失敗
49 | 退出
50 | 確定要退出登入嗎?
51 | 確定
52 | 退出成功
53 | 區域代號
54 | 營運商代號
55 | 全球帳號
56 | 擴展設定
57 | 顯示模式
58 | 跟隨系統
59 | 淺色模式
60 | 深色模式
61 | 裝置列表設定
62 | 來源
63 | 遠端
64 | 內建
65 | 正在更新…
66 | 立即更新
67 | 更新成功
68 | 更新失敗
69 | 無可用更新
70 | 版本
71 | 檢測到二次認證,請點擊獲取驗證碼按鈕
72 | 需要輸入驗證碼,請點擊獲取驗證碼按鈕
73 | 驗證碼
74 | 發送郵箱驗證碼
75 | 發送手機驗證碼
76 | 發送驗證碼失敗,請嘗試其他驗證方式!
77 | 驗證中…
78 | 版權所有 © 2024-2025 YuKongA
79 |
80 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/LoginCardView.kt:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import androidx.compose.foundation.layout.Arrangement
4 | import androidx.compose.foundation.layout.Column
5 | import androidx.compose.foundation.layout.PaddingValues
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.MutableState
12 | import androidx.compose.runtime.mutableStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.ui.Alignment
15 | import androidx.compose.ui.Modifier
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.text.font.FontWeight
18 | import androidx.compose.ui.unit.dp
19 | import isWeb
20 | import org.jetbrains.compose.resources.stringResource
21 | import top.yukonga.miuix.kmp.basic.Card
22 | import top.yukonga.miuix.kmp.basic.CardDefaults
23 | import top.yukonga.miuix.kmp.basic.Icon
24 | import top.yukonga.miuix.kmp.basic.Text
25 | import top.yukonga.miuix.kmp.icon.MiuixIcons
26 | import top.yukonga.miuix.kmp.icon.icons.useful.Confirm
27 | import top.yukonga.miuix.kmp.icon.icons.useful.Info
28 | import top.yukonga.miuix.kmp.theme.MiuixTheme
29 | import top.yukonga.miuix.kmp.utils.PressFeedbackType
30 | import updater.composeapp.generated.resources.Res
31 | import updater.composeapp.generated.resources.logged_in
32 | import updater.composeapp.generated.resources.login_desc
33 | import updater.composeapp.generated.resources.login_expired
34 | import updater.composeapp.generated.resources.login_expired_desc
35 | import updater.composeapp.generated.resources.no_account
36 | import updater.composeapp.generated.resources.using_v2
37 |
38 | @Composable
39 | fun LoginCardView(
40 | isLogin: MutableState,
41 | isDarkTheme: Boolean
42 | ) {
43 | val account = when (isLogin.value) {
44 | 1 -> stringResource(Res.string.logged_in)
45 | 0 -> stringResource(Res.string.no_account)
46 | else -> stringResource(Res.string.login_expired)
47 | }
48 | val info = when (isLogin.value) {
49 | 1 -> stringResource(Res.string.using_v2)
50 | 0 -> stringResource(Res.string.login_desc)
51 | else -> stringResource(Res.string.login_expired_desc)
52 | }
53 | val icon = if (isLogin.value == 1) MiuixIcons.Useful.Confirm else MiuixIcons.Useful.Info
54 | val color = when {
55 | isDarkTheme && isLogin.value == 1 -> Color(0xFF1A3825)
56 | isDarkTheme && isLogin.value != 1 -> Color(0xFF310808)
57 | !isDarkTheme && isLogin.value == 1 -> Color(0xFFDFFAE4)
58 | else -> Color(0xFFF8E2E2)
59 | }
60 | val showDialog = remember { mutableStateOf(false) }
61 | Card(
62 | modifier = Modifier
63 | .fillMaxWidth()
64 | .padding(all = 12.dp),
65 | insideMargin = PaddingValues(16.dp),
66 | pressFeedbackType = PressFeedbackType.Sink,
67 | onLongPress = { showDialog.value = true },
68 | colors = CardDefaults.defaultColors(color = color),
69 | ) {
70 | Row(
71 | verticalAlignment = Alignment.CenterVertically,
72 | horizontalArrangement = Arrangement.SpaceBetween
73 | ) {
74 | Icon(
75 | modifier = Modifier.padding(start = 6.dp),
76 | imageVector = icon,
77 | tint = MiuixTheme.colorScheme.onSurface,
78 | contentDescription = null
79 | )
80 | Column(
81 | modifier = Modifier.padding(start = 20.dp)
82 | ) {
83 | Text(
84 | text = if (!isWeb()) account else "WebPage",
85 | fontWeight = FontWeight.SemiBold
86 | )
87 | Text(
88 | text = info
89 | )
90 | }
91 | Spacer(modifier = Modifier.weight(1f))
92 | if (!isWeb()) {
93 | LoginDialog(showDialog, isLogin)
94 | }
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/values-ja-rJP/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 概要
4 | ログイン
5 | ソース コードを表示:
6 | チャンネルに参加:
7 | コードネーム
8 | デバイス名
9 | システムバージョン
10 | Android バージョン
11 | 送信
12 | 検索履歴
13 | 検索履歴を消去
14 | メジャーバージョン
15 | ファイル名
16 | ファイルサイズ
17 | ファイル MD5
18 | ダウンロード URL
19 | 変更履歴
20 | ブランチ
21 | タグ
22 | フィンガープリント
23 | セキュリティ パッチ レベル
24 | ビルド時間
25 | 注意
26 | 検索中…
27 | 情報がありません!
28 | 要求されたバージョンは存在しません!
29 | リクエストが成功しました!
30 | ネットワーク接続がありません!
31 | ultimate リンクを取得できません!
32 | アカウント
33 | パスワード
34 | パスワードを保存
35 | キャンセル
36 | ログイン中…
37 | ログイン済み
38 | ログイン認証に失敗しました!
39 | ログイン期限切れ
40 | 再ログインをお勧めします
41 | ログイン成功
42 | アカウントまたはパスワードが空です!
43 | アカウントがありません
44 | v1 インターフェースを使用
45 | v2 インターフェースを使用
46 | コピー成功
47 | ダウンロード開始
48 | セキュリティキーの取得に失敗しました
49 | ログアウト
50 | ログアウトしてもよろしいですか?
51 | 確認
52 | ログアウト成功
53 | 地域コード
54 | キャリアコード
55 | グローバルアカウント
56 | 拡張機能の設定
57 | 表示モード
58 | システムに従ってください
59 | ライトモード
60 | ダークモード
61 | デバイスリストの設定
62 | ソース
63 | リモート
64 | 内蔵
65 | 更新中…
66 | 今すぐ更新
67 | 更新成功
68 | 更新に失敗しました
69 | 更新なし
70 | バージョン
71 | 二次認証が検出されました。検証コードの取得ボタンをクリックしてください
72 | 検証コードの入力が必要です。検証コードの取得ボタンをクリックしてください
73 | 検証コードL
74 | メールコードを送信
75 | 電話コードを送信
76 | 検証コードの送信に失敗しました。別の検証方法を試してください!
77 | 検証中…
78 | 著作権 © 2024-2025 YuKongA
79 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Updater
4 | About
5 | View Source:
6 | Join Channel:
7 | Login
8 | Code Name
9 | Device Name
10 | System Version
11 | Android Version
12 | Submit
13 | Search History
14 | Clear Search History
15 | Major Version
16 | File Name
17 | File Size
18 | File MD5
19 | Download Url
20 | Changelog
21 | Branch
22 | Tags
23 | Fingerprint
24 | Security Patch Level
25 | Build Time
26 | Attention
27 | Searching…
28 | No information!
29 | Requested version does not exist!
30 | Request successful!
31 | No network connection!
32 | Unable to get ultimate link!
33 | Account
34 | Password
35 | Save password
36 | Cancel
37 | Logging in…
38 | Logged in
39 | Login verification failed!
40 | Login expired
41 | Recommended to login again
42 | Login successful
43 | Account or Password empty!
44 | No account
45 | Using v1 interface
46 | Using v2 interface
47 | Copy successful
48 | Start download
49 | Failed to get security key
50 | Logout
51 | Are you sure you want to logout?
52 | Confirm
53 | Logout successful
54 | Region code
55 | Carrier code
56 | Global account
57 | Extension settings
58 | Display mode
59 | System default
60 | Light mode
61 | Dark mode
62 | Device List Settings
63 | Source
64 | Remote
65 | Embedded
66 | Updating…
67 | Update Now
68 | Update successful
69 | Update failed
70 | No updates
71 | Version
72 | Detected two-factor authentication, please click the get verification code button
73 | Verification code is required, please click the get verification code button
74 | Verification Code
75 | Send email code
76 | Send phone code
77 | Failed to send verification code, please try other verification methods!
78 | Verifying…
79 | Copyright © 2024-2025 YuKongA
80 |
81 |
--------------------------------------------------------------------------------
/.github/workflows/Action CI.yml:
--------------------------------------------------------------------------------
1 | name: Action CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | paths-ignore:
7 | - 'README.md'
8 | - 'LICENSE'
9 |
10 | permissions:
11 | contents: read
12 | actions: write
13 |
14 | jobs:
15 | build:
16 | runs-on: ${{ matrix.os }}
17 | strategy:
18 | matrix:
19 | os: [ macos-latest, ubuntu-latest, ubuntu-24.04-arm, windows-latest ]
20 | include:
21 | - os: macos-latest
22 | platform: macos arm64
23 | build-command: ./gradlew packageDmgNativeReleaseMacosArm64
24 | artifact-path: composeApp/build/compose/binaries/main/native-macosArm64-release-dmg
25 | artifact-name: Updater-darwin-arm64-dmg
26 | jdk-distribution: zulu
27 | - os: ubuntu-latest
28 | platform: linux x64
29 | platformEx: android aarch64
30 | build-command: ./gradlew createReleaseDistributable
31 | build-commandEx: ./gradlew assembleDebug && ./gradlew assembleRelease
32 | artifact-path: composeApp/build/compose/binaries/main-release/app/Updater
33 | artifact-pathEx: composeApp/build/outputs/apk/release
34 | artifact-name: Updater-linux-x64-bin
35 | artifact-nameEx: Updater-android-universal-apk
36 | jdk-distribution: zulu
37 | - os: ubuntu-24.04-arm
38 | platform: linux arm64
39 | build-command: ./gradlew createReleaseDistributable
40 | artifact-path: composeApp/build/compose/binaries/main-release/app/Updater
41 | artifact-name: Updater-linux-arm64-bin
42 | jdk-distribution: zulu
43 | - os: windows-latest
44 | platform: windows x64
45 | build-command: ./gradlew createReleaseDistributable
46 | artifact-path: composeApp/build/compose/binaries/main-release/app/Updater
47 | artifact-name: Updater-windows-x64-exe
48 | jdk-distribution: zulu
49 |
50 | steps:
51 | - name: Checkout sources
52 | uses: actions/checkout@v5
53 | with:
54 | fetch-depth: 0
55 |
56 | - name: Setup JDK
57 | uses: actions/setup-java@v5
58 | with:
59 | distribution: ${{ matrix.jdk-distribution }}
60 | java-version: '21'
61 |
62 | - name: Setup Gradle
63 | uses: gradle/actions/setup-gradle@v5
64 |
65 | - name: Decode android signing key
66 | if: matrix.platformEx == 'android aarch64'
67 | run: echo ${{ secrets.SIGNING_KEY }} | base64 -d > keystore.jks
68 |
69 | - name: Build ${{ matrix.platform }} platform
70 | run: ${{ matrix.build-command }}
71 |
72 | - name: Build ${{ matrix.platformEx }} platform
73 | if: matrix.platformEx == 'android aarch64'
74 | run: ${{ matrix.build-commandEx }}
75 | env:
76 | KEYSTORE_PATH: "../keystore.jks"
77 | KEYSTORE_PASS: ${{ secrets.KEY_STORE_PASSWORD }}
78 | KEY_ALIAS: ${{ secrets.ALIAS }}
79 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
80 |
81 | - name: Upload Updater ${{ matrix.platform }} artifact
82 | uses: actions/upload-artifact@v5
83 | with:
84 | name: ${{ matrix.artifact-name }}
85 | path: ${{ matrix.artifact-path }}
86 |
87 | - name: Upload Updater ${{ matrix.platformEx }} artifact
88 | if: matrix.platformEx == 'android aarch64'
89 | uses: actions/upload-artifact@v5
90 | with:
91 | name: ${{ matrix.artifact-nameEx }}
92 | path: ${{ matrix.artifact-pathEx }}
93 |
94 | - name: Post to Telegram ci channel
95 | if: ${{ success() && matrix.platformEx == 'android aarch64' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.ref_type != 'tag' }}
96 | env:
97 | CHANNEL_ID: ${{ secrets.CHANNEL_ID }}
98 | BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
99 | COMMIT_MESSAGE: |+
100 | New CI from Updater\-KMP
101 |
102 | ```
103 | ${{ github.event.head_commit.message }}
104 | ```
105 | run: |
106 | if [ ! -z "${{ secrets.BOT_TOKEN }}" ]; then
107 | export RELEASE=$(find ./composeApp/build/outputs/apk/release -name "*.apk")
108 | export DEBUG=$(find ./composeApp/build/outputs/apk/debug -name "*.apk")
109 | ESCAPED=`python3 -c 'import json,os,urllib.parse; print(urllib.parse.quote(json.dumps(os.environ["COMMIT_MESSAGE"])))'`
110 | curl -v "https://api.telegram.org/bot${BOT_TOKEN}/sendMediaGroup?chat_id=${CHANNEL_ID}&media=%5B%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2Frelease%22%7D%2C%7B%22type%22%3A%22document%22%2C%20%22media%22%3A%22attach%3A%2F%2Fdebug%22%2C%22parse_mode%22%3A%22MarkdownV2%22%2C%22caption%22%3A${ESCAPED}%7D%5D" -F release="@$RELEASE" -F debug="@$DEBUG"
111 | fi
--------------------------------------------------------------------------------
/composeApp/src/commonMain/composeResources/values-pt-rBR/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sobre
4 | Entrar
5 | Ver Código Fonte:
6 | Junte-se ao canal:
7 | Codename
8 | Nome do Dispositivo
9 | Versão do Sistema
10 | Versão do Android
11 | Enviar
12 | Histórico de Pesquisa
13 | Limpar histórico de Pesquisa
14 | Versão Principal
15 | Nome do Arquivo
16 | Tamanho do Arquivo
17 | MD5 do Arquivo
18 | URL de Download
19 | Registro de Alterações
20 | Ramo
21 | Etiquetas
22 | Impressão digital
23 | Nível do patch de segurança
24 | Hora da construção
25 | ATENÇÃO
26 | Pesquisando…
27 | Sem informações!
28 | A versão solicitada não existe!
29 | Solicitação bem-sucedida!
30 | Sem conexão com a rede!
31 | Não é possível obter o link ultimate!
32 | Conta
33 | Senha
34 | Salvar senha
35 | Cancelar
36 | Entrando…
37 | Logado
38 | Falha na verificação de login!
39 | Login expirado
40 | Recomendado fazer login novamente
41 | Login bem-sucedido
42 | Conta ou Senha vazia!
43 | Sem conta
44 | Usando interface v1
45 | Usando interface v2
46 | Cópia bem-sucedida
47 | Iniciar download
48 | Falha ao obter chave de segurança
49 | Sair
50 | Tem certeza de que deseja sair?
51 | Confirmar
52 | Logout bem-sucedido
53 | Código de Regiões
54 | Código da Operadora
55 | Conta global
56 | Configurações de Extensão
57 | Modo de exibição
58 | Padrão do sistema
59 | Modo claro
60 | Modo escuro
61 | Configurações da Lista de Dispositivos
62 | Fonte
63 | Remoto
64 | Embutido
65 | Atualizando...
66 | Atualizar agora
67 | Atualização bem-sucedida
68 | A atualização falhou
69 | Nenhuma atualização disponível
70 | Versão
71 | Detectada autenticação de dois fatores, clique no botão Obter código de verificação
72 | É necessário inserir o código de verificação, clique no botão Obter código de verificação
73 | Obter código de verificação
74 | Enviar código de e-mail
75 | Enviar código de telefone
76 | Falha ao enviar o código de verificação, tente outros métodos de verificação!
77 | Verificando...
78 | Copyright © 2024-2025 YuKongA
79 |
80 |
--------------------------------------------------------------------------------
/composeApp/src/desktopMain/kotlin/theme/LinuxThemeManager.kt:
--------------------------------------------------------------------------------
1 | package theme
2 |
3 | import kotlinx.coroutines.currentCoroutineContext
4 | import kotlinx.coroutines.delay
5 | import kotlinx.coroutines.isActive
6 | import java.io.BufferedReader
7 | import java.io.InputStreamReader
8 | import java.util.concurrent.ConcurrentHashMap
9 | import java.util.function.Consumer
10 |
11 | object LinuxThemeManager {
12 | private val darkThemeRegex = ".*dark.*".toRegex(RegexOption.IGNORE_CASE)
13 |
14 | private val GET_THEME_COMMANDS = arrayOf(
15 | "gsettings get org.gnome.desktop.interface gtk-theme",
16 | "gsettings get org.gnome.desktop.interface color-scheme"
17 | )
18 |
19 | private const val MONITORING_CMD = "gsettings monitor org.gnome.desktop.interface"
20 |
21 | @Volatile
22 | private var monitoringThread: Thread? = null
23 |
24 | private val listeners: MutableSet> = ConcurrentHashMap.newKeySet()
25 |
26 | fun isLinuxDarkTheme(): Boolean {
27 | return try {
28 | for (cmd in GET_THEME_COMMANDS) {
29 | val cmdParts = cmd.split(" ")
30 | val process = ProcessBuilder(*cmdParts.toTypedArray()).start()
31 | BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
32 | val line = reader.readLine()
33 | if (line != null && isDarkTheme(line)) return true
34 | }
35 | }
36 | false
37 | } catch (_: Exception) {
38 | false
39 | }
40 | }
41 |
42 | suspend fun listenLinuxThemeChanges(onThemeChanged: (Boolean) -> Unit) {
43 | try {
44 | val listener = Consumer { isDark ->
45 | onThemeChanged(isDark)
46 | }
47 | registerListener(listener)
48 |
49 | while (currentCoroutineContext().isActive) {
50 | delay(1000)
51 | }
52 |
53 | removeListener(listener)
54 | } catch (_: Exception) {
55 | var lastValue = isLinuxDarkTheme()
56 | while (currentCoroutineContext().isActive) {
57 | val currentValue = isLinuxDarkTheme()
58 | if (currentValue != lastValue) {
59 | lastValue = currentValue
60 | onThemeChanged(currentValue)
61 | }
62 | delay(2000)
63 | }
64 | }
65 | }
66 |
67 | private fun isDarkTheme(text: String): Boolean {
68 | return darkThemeRegex.containsMatchIn(text)
69 | }
70 |
71 | private fun registerListener(listener: Consumer) {
72 | val wasEmpty = listeners.isEmpty()
73 | listeners.add(listener)
74 | if (wasEmpty) {
75 | startMonitoring()
76 | }
77 | }
78 |
79 | private fun removeListener(listener: Consumer) {
80 | listeners.remove(listener)
81 | if (listeners.isEmpty()) {
82 | monitoringThread?.interrupt()
83 | monitoringThread = null
84 | }
85 | }
86 |
87 | private fun startMonitoring() {
88 | if (monitoringThread?.isAlive == true) return
89 |
90 | monitoringThread = Thread {
91 | var lastValue = isLinuxDarkTheme()
92 |
93 | try {
94 | val cmdParts = MONITORING_CMD.split(" ")
95 | val process = ProcessBuilder(*cmdParts.toTypedArray()).start()
96 | BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
97 | while (!Thread.currentThread().isInterrupted) {
98 | val line = reader.readLine() ?: break
99 |
100 | if (!line.contains("gtk-theme", ignoreCase = true) &&
101 | !line.contains("color-scheme", ignoreCase = true)
102 | ) {
103 | continue
104 | }
105 |
106 | val currentIsDark = isLinuxDarkTheme()
107 | if (currentIsDark != lastValue) {
108 | lastValue = currentIsDark
109 |
110 | listeners.forEach {
111 | try {
112 | it.accept(currentIsDark)
113 | } catch (_: Exception) {
114 | }
115 | }
116 | }
117 | }
118 |
119 | if (process.isAlive) {
120 | process.destroy()
121 | }
122 | }
123 | } catch (_: Exception) {
124 | while (!Thread.currentThread().isInterrupted) {
125 | try {
126 | val currentIsDark = isLinuxDarkTheme()
127 | if (currentIsDark != lastValue) {
128 | lastValue = currentIsDark
129 | listeners.forEach { it.accept(currentIsDark) }
130 | }
131 | Thread.sleep(2000)
132 | } catch (_: InterruptedException) {
133 | break
134 | } catch (_: Exception) {
135 | }
136 | }
137 | }
138 | }.apply {
139 | isDaemon = true
140 | start()
141 | }
142 | }
143 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/AboutDialog.kt:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import androidx.compose.foundation.Image
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.Row
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.foundation.layout.size
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.mutableStateOf
12 | import androidx.compose.runtime.remember
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.platform.LocalFocusManager
16 | import androidx.compose.ui.platform.LocalUriHandler
17 | import androidx.compose.ui.text.AnnotatedString
18 | import androidx.compose.ui.text.SpanStyle
19 | import androidx.compose.ui.text.font.FontWeight
20 | import androidx.compose.ui.text.style.TextDecoration
21 | import androidx.compose.ui.unit.dp
22 | import androidx.compose.ui.unit.sp
23 | import misc.VersionInfo
24 | import org.jetbrains.compose.resources.painterResource
25 | import org.jetbrains.compose.resources.stringResource
26 | import top.yukonga.miuix.kmp.basic.IconButton
27 | import top.yukonga.miuix.kmp.basic.Text
28 | import top.yukonga.miuix.kmp.extra.SuperDialog
29 | import top.yukonga.miuix.kmp.theme.MiuixTheme
30 | import top.yukonga.miuix.kmp.utils.Platform
31 | import top.yukonga.miuix.kmp.utils.platform
32 | import updater.composeapp.generated.resources.Res
33 | import updater.composeapp.generated.resources.about
34 | import updater.composeapp.generated.resources.app_name
35 | import updater.composeapp.generated.resources.icon
36 | import updater.composeapp.generated.resources.join_channel
37 | import updater.composeapp.generated.resources.opensource_info
38 | import updater.composeapp.generated.resources.view_source
39 |
40 | @Composable
41 | fun AboutDialog(
42 | ) {
43 | val showDialog = remember { mutableStateOf(false) }
44 | val focusManager = LocalFocusManager.current
45 |
46 | IconButton(
47 | modifier = Modifier.padding(start = if (platform() != Platform.IOS && platform() != Platform.Android) 10.dp else 20.dp),
48 | onClick = {
49 | showDialog.value = true
50 | focusManager.clearFocus()
51 | },
52 | holdDownState = showDialog.value
53 | ) {
54 | Image(
55 | painter = painterResource(Res.drawable.icon),
56 | contentDescription = "About",
57 | modifier = Modifier
58 | .size(32.dp)
59 | .padding(4.dp),
60 | )
61 | }
62 |
63 | SuperDialog(
64 | show = showDialog,
65 | title = stringResource(Res.string.about),
66 | onDismissRequest = {
67 | showDialog.value = false
68 | }
69 | ) {
70 | Row(
71 | modifier = Modifier.padding(bottom = 10.dp),
72 | horizontalArrangement = Arrangement.spacedBy(16.dp),
73 | verticalAlignment = Alignment.CenterVertically
74 | ) {
75 | Image(
76 | painter = painterResource(Res.drawable.icon),
77 | contentDescription = "Icon",
78 | modifier = Modifier.size(45.dp),
79 | )
80 | Column {
81 | Text(
82 | text = stringResource(Res.string.app_name),
83 | fontSize = 22.sp,
84 | fontWeight = FontWeight.SemiBold
85 | )
86 | Text(
87 | text = VersionInfo.VERSION_NAME + " (" + VersionInfo.VERSION_CODE + ")",
88 | )
89 | }
90 | }
91 | val uriHandler = LocalUriHandler.current
92 | Row(
93 | verticalAlignment = Alignment.CenterVertically
94 | ) {
95 | Text(
96 | text = stringResource(Res.string.view_source) + " ",
97 | )
98 | Text(
99 | text = AnnotatedString(
100 | text = "GitHub",
101 | spanStyle = SpanStyle(
102 | textDecoration = TextDecoration.Underline,
103 | color = MiuixTheme.colorScheme.primary
104 | )
105 | ),
106 | modifier = Modifier.clickable(
107 | onClick = {
108 | uriHandler.openUri("https://github.com/YuKongA/Updater-KMP")
109 | }
110 | )
111 | )
112 | }
113 | Row(
114 | verticalAlignment = Alignment.CenterVertically
115 | ) {
116 | Text(
117 | text = stringResource(Res.string.join_channel) + " ",
118 | )
119 | Text(
120 | text = AnnotatedString(
121 | text = "Telegram",
122 | spanStyle = SpanStyle(
123 | textDecoration = TextDecoration.Underline,
124 | color = MiuixTheme.colorScheme.primary
125 | )
126 | ),
127 | modifier = Modifier.clickable(
128 | onClick = {
129 | uriHandler.openUri("https://t.me/YuKongA13579")
130 | },
131 | )
132 | )
133 | }
134 | Text(
135 | modifier = Modifier.padding(top = 10.dp),
136 | text = stringResource(Res.string.opensource_info)
137 | )
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/utils/ZipFileUtils.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import data.FileInfoHelper
4 | import okio.ByteString
5 | import okio.ByteString.Companion.toByteString
6 |
7 | object ZipFileUtils {
8 | private const val CENSIG = 0x02014b50L // "PK\001\002" - Central directory file header signature
9 | private const val LOCSIG = 0x04034b50L // "PK\003\004" - Local file header signature
10 | private const val ENDSIG = 0x06054b50L // "PK\005\006" - End of central directory record signature
11 | private const val ENDHDR = 22 // Minimum size of end of central directory record
12 | private const val ZIP64_ENDSIG = 0x06064b50L // "PK\006\006" - Zip64 end of central directory record signature
13 | private const val ZIP64_LOCSIG = 0x07064b50L // "PK\006\007" - Zip64 end of central directory locator signature
14 | private const val ZIP64_LOCHDR = 20 // Size of Zip64 end of central directory locator
15 | private const val ZIP64_MAGICVAL = 0xFFFFFFFFL // Marker for Zip64 fields
16 |
17 | fun locateCentralDirectory(bytes: ByteArray, fileLength: Long): FileInfoHelper.FileInfo {
18 | val byteString = bytes.toByteString()
19 | val searchStartPos = bytes.size - ENDHDR
20 | var cenSize = -1L
21 | var cenOffset = -1L
22 |
23 | for (currentScanPos in searchStartPos downTo 0) {
24 | if ((byteString.getIntLe(currentScanPos).toLong() and 0xFFFFFFFFL) == ENDSIG) {
25 | val cenDirOffsetFieldPos = currentScanPos + 16
26 | val cenDirSizeFieldPos = currentScanPos + 12
27 |
28 | val offsetOfCentralDir = byteString.getIntLe(cenDirOffsetFieldPos).toLong() and 0xFFFFFFFFL
29 | val sizeOfCentralDir = byteString.getIntLe(cenDirSizeFieldPos).toLong() and 0xFFFFFFFFL
30 |
31 | if (offsetOfCentralDir == ZIP64_MAGICVAL || sizeOfCentralDir == ZIP64_MAGICVAL) {
32 | val zip64LocatorPos = currentScanPos - ZIP64_LOCHDR
33 | if (zip64LocatorPos >= 0 && (byteString.getIntLe(zip64LocatorPos).toLong() and 0xFFFFFFFFL) == ZIP64_LOCSIG) {
34 | val zip64EocdRecordOffsetInFile = byteString.getLongLe(zip64LocatorPos + 8)
35 | val zip64EocdRecordOffsetInBuffer = bytes.size - (fileLength - zip64EocdRecordOffsetInFile).toInt()
36 | if (zip64EocdRecordOffsetInBuffer >= 0
37 | && (zip64EocdRecordOffsetInBuffer + 56) <= bytes.size
38 | && (byteString.getIntLe(zip64EocdRecordOffsetInBuffer).toLong() and 0xFFFFFFFFL) == ZIP64_ENDSIG
39 | ) {
40 | cenSize = byteString.getLongLe(zip64EocdRecordOffsetInBuffer + 40)
41 | cenOffset = byteString.getLongLe(zip64EocdRecordOffsetInBuffer + 48)
42 | break
43 | }
44 | }
45 | } else {
46 | cenSize = sizeOfCentralDir
47 | cenOffset = offsetOfCentralDir
48 | break
49 | }
50 | }
51 | }
52 | return FileInfoHelper.FileInfo(cenOffset, cenSize)
53 | }
54 |
55 | fun locateLocalFileHeader(bytes: ByteArray, fileName: String): Long {
56 | val byteString = bytes.toByteString()
57 | var pos = 0
58 | var localHeaderOffset = -1L
59 |
60 | while (pos + 46 <= bytes.size) {
61 | if ((byteString.getIntLe(pos).toLong() and 0xFFFFFFFFL) == CENSIG) {
62 | val fileNameLength = byteString.getShortLe(pos + 28).toInt() and 0xFFFF
63 | val extraFieldLength = byteString.getShortLe(pos + 30).toInt() and 0xFFFF
64 | val fileCommentLength = byteString.getShortLe(pos + 32).toInt() and 0xFFFF
65 | val relativeOffsetOfLocalHeader = byteString.getIntLe(pos + 42).toLong() and 0xFFFFFFFFL
66 |
67 | val fileNameStartPos = pos + 46
68 | if (fileNameStartPos + fileNameLength > bytes.size) break
69 |
70 | val currentFileName = byteString.substring(fileNameStartPos, fileNameStartPos + fileNameLength).utf8()
71 | if (fileName == currentFileName) {
72 | localHeaderOffset = relativeOffsetOfLocalHeader
73 | break
74 | }
75 | pos = fileNameStartPos + fileNameLength + extraFieldLength + fileCommentLength
76 | } else {
77 | break
78 | }
79 | }
80 | return localHeaderOffset
81 | }
82 |
83 | fun locateLocalFileOffset(bytes: ByteArray): Long {
84 | val byteString = bytes.toByteString()
85 | if ((byteString.getIntLe(0).toLong() and 0xFFFFFFFFL) == LOCSIG) {
86 | val fileNameLength = byteString.getShortLe(26).toInt() and 0xFFFF
87 | val extraFieldLength = byteString.getShortLe(28).toInt() and 0xFFFF
88 | return (30L + fileNameLength + extraFieldLength)
89 | }
90 | return -1L
91 | }
92 |
93 | private fun ByteString.getIntLe(pos: Int): Int {
94 | return (get(pos).toInt() and 0xFF) or
95 | ((get(pos + 1).toInt() and 0xFF) shl 8) or
96 | ((get(pos + 2).toInt() and 0xFF) shl 16) or
97 | ((get(pos + 3).toInt() and 0xFF) shl 24)
98 | }
99 |
100 | private fun ByteString.getShortLe(pos: Int): Short {
101 | return ((get(pos).toInt() and 0xFF) or
102 | ((get(pos + 1).toInt() and 0xFF) shl 8)).toShort()
103 | }
104 |
105 | private fun ByteString.getLongLe(pos: Int): Long {
106 | return (getIntLe(pos).toLong() and ZIP64_MAGICVAL) or
107 | (getIntLe(pos + 4).toLong() shl 32)
108 | }
109 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/data/RomInfoHelper.kt:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import kotlinx.serialization.ExperimentalSerializationApi
4 | import kotlinx.serialization.KSerializer
5 | import kotlinx.serialization.SerialName
6 | import kotlinx.serialization.Serializable
7 | import kotlinx.serialization.descriptors.SerialDescriptor
8 | import kotlinx.serialization.descriptors.buildClassSerialDescriptor
9 | import kotlinx.serialization.encoding.Decoder
10 | import kotlinx.serialization.encoding.Encoder
11 | import kotlinx.serialization.json.Json
12 | import kotlinx.serialization.json.JsonArray
13 | import kotlinx.serialization.json.JsonElement
14 | import kotlinx.serialization.json.JsonIgnoreUnknownKeys
15 | import kotlinx.serialization.json.JsonObject
16 | import kotlinx.serialization.json.JsonPrimitive
17 | import kotlinx.serialization.json.addJsonObject
18 | import kotlinx.serialization.json.buildJsonObject
19 | import kotlinx.serialization.json.decodeFromJsonElement
20 | import kotlinx.serialization.json.put
21 | import kotlinx.serialization.json.putJsonArray
22 |
23 | object RomInfoHelper {
24 | @Serializable
25 | @JsonIgnoreUnknownKeys
26 | @OptIn(ExperimentalSerializationApi::class)
27 | data class RomInfo(
28 | @SerialName("AuthResult") val authResult: Int? = null,
29 | @SerialName("CurrentRom") val currentRom: Rom? = null,
30 | @SerialName("LatestRom") val latestRom: Rom? = null,
31 | @SerialName("IncrementRom") val incrementRom: Rom? = null,
32 | @SerialName("CrossRom") val crossRom: Rom? = null,
33 | @SerialName("Icon") val icon: Map? = null,
34 | @SerialName("FileMirror") val fileMirror: FileMirror? = null,
35 | @SerialName("GentleNotice") val gentleNotice: GentleNotice? = null,
36 | )
37 |
38 | @Serializable
39 | @JsonIgnoreUnknownKeys
40 | @OptIn(ExperimentalSerializationApi::class)
41 | data class Rom(
42 | val bigversion: String? = null,
43 | val branch: String? = null,
44 | @Serializable(with = ChangelogSerializer::class)
45 | val changelog: LinkedHashMap>? = null,
46 | val codebase: String? = null,
47 | val device: String? = null,
48 | val filename: String? = null,
49 | val filesize: String? = null,
50 | val md5: String? = null,
51 | val name: String? = null,
52 | val osbigversion: String? = null,
53 | val type: String? = null,
54 | val version: String? = null,
55 | val isBeta: Int = 0,
56 | val isGov: Int = 0,
57 | )
58 |
59 | @Serializable
60 | data class ChangelogItem(
61 | val txt: String,
62 | val image: List? = null
63 | )
64 |
65 | @Serializable
66 | data class ChangelogImage(
67 | val path: String,
68 | val h: String,
69 | val w: String
70 | )
71 |
72 | @Serializable
73 | data class FileMirror(
74 | val icon: String,
75 | val image: String,
76 | val video: String,
77 | val headimage: String,
78 | )
79 |
80 | @Serializable
81 | data class GentleNotice(
82 | val text: String,
83 | )
84 |
85 | object ChangelogSerializer : KSerializer>> {
86 | override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Changelog")
87 |
88 | override fun deserialize(decoder: Decoder): LinkedHashMap> {
89 | val jsonElement = decoder.decodeSerializableValue(JsonElement.serializer())
90 | val result = LinkedHashMap>()
91 |
92 | if (jsonElement !is JsonObject) return result
93 |
94 | jsonElement.forEach { (key, value) ->
95 | val items = when (value) {
96 | is JsonObject if value.containsKey("txt") -> {
97 | when (val txtElement = value["txt"]) {
98 | is JsonArray -> {
99 | txtElement.mapNotNull {
100 | (it as? JsonPrimitive)?.content?.let { text ->
101 | ChangelogItem(text, null)
102 | }
103 | }
104 | }
105 |
106 | else -> emptyList()
107 | }
108 | }
109 |
110 | is JsonArray -> {
111 | value.mapNotNull { element ->
112 | try {
113 | Json.decodeFromJsonElement(element)
114 | } catch (_: Exception) {
115 | null
116 | }
117 | }
118 | }
119 |
120 | else -> emptyList()
121 | }
122 | if (items.isNotEmpty()) {
123 | result[key] = items
124 | }
125 | }
126 |
127 | return result
128 | }
129 |
130 | override fun serialize(encoder: Encoder, value: LinkedHashMap>) {
131 | val jsonObject = buildJsonObject {
132 | value.forEach { (key, items) ->
133 | putJsonArray(key) {
134 | items.forEach { item ->
135 | addJsonObject {
136 | put("txt", item.txt)
137 | item.image?.let { images ->
138 | putJsonArray("image") {
139 | images.forEach { img ->
140 | addJsonObject {
141 | put("path", img.path)
142 | put("h", img.h)
143 | put("w", img.w)
144 | }
145 | }
146 | }
147 | }
148 | }
149 | }
150 | }
151 | }
152 | }
153 | encoder.encodeSerializableValue(JsonObject.serializer(), jsonObject)
154 | }
155 | }
156 | }
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/DeviceListDialog.kt:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.expandVertically
5 | import androidx.compose.animation.fadeIn
6 | import androidx.compose.animation.fadeOut
7 | import androidx.compose.animation.shrinkVertically
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.PaddingValues
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.runtime.LaunchedEffect
14 | import androidx.compose.runtime.MutableState
15 | import androidx.compose.runtime.mutableStateOf
16 | import androidx.compose.runtime.remember
17 | import androidx.compose.runtime.rememberCoroutineScope
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.DpSize
21 | import androidx.compose.ui.unit.dp
22 | import data.DeviceInfoHelper
23 | import kotlinx.coroutines.launch
24 | import org.jetbrains.compose.resources.stringResource
25 | import top.yukonga.miuix.kmp.basic.BasicComponent
26 | import top.yukonga.miuix.kmp.basic.ButtonDefaults
27 | import top.yukonga.miuix.kmp.basic.Text
28 | import top.yukonga.miuix.kmp.basic.TextButton
29 | import top.yukonga.miuix.kmp.extra.SuperDialog
30 | import top.yukonga.miuix.kmp.extra.SuperDropdown
31 | import top.yukonga.miuix.kmp.theme.MiuixTheme
32 | import updater.composeapp.generated.resources.Res
33 | import updater.composeapp.generated.resources.cancel
34 | import updater.composeapp.generated.resources.device_list_embedded
35 | import updater.composeapp.generated.resources.device_list_no_updates
36 | import updater.composeapp.generated.resources.device_list_remote
37 | import updater.composeapp.generated.resources.device_list_settings
38 | import updater.composeapp.generated.resources.device_list_source
39 | import updater.composeapp.generated.resources.device_list_update_failed
40 | import updater.composeapp.generated.resources.device_list_update_now
41 | import updater.composeapp.generated.resources.device_list_updated
42 | import updater.composeapp.generated.resources.device_list_updating
43 | import updater.composeapp.generated.resources.device_list_version
44 | import utils.DeviceListUtils
45 |
46 | @Composable
47 | fun DeviceListDialog(
48 | showDeviceSettingsDialog: MutableState,
49 | ) {
50 | val coroutinesScope = rememberCoroutineScope()
51 | val version = remember { mutableStateOf(DeviceListUtils.getCachedVersion() ?: "-") }
52 | val source = remember { mutableStateOf(DeviceListUtils.getDeviceListSource()) }
53 | val updateResultMsg = remember { mutableStateOf("") }
54 |
55 | LaunchedEffect(showDeviceSettingsDialog.value) {
56 | if (showDeviceSettingsDialog.value) {
57 | updateResultMsg.value = ""
58 | }
59 | }
60 |
61 | fun refreshDeviceListInfo() {
62 | version.value = DeviceListUtils.getCachedVersion() ?: "-"
63 | source.value = DeviceListUtils.getDeviceListSource()
64 | }
65 |
66 | SuperDialog(
67 | show = showDeviceSettingsDialog,
68 | onDismissRequest = {
69 | showDeviceSettingsDialog.value = false
70 | },
71 | title = stringResource(Res.string.device_list_settings),
72 | insideMargin = DpSize(0.dp, 24.dp)
73 | ) {
74 | SuperDropdown(
75 | title = stringResource(Res.string.device_list_source),
76 | items = listOf(
77 | stringResource(Res.string.device_list_remote),
78 | stringResource(Res.string.device_list_embedded)
79 | ),
80 | selectedIndex = if (source.value == DeviceListUtils.DeviceListSource.REMOTE) 0 else 1,
81 | onSelectedIndexChange = {
82 | source.value = if (it == 0) {
83 | DeviceListUtils.DeviceListSource.REMOTE
84 | } else {
85 | DeviceListUtils.DeviceListSource.EMBEDDED
86 | }
87 | DeviceListUtils.setDeviceListSource(source.value)
88 | coroutinesScope.launch {
89 | DeviceInfoHelper.updateDeviceList()
90 | refreshDeviceListInfo()
91 | }
92 | },
93 | insideMargin = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
94 | )
95 | AnimatedVisibility(
96 | visible = source.value == DeviceListUtils.DeviceListSource.REMOTE,
97 | enter = fadeIn() + expandVertically(),
98 | exit = fadeOut() + shrinkVertically()
99 | ) {
100 | Column {
101 | BasicComponent(
102 | title = stringResource(Res.string.device_list_version),
103 | rightActions = {
104 | Text(
105 | text = version.value,
106 | fontSize = MiuixTheme.textStyles.body2.fontSize,
107 | color = MiuixTheme.colorScheme.onSurfaceVariantActions,
108 | textAlign = TextAlign.End,
109 | )
110 | },
111 | insideMargin = PaddingValues(horizontal = 24.dp, vertical = 16.dp)
112 | )
113 | val updatingText = stringResource(Res.string.device_list_updating)
114 | val updatedText = stringResource(Res.string.device_list_updated)
115 | val updateFailedText = stringResource(Res.string.device_list_update_failed)
116 | val updateNowText = stringResource(Res.string.device_list_update_now)
117 | val updateNoUpdates = stringResource(Res.string.device_list_no_updates)
118 | TextButton(
119 | modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp).padding(bottom = 16.dp),
120 | text = updateResultMsg.value.ifEmpty { updateNowText },
121 | colors = ButtonDefaults.textButtonColorsPrimary(),
122 | onClick = {
123 | updateResultMsg.value = updatingText
124 | coroutinesScope.launch {
125 | val result = DeviceListUtils.updateDeviceList()
126 | if (result == 0) {
127 | DeviceInfoHelper.updateDeviceList()
128 | refreshDeviceListInfo()
129 | }
130 | updateResultMsg.value = when (result) {
131 | 0 -> updatedText
132 | 1 -> updateNoUpdates
133 | else -> updateFailedText
134 | }
135 | }
136 | },
137 | enabled = updateResultMsg.value.isEmpty()
138 | )
139 | }
140 | }
141 | TextButton(
142 | modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
143 | text = stringResource(Res.string.cancel),
144 | onClick = {
145 | showDeviceSettingsDialog.value = false
146 | }
147 | )
148 | }
149 | }
150 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/utils/MetadataUtils.kt:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import io.ktor.client.request.get
4 | import io.ktor.client.request.head
5 | import io.ktor.client.request.header
6 | import io.ktor.client.statement.bodyAsChannel
7 | import io.ktor.http.HttpHeaders
8 | import io.ktor.utils.io.readAvailable
9 | import kotlinx.coroutines.withTimeout
10 | import platform.httpClientPlatform
11 | import utils.ZipFileUtils.locateCentralDirectory
12 | import utils.ZipFileUtils.locateLocalFileHeader
13 | import utils.ZipFileUtils.locateLocalFileOffset
14 | import kotlin.math.min
15 |
16 |
17 | class MetadataUtils private constructor() {
18 | companion object {
19 | private const val METADATA_PATH = "META-INF/com/android/metadata"
20 | private const val CHUNK_SIZE = 1024
21 | private const val END_BYTES_SIZE = 4096
22 | private const val LOCAL_HEADER_SIZE = 256
23 | private const val TIMEOUT_MS = 20000L
24 | private val instance by lazy { MetadataUtils() }
25 |
26 | suspend fun getMetadata(url: String): String = instance.fetchMetadata(url)
27 |
28 | fun getMetadataValue(metadata: String, prefix: String): String =
29 | metadata.lineSequence().firstOrNull { it.startsWith(prefix) }?.substringAfter(prefix).orEmpty()
30 | }
31 |
32 | private val client = httpClientPlatform()
33 |
34 | private suspend fun fetchMetadata(url: String): String {
35 | return withTimeout(TIMEOUT_MS) {
36 | try {
37 | extractMetadata(url)
38 | } catch (_: Exception) {
39 | ""
40 | }
41 | }
42 | }
43 |
44 | private suspend fun extractMetadata(url: String): String {
45 | val fileLength = getFileLength(url) ?: return ""
46 | if (fileLength == 0L) return ""
47 |
48 | val actualEndBytesSize = min(fileLength, END_BYTES_SIZE.toLong()).toInt()
49 | val endBytes = readRange(url, fileLength - actualEndBytesSize, actualEndBytesSize) ?: return ""
50 |
51 | val centralDirectoryInfo = locateCentralDirectory(endBytes, fileLength)
52 | if (centralDirectoryInfo.offset == -1L || centralDirectoryInfo.size == -1L ||
53 | centralDirectoryInfo.offset < 0 || centralDirectoryInfo.size <= 0 ||
54 | centralDirectoryInfo.offset + centralDirectoryInfo.size > fileLength
55 | ) return ""
56 |
57 | val centralDirectory = readRange(url, centralDirectoryInfo.offset, centralDirectoryInfo.size.toInt()) ?: return ""
58 |
59 | val localHeaderOffset = locateLocalFileHeader(centralDirectory, METADATA_PATH)
60 | if (localHeaderOffset == -1L || localHeaderOffset < 0 || localHeaderOffset >= fileLength) return ""
61 |
62 | val maxBytesForLocalHeader = min(fileLength - localHeaderOffset, LOCAL_HEADER_SIZE.toLong()).toInt()
63 |
64 | if (maxBytesForLocalHeader < 30) return ""
65 | val localHeaderBytes = readRange(url, localHeaderOffset, maxBytesForLocalHeader) ?: return ""
66 |
67 | val metadataInternalOffset = locateLocalFileOffset(localHeaderBytes)
68 | if (metadataInternalOffset == -1L || metadataInternalOffset > maxBytesForLocalHeader) return ""
69 |
70 | val metadataActualOffset = localHeaderOffset + metadataInternalOffset
71 | val metadataSize = localHeaderBytes.getUncompressedSize()
72 |
73 | if (metadataSize < 0 || metadataActualOffset + metadataSize > fileLength) return ""
74 |
75 | return readContent(url, metadataActualOffset, metadataSize) ?: return ""
76 | }
77 |
78 | private suspend fun getFileLength(url: String): Long? {
79 | return try {
80 | val response = client.head(url) {
81 | header(HttpHeaders.Range, "bytes=0-0")
82 | }
83 |
84 | response.headers[HttpHeaders.ContentRange]?.let { contentRange ->
85 | val parts = contentRange.split("/")
86 | if (parts.size > 1) {
87 | parts[1].toLongOrNull()?.let { if (it > 0) return it }
88 | }
89 | }
90 | response.headers[HttpHeaders.ContentLength]?.toLongOrNull()?.let { if (it > 0) return it }
91 |
92 | null
93 | } catch (_: Exception) {
94 | null
95 | }
96 | }
97 |
98 | private suspend fun readRange(url: String, start: Long, size: Int): ByteArray? {
99 | if (size == 0) return ByteArray(0)
100 | if (size < 0 || start < 0) return null
101 |
102 | val bytes = ByteArray(size)
103 | return try {
104 | val response = client.get(url) {
105 | header(HttpHeaders.Range, "bytes=$start-${start + size - 1}")
106 | }
107 |
108 | val channel = response.bodyAsChannel()
109 | var totalBytesRead = 0
110 | while (totalBytesRead < size) {
111 | val bytesReadThisTurn = channel.readAvailable(bytes, totalBytesRead, size - totalBytesRead)
112 | if (bytesReadThisTurn == -1) return null
113 |
114 | totalBytesRead += bytesReadThisTurn
115 | }
116 | bytes
117 | } catch (_: Exception) {
118 | null
119 | }
120 |
121 | }
122 |
123 | private suspend fun readContent(url: String, offset: Long, size: Int): String? {
124 | if (size == 0) return ""
125 | if (size < 0 || offset < 0) return null
126 |
127 | val contentBytes = ByteArray(size)
128 | var totalBytesRead = 0
129 | return try {
130 | while (totalBytesRead < size) {
131 | val remaining = size - totalBytesRead
132 | val currentChunkSize = min(CHUNK_SIZE, remaining)
133 |
134 | val bytesReadInChunk = executeStreamedRangeRequest(
135 | url,
136 | offset + totalBytesRead,
137 | contentBytes,
138 | totalBytesRead,
139 | currentChunkSize
140 | )
141 |
142 | if (bytesReadInChunk <= 0) return null
143 | totalBytesRead += bytesReadInChunk
144 | }
145 | contentBytes.decodeToString()
146 | } catch (_: Exception) {
147 | null
148 | }
149 | }
150 |
151 | private suspend fun executeStreamedRangeRequest(
152 | url: String,
153 | fileOffset: Long,
154 | buffer: ByteArray,
155 | bufferOffset: Int,
156 | length: Int
157 | ): Int {
158 | if (length == 0) return 0
159 | if (length < 0 || fileOffset < 0 || bufferOffset < 0 || bufferOffset + length > buffer.size) return -1 // Invalid params
160 |
161 | return try {
162 | val response = client.get(url) {
163 | header(HttpHeaders.Range, "bytes=$fileOffset-${fileOffset + length - 1}")
164 | }
165 | response.bodyAsChannel().readAvailable(buffer, bufferOffset, length)
166 | } catch (_: Exception) {
167 | -1
168 | }
169 | }
170 |
171 | private fun ByteArray.getUncompressedSize(): Int {
172 | if (this.size < 22 + 4) return -1
173 | return (this[22].toInt() and 0xff) or
174 | ((this[23].toInt() and 0xff) shl 8) or
175 | ((this[24].toInt() and 0xff) shl 16) or
176 | ((this[25].toInt() and 0xff) shl 24)
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/data/DeviceInfoHelper.kt:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import kotlinx.coroutines.flow.MutableStateFlow
4 | import kotlinx.coroutines.flow.asStateFlow
5 | import kotlinx.serialization.Serializable
6 | import utils.DeviceListUtils
7 |
8 | object DeviceInfoHelper {
9 | @Serializable
10 | data class Device(
11 | val deviceName: String,
12 | val deviceCodeName: String,
13 | val deviceCode: String,
14 | )
15 |
16 | @Serializable
17 | data class RemoteDevices(
18 | val devices: List,
19 | val version: String,
20 | )
21 |
22 | data class Android(
23 | val androidVersionCode: String,
24 | val androidLetterCode: String,
25 | )
26 |
27 | data class Region(
28 | val regionCodeName: String,
29 | val regionCode: String,
30 | val regionName: String = regionCode,
31 | )
32 |
33 | data class Carrier(
34 | val carrierName: String,
35 | val carrierCode: String,
36 | val regionAppend: String = "",
37 | )
38 |
39 | /**
40 | * List of Xiaomi devices.
41 | *
42 | * For auto-completion of device designators and system version suffixes.
43 | *
44 | */
45 | private val embeddedDeviceList = listOf(
46 | Device("Xiaomi 15S Pro", "dijun", "OD"),
47 | Device("Xiaomi 15", "dada", "OC"),
48 | Device("Xiaomi 15 Pro", "haotian", "OB"),
49 | Device("Xiaomi 15 Ultra", "xuanyuan", "OA"),
50 | )
51 |
52 | /**
53 | * Current device list
54 | */
55 | private var currentDeviceList: List = embeddedDeviceList
56 |
57 | /**
58 | * Update the current device list with remote or embedded data based
59 | */
60 | suspend fun updateDeviceList() {
61 | currentDeviceList = DeviceListUtils.getDeviceList(embeddedDeviceList)
62 | rebuildMappings()
63 | }
64 |
65 | /**
66 | * Rebuild all mappings when device list is updated
67 | */
68 | private fun rebuildMappings() {
69 | _deviceNameToDeviceCodeName = currentDeviceList.associateBy({ it.deviceName }, { it.deviceCodeName })
70 | _deviceCodeNameToDeviceName = currentDeviceList.associateBy({ it.deviceCodeName }, { it.deviceName })
71 | _deviceNames = currentDeviceList.map { it.deviceName }
72 | _codeNames = currentDeviceList.map { it.deviceCodeName }
73 | _deviceNamesFlow.value = _deviceNames
74 | _codeNamesFlow.value = _codeNames
75 | }
76 |
77 | private val androidW = Android("16.0", "W")
78 | private val androidV = Android("15.0", "V")
79 | private val androidU = Android("14.0", "U")
80 | private val androidT = Android("13.0", "T")
81 | private val androidS = Android("12.0", "S")
82 | private val androidR = Android("11.0", "R")
83 | private val androidQ = Android("10.0", "Q")
84 | private val androidP = Android("9.0", "P")
85 | private val androidOMr1 = Android("8.1", "O")
86 | private val androidO = Android("8.0", "O")
87 | private val androidNMr1 = Android("7.1", "N")
88 | private val androidN = Android("7.0", "N")
89 | private val androidM = Android("6.0", "M")
90 | private val androidLMr1 = Android("5.1", "L")
91 | private val androidL = Android("5.0", "L")
92 | private val androidK = Android("4.4", "K")
93 |
94 | private val androidList = listOf(
95 | androidW,
96 | androidV,
97 | androidU,
98 | androidT,
99 | androidS,
100 | androidR,
101 | androidQ,
102 | androidP,
103 | androidOMr1,
104 | androidO,
105 | androidNMr1,
106 | androidN,
107 | androidM,
108 | androidLMr1,
109 | androidL,
110 | androidK,
111 | )
112 |
113 |
114 | private val CN = Region("", "CN", "Default (CN)")
115 | private val GL = Region("_global", "MI", "GL (MI)")
116 | private val EEA = Region("_eea_global", "EU", "EEA (EU)")
117 | private val CL = Region("_cl_global", "CL")
118 | private val GT = Region("_gt_global", "GT")
119 | private val ID = Region("_id_global", "ID")
120 | private val IN = Region("_in_global", "IN")
121 | private val JP = Region("_jp_global", "JP")
122 | private val KR = Region("_kr_global", "KR")
123 | private val LM = Region("_lm_global", "LM")
124 | private val MX = Region("_mx_global", "MX")
125 | private val RU = Region("_ru_global", "RU")
126 | private val TR = Region("_tr_global", "TR")
127 | private val TW = Region("_tw_global", "TW")
128 | private val ZA = Region("_za_global", "ZA")
129 |
130 | private val regionList = listOf(CN, GL, EEA, CL, GT, ID, IN, JP, KR, LM, MX, RU, TR, TW, ZA)
131 |
132 | private val XM = Carrier("Default (Xiaomi)", "XM")
133 | private val DM = Carrier("MiStore (Demo)", "DM")
134 | private val DC = Carrier("DeviceLockController", "DC", "_dc")
135 | private val AT = Carrier("AT&T", "AT", "_at")
136 | private val BY = Carrier("Bouygues", "BY", "_by")
137 | private val CR = Carrier("Claro", "CR", "_cr")
138 | private val EN = Carrier("Entel", "EN", "_en")
139 | private val HG = Carrier("3HK", "HG", "_hg")
140 | private val KD = Carrier("KDDI", "KD", "_kd")
141 | private val MS = Carrier("Movistar", "MS", "_ms")
142 | private val MT = Carrier("MTN", "MT", "_mt")
143 | private val OR = Carrier("Orange", "OR", "_or")
144 | private val SB = Carrier("SoftBank", "SB", "_ti")
145 | private val SF = Carrier("Altice France", "SF", "_sf")
146 | private val TF = Carrier("Telefónica", "TF", "_tf")
147 | private val TG = Carrier("Tigo", "TG", "_tg")
148 | private val TM = Carrier("TIM", "TI", "_tm")
149 | private val VC = Carrier("Vodacom", "VC", "_vc")
150 | private val VF = Carrier("Vodafone", "VF", "_vf")
151 |
152 | private val carrierList = listOf(XM, DM, DC, AT, BY, CR, EN, HG, KD, MS, MT, OR, SB, SF, TF, TG, TM, VC, VF)
153 |
154 | private var _deviceNameToDeviceCodeName = currentDeviceList.associateBy({ it.deviceName }, { it.deviceCodeName })
155 | private var _deviceCodeNameToDeviceName = currentDeviceList.associateBy({ it.deviceCodeName }, { it.deviceName })
156 | private var _deviceNames = currentDeviceList.map { it.deviceName }
157 | private var _codeNames = currentDeviceList.map { it.deviceCodeName }
158 |
159 | private val regionNameToRegionCode = regionList.associateBy({ it.regionName }, { it.regionCode })
160 | private val regionNameToRegionCodeName = regionList.associateBy({ it.regionName }, { it.regionCodeName })
161 | private val carrierNameToCarrierCode = carrierList.associateBy({ it.carrierName }, { it.carrierCode })
162 | private val carrierNameToCarrierCodeName = carrierList.associateBy({ it.carrierName }, { it.regionAppend })
163 | private val androidVersionCodeToAndroidLetterCode = androidList.associateBy { it.androidVersionCode }
164 |
165 | private val _deviceNamesFlow = MutableStateFlow(_deviceNames)
166 | val deviceNamesFlow = _deviceNamesFlow.asStateFlow()
167 |
168 | private val _codeNamesFlow = MutableStateFlow(_codeNames)
169 | val codeNamesFlow = _codeNamesFlow.asStateFlow()
170 |
171 | val regionNames = regionList.map { it.regionName }
172 | val carrierNames = carrierList.map { it.carrierName }
173 | val androidVersions = androidList.map { it.androidVersionCode }
174 |
175 | fun codeName(deviceName: String): String = _deviceNameToDeviceCodeName[deviceName] ?: ""
176 | fun deviceName(deviceCodeName: String): String = _deviceCodeNameToDeviceName[deviceCodeName] ?: ""
177 | fun regionCode(regionName: String): String = regionNameToRegionCode[regionName] ?: ""
178 | fun carrierCode(carrierName: String): String = carrierNameToCarrierCode[carrierName] ?: ""
179 | fun regionCodeName(regionName: String): String = regionNameToRegionCodeName[regionName] ?: ""
180 | fun carrierCodeName(carrierName: String): String = carrierNameToCarrierCodeName[carrierName] ?: ""
181 |
182 | fun deviceCode(androidVersionCode: String, codeName: String, regionCode: String, carrierCode: String): String {
183 | val android = androidVersionCodeToAndroidLetterCode[androidVersionCode] ?: return ""
184 | val device = currentDeviceList.find { it.deviceCodeName == codeName } ?: return ""
185 | return "${android.androidLetterCode}${device.deviceCode}${regionCode}${carrierCode}"
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 |
118 |
119 | # Determine the Java command to use to start the JVM.
120 | if [ -n "$JAVA_HOME" ] ; then
121 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
122 | # IBM's JDK on AIX uses strange locations for the executables
123 | JAVACMD=$JAVA_HOME/jre/sh/java
124 | else
125 | JAVACMD=$JAVA_HOME/bin/java
126 | fi
127 | if [ ! -x "$JAVACMD" ] ; then
128 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
129 |
130 | Please set the JAVA_HOME variable in your environment to match the
131 | location of your Java installation."
132 | fi
133 | else
134 | JAVACMD=java
135 | if ! command -v java >/dev/null 2>&1
136 | then
137 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
138 |
139 | Please set the JAVA_HOME variable in your environment to match the
140 | location of your Java installation."
141 | fi
142 | fi
143 |
144 | # Increase the maximum file descriptors if we can.
145 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
146 | case $MAX_FD in #(
147 | max*)
148 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
149 | # shellcheck disable=SC2039,SC3045
150 | MAX_FD=$( ulimit -H -n ) ||
151 | warn "Could not query maximum file descriptor limit"
152 | esac
153 | case $MAX_FD in #(
154 | '' | soft) :;; #(
155 | *)
156 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
157 | # shellcheck disable=SC2039,SC3045
158 | ulimit -n "$MAX_FD" ||
159 | warn "Could not set maximum file descriptor limit to $MAX_FD"
160 | esac
161 | fi
162 |
163 | # Collect all arguments for the java command, stacking in reverse order:
164 | # * args from the command line
165 | # * the main class name
166 | # * -classpath
167 | # * -D...appname settings
168 | # * --module-path (only if needed)
169 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
170 |
171 | # For Cygwin or MSYS, switch paths to Windows format before running java
172 | if "$cygwin" || "$msys" ; then
173 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
174 |
175 | JAVACMD=$( cygpath --unix "$JAVACMD" )
176 |
177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 | for arg do
179 | if
180 | case $arg in #(
181 | -*) false ;; # don't mess with options #(
182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 | [ -e "$t" ] ;; #(
184 | *) false ;;
185 | esac
186 | then
187 | arg=$( cygpath --path --ignore --mixed "$arg" )
188 | fi
189 | # Roll the args list around exactly as many times as the number of
190 | # args, so each arg winds up back in the position where it started, but
191 | # possibly modified.
192 | #
193 | # NB: a `for` loop captures its iteration list before it begins, so
194 | # changing the positional parameters here affects neither the number of
195 | # iterations, nor the values presented in `arg`.
196 | shift # remove old arg
197 | set -- "$@" "$arg" # push replacement arg
198 | done
199 | fi
200 |
201 |
202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 |
205 | # Collect all arguments for the java command:
206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 | # and any embedded shellness will be escaped.
208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 | # treated as '${Hostname}' itself on the command line.
210 |
211 | set -- \
212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
214 | "$@"
215 |
216 | # Stop when "xargs" is not available.
217 | if ! command -v xargs >/dev/null 2>&1
218 | then
219 | die "xargs is not available"
220 | fi
221 |
222 | # Use "xargs" to parse quoted args.
223 | #
224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
225 | #
226 | # In Bash we could simply go:
227 | #
228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
229 | # set -- "${ARGS[@]}" "$@"
230 | #
231 | # but POSIX shell has neither arrays nor command substitution, so instead we
232 | # post-process each arg (as a line of input to sed) to backslash-escape any
233 | # character that might be a shell metacharacter, then use eval to reverse
234 | # that process (while maintaining the separation between arguments), and wrap
235 | # the whole thing up as a single "set" statement.
236 | #
237 | # This will of course break if any of these variables contains a newline or
238 | # an unmatched quote.
239 | #
240 |
241 | eval "set -- $(
242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
243 | xargs -n1 |
244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
245 | tr '\n' ' '
246 | )" '"$@"'
247 |
248 | exec "$JAVACMD" "$@"
249 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/java,linux,macos,kotlin,android,windows,composer,jetbrains,androidstudio,visualstudiocode,xcode
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=java,linux,macos,kotlin,android,windows,composer,jetbrains,androidstudio,visualstudiocode,xcode
3 |
4 | ### Android ###
5 | # Gradle files
6 | .gradle/
7 | build/
8 |
9 | # Local configuration file (sdk path, etc)
10 | local.properties
11 |
12 | # Log/OS Files
13 | *.log
14 |
15 | # Android Studio generated files and folders
16 | captures/
17 | .externalNativeBuild/
18 | .cxx/
19 | *.apk
20 | output.json
21 |
22 | # IntelliJ
23 | *.iml
24 | .idea/
25 | misc.xml
26 | deploymentTargetDropDown.xml
27 | render.experimental.xml
28 |
29 | # Keystore files
30 | *.jks
31 | *.keystore
32 |
33 | # Google Services (e.g. APIs or Firebase)
34 | google-services.json
35 |
36 | # Android Profiling
37 | *.hprof
38 |
39 | ### Android Patch ###
40 | gen-external-apklibs
41 |
42 | # Replacement of .externalNativeBuild directories introduced
43 | # with Android Studio 3.5.
44 |
45 | ### Composer ###
46 | composer.phar
47 | /vendor/
48 |
49 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
50 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
51 | # composer.lock
52 |
53 | ### Java ###
54 | # Compiled class file
55 | *.class
56 |
57 | # Log file
58 |
59 | # BlueJ files
60 | *.ctxt
61 |
62 | # Mobile Tools for Java (J2ME)
63 | .mtj.tmp/
64 |
65 | # Package Files #
66 | *.jar
67 | *.war
68 | *.nar
69 | *.ear
70 | *.zip
71 | *.tar.gz
72 | *.rar
73 |
74 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
75 | hs_err_pid*
76 | replay_pid*
77 |
78 | ### JetBrains ###
79 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
80 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
81 |
82 | # User-specific stuff
83 | .idea/**/workspace.xml
84 | .idea/**/tasks.xml
85 | .idea/**/usage.statistics.xml
86 | .idea/**/dictionaries
87 | .idea/**/shelf
88 |
89 | # AWS User-specific
90 | .idea/**/aws.xml
91 |
92 | # Generated files
93 | .idea/**/contentModel.xml
94 |
95 | # Sensitive or high-churn files
96 | .idea/**/dataSources/
97 | .idea/**/dataSources.ids
98 | .idea/**/dataSources.local.xml
99 | .idea/**/sqlDataSources.xml
100 | .idea/**/dynamic.xml
101 | .idea/**/uiDesigner.xml
102 | .idea/**/dbnavigator.xml
103 |
104 | # Gradle
105 | .idea/**/gradle.xml
106 | .idea/**/libraries
107 |
108 | # Gradle and Maven with auto-import
109 | # When using Gradle or Maven with auto-import, you should exclude module files,
110 | # since they will be recreated, and may cause churn. Uncomment if using
111 | # auto-import.
112 | # .idea/artifacts
113 | # .idea/compiler.xml
114 | # .idea/jarRepositories.xml
115 | # .idea/modules.xml
116 | # .idea/*.iml
117 | # .idea/modules
118 | # *.iml
119 | # *.ipr
120 |
121 | # CMake
122 | cmake-build-*/
123 |
124 | # Mongo Explorer plugin
125 | .idea/**/mongoSettings.xml
126 |
127 | # File-based project format
128 | *.iws
129 |
130 | # IntelliJ
131 | out/
132 |
133 | # mpeltonen/sbt-idea plugin
134 | .idea_modules/
135 |
136 | # JIRA plugin
137 | atlassian-ide-plugin.xml
138 |
139 | # Cursive Clojure plugin
140 | .idea/replstate.xml
141 |
142 | # SonarLint plugin
143 | .idea/sonarlint/
144 |
145 | # Crashlytics plugin (for Android Studio and IntelliJ)
146 | com_crashlytics_export_strings.xml
147 | crashlytics.properties
148 | crashlytics-build.properties
149 | fabric.properties
150 |
151 | # Editor-based Rest Client
152 | .idea/httpRequests
153 |
154 | # Android studio 3.1+ serialized cache file
155 | .idea/caches/build_file_checksums.ser
156 |
157 | ### JetBrains Patch ###
158 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
159 |
160 | # *.iml
161 | # modules.xml
162 | # .idea/misc.xml
163 | # *.ipr
164 |
165 | # Sonarlint plugin
166 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
167 | .idea/**/sonarlint/
168 |
169 | # SonarQube Plugin
170 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
171 | .idea/**/sonarIssues.xml
172 |
173 | # Markdown Navigator plugin
174 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
175 | .idea/**/markdown-navigator.xml
176 | .idea/**/markdown-navigator-enh.xml
177 | .idea/**/markdown-navigator/
178 |
179 | # Cache file creation bug
180 | # See https://youtrack.jetbrains.com/issue/JBR-2257
181 | .idea/$CACHE_FILE$
182 |
183 | # CodeStream plugin
184 | # https://plugins.jetbrains.com/plugin/12206-codestream
185 | .idea/codestream.xml
186 |
187 | # Azure Toolkit for IntelliJ plugin
188 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
189 | .idea/**/azureSettings.xml
190 |
191 | ### Kotlin ###
192 | /.kotlin
193 | # Compiled class file
194 |
195 | # Log file
196 |
197 | # BlueJ files
198 |
199 | # Mobile Tools for Java (J2ME)
200 |
201 | # Package Files #
202 |
203 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
204 |
205 | ### Linux ###
206 | *~
207 |
208 | # temporary files which can be created if a process still has a handle open of a deleted file
209 | .fuse_hidden*
210 |
211 | # KDE directory preferences
212 | .directory
213 |
214 | # Linux trash folder which might appear on any partition or disk
215 | .Trash-*
216 |
217 | # .nfs files are created when an open file is removed but is still being accessed
218 | .nfs*
219 |
220 | ### macOS ###
221 | # General
222 | .DS_Store
223 | .AppleDouble
224 | .LSOverride
225 |
226 | # Icon must end with two \r
227 | Icon
228 |
229 |
230 | # Thumbnails
231 | ._*
232 |
233 | # Files that might appear in the root of a volume
234 | .DocumentRevisions-V100
235 | .fseventsd
236 | .Spotlight-V100
237 | .TemporaryItems
238 | .Trashes
239 | .VolumeIcon.icns
240 | .com.apple.timemachine.donotpresent
241 |
242 | # Directories potentially created on remote AFP share
243 | .AppleDB
244 | .AppleDesktop
245 | Network Trash Folder
246 | Temporary Items
247 | .apdisk
248 |
249 | ### macOS Patch ###
250 | # iCloud generated files
251 | *.icloud
252 |
253 | ### VisualStudioCode ###
254 | .vscode/*
255 | !.vscode/settings.json
256 | !.vscode/tasks.json
257 | !.vscode/launch.json
258 | !.vscode/extensions.json
259 | !.vscode/*.code-snippets
260 |
261 | # Local History for Visual Studio Code
262 | .history/
263 |
264 | # Built Visual Studio Code Extensions
265 | *.vsix
266 |
267 | ### VisualStudioCode Patch ###
268 | # Ignore all local history of files
269 | .history
270 | .ionide
271 |
272 | ### Windows ###
273 | # Windows thumbnail cache files
274 | Thumbs.db
275 | Thumbs.db:encryptable
276 | ehthumbs.db
277 | ehthumbs_vista.db
278 |
279 | # Dump file
280 | *.stackdump
281 |
282 | # Folder config file
283 | [Dd]esktop.ini
284 |
285 | # Recycle Bin used on file shares
286 | $RECYCLE.BIN/
287 |
288 | # Windows Installer files
289 | *.cab
290 | *.msi
291 | *.msix
292 | *.msm
293 | *.msp
294 |
295 | # Windows shortcuts
296 | *.lnk
297 |
298 | ### Xcode ###
299 | ## User settings
300 | xcuserdata/
301 |
302 | ## Xcode 8 and earlier
303 | *.xcscmblueprint
304 | *.xccheckout
305 |
306 | ### Xcode Patch ###
307 |
308 | # Ignore cocoapods files
309 | iosApp/Podfile.lock
310 | iosApp/Pods/*
311 | iosApp/iosApp.xcworkspace/*
312 | iosApp/iosApp.xcodeproj/*
313 | !iosApp/iosApp.xcodeproj/project.pbxproj
314 | composeApp/composeApp.podspec
315 |
316 | ### AndroidStudio ###
317 | # Covers files to be ignored for android development using Android Studio.
318 |
319 | # Built application files
320 | *.ap_
321 | *.aab
322 |
323 | # Files for the ART/Dalvik VM
324 | *.dex
325 |
326 | # Java class files
327 |
328 | # Generated files
329 | bin/
330 | gen/
331 |
332 | # Gradle files
333 | .gradle
334 |
335 | # Signing files
336 | .signing/
337 |
338 | # Local configuration file (sdk path, etc)
339 |
340 | # Proguard folder generated by Eclipse
341 | proguard/
342 |
343 | # Log Files
344 |
345 | # Android Studio
346 | /*/build/
347 | /*/local.properties
348 | /*/out
349 | /*/*/build
350 | /*/*/production
351 | .navigation/
352 | *.ipr
353 | *.swp
354 |
355 | # Keystore files
356 |
357 | # Google Services (e.g. APIs or Firebase)
358 | # google-services.json
359 |
360 | # Android Patch
361 |
362 | # External native build folder generated in Android Studio 2.2 and later
363 | .externalNativeBuild
364 |
365 | # NDK
366 | obj/
367 |
368 | # IntelliJ IDEA
369 | /out/
370 |
371 | # User-specific configurations
372 | .idea/caches/
373 | .idea/libraries/
374 | .idea/shelf/
375 | .idea/workspace.xml
376 | .idea/tasks.xml
377 | .idea/.name
378 | .idea/compiler.xml
379 | .idea/copyright/profiles_settings.xml
380 | .idea/encodings.xml
381 | .idea/misc.xml
382 | .idea/modules.xml
383 | .idea/scopes/scope_settings.xml
384 | .idea/dictionaries
385 | .idea/vcs.xml
386 | .idea/jsLibraryMappings.xml
387 | .idea/datasources.xml
388 | .idea/dataSources.ids
389 | .idea/sqlDataSources.xml
390 | .idea/dynamic.xml
391 | .idea/uiDesigner.xml
392 | .idea/assetWizardSettings.xml
393 | .idea/gradle.xml
394 | .idea/jarRepositories.xml
395 | .idea/navEditor.xml
396 |
397 | # Legacy Eclipse project files
398 | .classpath
399 | .project
400 | .cproject
401 | .settings/
402 |
403 | # Mobile Tools for Java (J2ME)
404 |
405 | # Package Files #
406 |
407 | # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
408 |
409 | ## Plugin-specific files:
410 |
411 | # mpeltonen/sbt-idea plugin
412 |
413 | # JIRA plugin
414 |
415 | # Mongo Explorer plugin
416 | .idea/mongoSettings.xml
417 |
418 | # Crashlytics plugin (for Android Studio and IntelliJ)
419 |
420 | ### AndroidStudio Patch ###
421 |
422 | !/gradle/wrapper/gradle-wrapper.jar
423 |
424 | # End of https://www.toptal.com/developers/gitignore/api/java,linux,macos,kotlin,android,windows,composer,jetbrains,androidstudio,visualstudiocode,xcode
425 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/components/AutoCompleteTextField.kt:
--------------------------------------------------------------------------------
1 | package ui.components
2 |
3 | import androidx.compose.animation.animateContentSize
4 | import androidx.compose.animation.core.VisibilityThreshold
5 | import androidx.compose.animation.core.spring
6 | import androidx.compose.foundation.background
7 | import androidx.compose.foundation.clickable
8 | import androidx.compose.foundation.layout.Arrangement
9 | import androidx.compose.foundation.layout.Box
10 | import androidx.compose.foundation.layout.PaddingValues
11 | import androidx.compose.foundation.layout.Row
12 | import androidx.compose.foundation.layout.fillMaxWidth
13 | import androidx.compose.foundation.layout.padding
14 | import androidx.compose.foundation.rememberScrollState
15 | import androidx.compose.foundation.text.KeyboardActions
16 | import androidx.compose.foundation.text.KeyboardOptions
17 | import androidx.compose.foundation.verticalScroll
18 | import androidx.compose.runtime.Composable
19 | import androidx.compose.runtime.LaunchedEffect
20 | import androidx.compose.runtime.MutableState
21 | import androidx.compose.runtime.collectAsState
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.mutableStateOf
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.runtime.setValue
26 | import androidx.compose.ui.Alignment
27 | import androidx.compose.ui.Modifier
28 | import androidx.compose.ui.focus.onFocusChanged
29 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType.Companion.LongPress
30 | import androidx.compose.ui.layout.SubcomposeLayout
31 | import androidx.compose.ui.platform.LocalFocusManager
32 | import androidx.compose.ui.platform.LocalHapticFeedback
33 | import androidx.compose.ui.text.font.FontWeight
34 | import androidx.compose.ui.text.input.ImeAction
35 | import androidx.compose.ui.unit.DpSize
36 | import androidx.compose.ui.unit.IntOffset
37 | import androidx.compose.ui.unit.IntRect
38 | import androidx.compose.ui.unit.IntSize
39 | import androidx.compose.ui.unit.LayoutDirection
40 | import androidx.compose.ui.unit.dp
41 | import kotlinx.coroutines.flow.MutableStateFlow
42 | import top.yukonga.miuix.kmp.basic.ListPopup
43 | import top.yukonga.miuix.kmp.basic.PopupPositionProvider
44 | import top.yukonga.miuix.kmp.basic.Text
45 | import top.yukonga.miuix.kmp.basic.TextField
46 | import top.yukonga.miuix.kmp.extra.DropdownColors
47 | import top.yukonga.miuix.kmp.extra.DropdownDefaults
48 | import top.yukonga.miuix.kmp.theme.MiuixTheme
49 | import kotlin.math.min
50 |
51 | @Composable
52 | fun AutoCompleteTextField(
53 | text: MutableState,
54 | items: List,
55 | onValueChange: MutableStateFlow,
56 | label: String
57 | ) {
58 | val filteredList = remember(text.value, items) {
59 | items.filter {
60 | it.startsWith(text.value, ignoreCase = true)
61 | || it.contains(text.value, ignoreCase = true)
62 | || it.replace(" ", "").contains(text.value, ignoreCase = true)
63 | }.sortedBy { !it.startsWith(text.value, ignoreCase = true) }
64 | }
65 | var isFocused by remember { mutableStateOf(false) }
66 | val showPopup = remember { mutableStateOf(false) }
67 | val hapticFeedback = LocalHapticFeedback.current
68 | val focusManager = LocalFocusManager.current
69 |
70 | LaunchedEffect(isFocused, onValueChange.collectAsState().value) {
71 | showPopup.value = isFocused && text.value.isNotEmpty()
72 | }
73 |
74 | Box(
75 | modifier = Modifier
76 | .padding(horizontal = 12.dp)
77 | .padding(bottom = 12.dp)
78 | .fillMaxWidth()
79 | ) {
80 | TextField(
81 | insideMargin = DpSize(16.dp, 20.dp),
82 | value = text.value,
83 | onValueChange = {
84 | onValueChange.value = it
85 | },
86 | singleLine = true,
87 | label = label,
88 | backgroundColor = MiuixTheme.colorScheme.surface,
89 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
90 | keyboardActions = KeyboardActions(onDone = {
91 | focusManager.clearFocus()
92 | showPopup.value = false
93 | }),
94 | modifier = Modifier.onFocusChanged { focusState ->
95 | isFocused = focusState.isFocused
96 | }
97 | )
98 | ListPopup(
99 | show = showPopup,
100 | onDismissRequest = {
101 | focusManager.clearFocus()
102 | showPopup.value = false
103 | },
104 | popupPositionProvider = AutoCompletePositionProvider,
105 | alignment = PopupPositionProvider.Align.TopLeft,
106 | enableWindowDim = false,
107 | maxHeight = 280.dp
108 | ) {
109 | AutoCompleteListPopupColumn {
110 | if (filteredList.isNotEmpty()) {
111 | filteredList.forEachIndexed { index, item ->
112 | AutoCompleteDropdownImpl(
113 | text = item,
114 | optionSize = filteredList.size,
115 | onSelectedIndexChange = {
116 | hapticFeedback.performHapticFeedback(LongPress)
117 | onValueChange.value = item
118 | focusManager.clearFocus()
119 | showPopup.value = false
120 | },
121 | isSelected = false,
122 | index = index,
123 | )
124 | }
125 | } else {
126 | AutoCompleteDropdownImpl(
127 | text = null,
128 | optionSize = 0,
129 | onSelectedIndexChange = {},
130 | isSelected = false,
131 | index = 0,
132 | )
133 | }
134 | }
135 | }
136 | }
137 | }
138 |
139 | val AutoCompletePositionProvider = object : PopupPositionProvider {
140 | override fun calculatePosition(
141 | anchorBounds: IntRect,
142 | windowBounds: IntRect,
143 | layoutDirection: LayoutDirection,
144 | popupContentSize: IntSize,
145 | popupMargin: IntRect,
146 | alignment: PopupPositionProvider.Align
147 | ): IntOffset {
148 | val offsetX: Int = anchorBounds.left
149 | val offsetY: Int = anchorBounds.bottom + popupMargin.top
150 | return IntOffset(
151 | x = offsetX.coerceIn(
152 | minimumValue = windowBounds.left,
153 | maximumValue = (windowBounds.right - popupContentSize.width - popupMargin.right).coerceAtLeast(windowBounds.left)
154 | ),
155 | y = offsetY.coerceIn(
156 | minimumValue = (windowBounds.top + popupMargin.top),
157 | maximumValue = (windowBounds.bottom - popupContentSize.height - popupMargin.bottom).coerceAtLeast(windowBounds.top + popupMargin.top)
158 | )
159 | )
160 | }
161 |
162 | override fun getMargins(): PaddingValues {
163 | return PaddingValues(horizontal = 20.dp, vertical = 0.dp)
164 | }
165 | }
166 |
167 | @Composable
168 | fun AutoCompleteListPopupColumn(
169 | content: @Composable () -> Unit
170 | ) {
171 | SubcomposeLayout(
172 | modifier = Modifier
173 | .verticalScroll(rememberScrollState())
174 | .animateContentSize(
175 | spring(
176 | stiffness = 8000f,
177 | visibilityThreshold = IntSize.VisibilityThreshold
178 | )
179 | )
180 | ) { constraints ->
181 | var listHeight = 0
182 | val tempConstraints = constraints.copy(minWidth = 0, maxWidth = 288.dp.roundToPx(), minHeight = 0)
183 | val listWidth = subcompose("miuixPopupListFake", content).map {
184 | it.measure(tempConstraints)
185 | }.maxOf { it.width }.coerceIn(0, 288.dp.roundToPx())
186 | val childConstraints = constraints.copy(minWidth = listWidth, maxWidth = listWidth, minHeight = 0)
187 | val placeables = subcompose("miuixPopupListReal", content).map {
188 | val placeable = it.measure(childConstraints)
189 | listHeight += placeable.height
190 | placeable
191 | }
192 | layout(listWidth, min(constraints.maxHeight, listHeight)) {
193 | var height = 0
194 | placeables.forEach {
195 | it.place(0, height)
196 | height += it.height
197 | }
198 | }
199 | }
200 | }
201 |
202 | @Composable
203 | fun AutoCompleteDropdownImpl(
204 | text: String?,
205 | optionSize: Int,
206 | isSelected: Boolean,
207 | index: Int,
208 | dropdownColors: DropdownColors = DropdownDefaults.dropdownColors(),
209 | onSelectedIndexChange: (Int) -> Unit
210 | ) {
211 | val additionalTopPadding = if (index == 0) 20f.dp else 12f.dp
212 | val additionalBottomPadding = if (index == optionSize - 1) 20f.dp else 12f.dp
213 | val textColor = if (isSelected) {
214 | dropdownColors.selectedContentColor
215 | } else {
216 | dropdownColors.contentColor
217 | }
218 | val backgroundColor = if (isSelected) {
219 | dropdownColors.selectedContainerColor
220 | } else {
221 | dropdownColors.containerColor
222 | }
223 |
224 | if (text != null) {
225 | Row(
226 | verticalAlignment = Alignment.CenterVertically,
227 | horizontalArrangement = Arrangement.SpaceBetween,
228 | modifier = Modifier
229 | .clickable {
230 | onSelectedIndexChange(index)
231 | }
232 | .background(backgroundColor)
233 | .padding(horizontal = 20.dp)
234 | .padding(top = additionalTopPadding, bottom = additionalBottomPadding)
235 | ) {
236 | Text(
237 | text = text,
238 | fontSize = MiuixTheme.textStyles.body1.fontSize,
239 | fontWeight = FontWeight.Medium,
240 | color = textColor,
241 | )
242 | }
243 | } else {
244 | Box(
245 | modifier = Modifier
246 | .padding(horizontal = 20.dp)
247 | ) {}
248 | }
249 | }
250 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/ui/BasicViews.kt:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import androidx.compose.animation.AnimatedVisibility
4 | import androidx.compose.animation.expandVertically
5 | import androidx.compose.animation.fadeIn
6 | import androidx.compose.animation.fadeOut
7 | import androidx.compose.animation.shrinkVertically
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.foundation.text.KeyboardActions
12 | import androidx.compose.foundation.text.KeyboardOptions
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.LaunchedEffect
15 | import androidx.compose.runtime.MutableState
16 | import androidx.compose.runtime.collectAsState
17 | import androidx.compose.runtime.getValue
18 | import androidx.compose.runtime.mutableStateOf
19 | import androidx.compose.runtime.remember
20 | import androidx.compose.ui.Alignment
21 | import androidx.compose.ui.Modifier
22 | import androidx.compose.ui.platform.LocalFocusManager
23 | import androidx.compose.ui.text.input.ImeAction
24 | import androidx.compose.ui.unit.DpSize
25 | import androidx.compose.ui.unit.dp
26 | import data.DeviceInfoHelper
27 | import kotlinx.coroutines.flow.MutableStateFlow
28 | import org.jetbrains.compose.resources.stringResource
29 | import top.yukonga.miuix.kmp.basic.ButtonDefaults
30 | import top.yukonga.miuix.kmp.basic.Card
31 | import top.yukonga.miuix.kmp.basic.TextButton
32 | import top.yukonga.miuix.kmp.basic.TextField
33 | import top.yukonga.miuix.kmp.extra.SpinnerEntry
34 | import top.yukonga.miuix.kmp.extra.SuperDropdown
35 | import top.yukonga.miuix.kmp.extra.SuperSpinner
36 | import top.yukonga.miuix.kmp.theme.MiuixTheme
37 | import ui.components.AutoCompleteTextField
38 | import updater.composeapp.generated.resources.Res
39 | import updater.composeapp.generated.resources.android_version
40 | import updater.composeapp.generated.resources.carrier_code
41 | import updater.composeapp.generated.resources.code_name
42 | import updater.composeapp.generated.resources.device_name
43 | import updater.composeapp.generated.resources.region_code
44 | import updater.composeapp.generated.resources.search_history
45 | import updater.composeapp.generated.resources.submit
46 | import updater.composeapp.generated.resources.system_version
47 | import updater.composeapp.generated.resources.toast_no_info
48 | import utils.MessageUtils.Companion.showMessage
49 |
50 | @Composable
51 | private fun SearchHistoryView(
52 | searchKeywords: MutableState>,
53 | searchKeywordsSelected: MutableState,
54 | onHistorySelect: (String) -> Unit
55 | ) {
56 | AnimatedVisibility(
57 | visible = searchKeywords.value.isNotEmpty(),
58 | enter = fadeIn() + expandVertically(),
59 | exit = fadeOut() + shrinkVertically()
60 | ) {
61 | val localFocusManager = LocalFocusManager.current
62 | val spinnerOptions = searchKeywords.value.map { keyword ->
63 | val parts = keyword.split("-")
64 | SpinnerEntry(
65 | icon = null,
66 | title = "${parts.getOrElse(0) { "" }.ifEmpty { "Unknown" }} (${parts.getOrElse(1) { "" }})",
67 | summary = "${parts.getOrElse(2) { "" }}-${parts.getOrElse(3) { "" }}-${parts.getOrElse(4) { "" }}-${parts.getOrElse(5) { "" }}",
68 | )
69 | }
70 | Card(
71 | modifier = Modifier
72 | .padding(horizontal = 12.dp)
73 | .padding(top = 12.dp)
74 | ) {
75 | SuperSpinner(
76 | title = stringResource(Res.string.search_history),
77 | items = spinnerOptions,
78 | selectedIndex = searchKeywordsSelected.value,
79 | showValue = false,
80 | onSelectedIndexChange = { index ->
81 | onHistorySelect(searchKeywords.value[index])
82 | searchKeywordsSelected.value = index
83 | },
84 | onClick = {
85 | localFocusManager.clearFocus()
86 | },
87 | maxHeight = 280.dp
88 | )
89 | }
90 | }
91 | }
92 |
93 | @Composable
94 | fun BasicViews(
95 | deviceName: MutableState,
96 | codeName: MutableState,
97 | androidVersion: MutableState,
98 | deviceRegion: MutableState,
99 | deviceCarrier: MutableState,
100 | systemVersion: MutableState,
101 | updateRomInfo: MutableState,
102 | searchKeywords: MutableState>,
103 | searchKeywordsSelected: MutableState,
104 | ) {
105 | val androidVersionSelected = remember {
106 | mutableStateOf(DeviceInfoHelper.androidVersions.indexOf(androidVersion.value).takeIf { it >= 0 } ?: 0)
107 | }
108 | val regionSelected = remember {
109 | mutableStateOf(DeviceInfoHelper.regionNames.indexOf(deviceRegion.value).takeIf { it >= 0 } ?: 0)
110 | }
111 |
112 | val carrierSelected = remember {
113 | mutableStateOf(DeviceInfoHelper.carrierNames.indexOf(deviceCarrier.value).takeIf { it >= 0 } ?: 0)
114 | }
115 |
116 | val deviceNames by DeviceInfoHelper.deviceNamesFlow.collectAsState()
117 | val codeNames by DeviceInfoHelper.codeNamesFlow.collectAsState()
118 |
119 | val deviceNameFlow = remember { MutableStateFlow(deviceName.value) }
120 | val codeNameFlow = remember { MutableStateFlow(codeName.value) }
121 |
122 | val toastNoInfo = stringResource(Res.string.toast_no_info)
123 |
124 | val focusManager = LocalFocusManager.current
125 |
126 | LaunchedEffect(deviceNameFlow) {
127 | deviceNameFlow.collect { newValue ->
128 | if (deviceName.value != newValue) {
129 | deviceName.value = newValue
130 | val text = DeviceInfoHelper.codeName(newValue)
131 | if (text.isNotEmpty()) codeName.value = text
132 | }
133 | }
134 | }
135 |
136 | LaunchedEffect(codeNameFlow) {
137 | codeNameFlow.collect { newValue ->
138 | if (codeName.value != newValue) {
139 | codeName.value = newValue
140 | val text = DeviceInfoHelper.deviceName(newValue)
141 | if (text.isNotEmpty()) deviceName.value = text
142 | }
143 | }
144 | }
145 |
146 | Column(
147 | modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
148 | horizontalAlignment = Alignment.CenterHorizontally
149 | ) {
150 | AutoCompleteTextField(
151 | text = deviceName,
152 | items = deviceNames,
153 | onValueChange = deviceNameFlow,
154 | label = stringResource(Res.string.device_name)
155 | )
156 | AutoCompleteTextField(
157 | text = codeName,
158 | items = codeNames,
159 | onValueChange = codeNameFlow,
160 | label = stringResource(Res.string.code_name)
161 | )
162 | TextField(
163 | insideMargin = DpSize(16.dp, 20.dp),
164 | modifier = Modifier
165 | .fillMaxWidth()
166 | .padding(horizontal = 12.dp)
167 | .padding(bottom = 12.dp),
168 | value = systemVersion.value,
169 | onValueChange = { systemVersion.value = it },
170 | label = stringResource(Res.string.system_version),
171 | singleLine = true,
172 | backgroundColor = MiuixTheme.colorScheme.surface,
173 | keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
174 | keyboardActions = KeyboardActions(onSearch = {
175 | focusManager.clearFocus()
176 | if (codeName.value != "" && androidVersion.value != "" && systemVersion.value != "") {
177 | updateRomInfo.value++
178 | } else {
179 | showMessage(toastNoInfo)
180 | }
181 | })
182 | )
183 | Card(
184 | modifier = Modifier
185 | .padding(horizontal = 12.dp)
186 | ) {
187 | SuperDropdown(
188 | title = stringResource(Res.string.android_version),
189 | items = DeviceInfoHelper.androidVersions,
190 | selectedIndex = androidVersionSelected.value,
191 | onSelectedIndexChange = { index ->
192 | androidVersionSelected.value = index
193 | androidVersion.value = DeviceInfoHelper.androidVersions[index]
194 | },
195 | onClick = {
196 | focusManager.clearFocus()
197 | },
198 | maxHeight = 280.dp
199 | )
200 | SuperDropdown(
201 | title = stringResource(Res.string.region_code),
202 | items = DeviceInfoHelper.regionNames,
203 | selectedIndex = regionSelected.value,
204 | onSelectedIndexChange = { index ->
205 | regionSelected.value = index
206 | deviceRegion.value = DeviceInfoHelper.regionNames[index]
207 | },
208 | onClick = {
209 | focusManager.clearFocus()
210 | },
211 | maxHeight = 280.dp
212 | )
213 | SuperDropdown(
214 | title = stringResource(Res.string.carrier_code),
215 | items = DeviceInfoHelper.carrierNames,
216 | selectedIndex = carrierSelected.value,
217 | onSelectedIndexChange = { index ->
218 | carrierSelected.value = index
219 | deviceCarrier.value = DeviceInfoHelper.carrierNames[index]
220 | },
221 | onClick = {
222 | focusManager.clearFocus()
223 | },
224 | maxHeight = 280.dp
225 | )
226 | }
227 | SearchHistoryView(
228 | searchKeywords = searchKeywords,
229 | searchKeywordsSelected = searchKeywordsSelected,
230 | onHistorySelect = { keyword ->
231 | val parts = keyword.split("-")
232 | deviceName.value = parts[0]
233 | codeName.value = parts[1]
234 | deviceRegion.value = parts[2]
235 | deviceCarrier.value = parts[3]
236 | androidVersion.value = parts[4]
237 | systemVersion.value = parts[5]
238 | regionSelected.value = DeviceInfoHelper.regionNames.indexOf(parts[2])
239 | carrierSelected.value = DeviceInfoHelper.carrierNames.indexOf(parts[3])
240 | androidVersionSelected.value = DeviceInfoHelper.androidVersions.indexOf(parts[4])
241 | }
242 | )
243 | TextButton(
244 | modifier = Modifier
245 | .fillMaxWidth()
246 | .padding(top = 12.dp)
247 | .padding(horizontal = 12.dp),
248 | colors = ButtonDefaults.textButtonColorsPrimary(),
249 | onClick = {
250 | focusManager.clearFocus()
251 | if (codeName.value != "" && androidVersion.value != "" && systemVersion.value != "") {
252 | updateRomInfo.value++
253 | } else {
254 | showMessage(toastNoInfo)
255 | }
256 | },
257 | text = stringResource(Res.string.submit)
258 | )
259 | }
260 | }
261 |
--------------------------------------------------------------------------------
/composeApp/src/commonMain/kotlin/Login.kt:
--------------------------------------------------------------------------------
1 | import androidx.compose.runtime.MutableState
2 | import data.DataHelper
3 | import dev.whyoleg.cryptography.DelicateCryptographyApi
4 | import dev.whyoleg.cryptography.algorithms.MD5
5 | import io.ktor.client.plugins.cookies.AcceptAllCookiesStorage
6 | import io.ktor.client.plugins.cookies.HttpCookies
7 | import io.ktor.client.plugins.defaultRequest
8 | import io.ktor.client.request.forms.submitForm
9 | import io.ktor.client.request.get
10 | import io.ktor.client.request.header
11 | import io.ktor.client.request.parameter
12 | import io.ktor.client.statement.bodyAsText
13 | import io.ktor.http.isSuccess
14 | import io.ktor.http.parameters
15 | import io.ktor.http.setCookie
16 | import io.ktor.http.userAgent
17 | import kotlinx.serialization.json.Json
18 | import kotlinx.serialization.json.JsonObject
19 | import kotlinx.serialization.json.int
20 | import kotlinx.serialization.json.intOrNull
21 | import kotlinx.serialization.json.jsonArray
22 | import kotlinx.serialization.json.jsonPrimitive
23 | import platform.httpClientPlatform
24 | import platform.prefGet
25 | import platform.prefRemove
26 | import platform.prefSet
27 | import platform.provider
28 | import kotlin.time.Clock
29 | import kotlin.time.ExperimentalTime
30 |
31 | class Login() {
32 | private val client = httpClientPlatform().config {
33 | install(HttpCookies) {
34 | storage = AcceptAllCookiesStorage()
35 | }
36 | defaultRequest {
37 | userAgent(generateUserAgent())
38 | }
39 | }
40 |
41 | private val accountUrl = "https://account.xiaomi.com"
42 | private val serviceLoginAuth2Url = "$accountUrl/pass/serviceLoginAuth2"
43 |
44 | /**
45 | * Login Xiaomi account.
46 | *
47 | * @param account: Xiaomi account
48 | * @param password: Xiaomi password
49 | * @param global: Global or China account
50 | * @param savePassword: Save password or not
51 | * @param isLogin: Login status
52 | * @param captcha: Captcha if needed
53 | * @param flag: 2FA flag if needed, 4 for phone, 8 for email
54 | * @param ticket: 2FA ticket if needed
55 | *
56 | * @return Login status
57 | */
58 | @OptIn(ExperimentalTime::class)
59 | suspend fun login(
60 | account: String,
61 | password: String,
62 | global: Boolean,
63 | savePassword: String,
64 | isLogin: MutableState,
65 | captcha: String = "",
66 | flag: Int? = null,
67 | ticket: String = "",
68 | ): Int {
69 | if (account.isEmpty() || password.isEmpty()) return 1 // 1: 输入为空
70 | if (savePassword == "1") {
71 | Password().savePassword(account, password)
72 | } else {
73 | Password().deletePassword()
74 | }
75 | try {
76 | if (flag != null && ticket.isEmpty()) {
77 | return send2FATicket(flag = flag)
78 | }
79 | if (flag != null && ticket.isNotEmpty()) {
80 | val verify2FATicket = verify2FATicket(flag = flag, ticket = ticket)
81 | if (!verify2FATicket) return 3 // 3: 登录失败
82 | }
83 | return serviceLoginAuth2(
84 | account = account,
85 | password = password,
86 | global = global,
87 | isLogin = isLogin,
88 | captcha = captcha,
89 | )
90 | } catch (_: Exception) {
91 | return 2 // 2: 网络错误
92 | }
93 | }
94 |
95 | /**
96 | * Logout Xiaomi account.
97 | *
98 | * @param isLogin: Login status
99 | *
100 | * @return Logout status
101 | */
102 | fun logout(isLogin: MutableState): Boolean {
103 | prefRemove("loginInfo")
104 | isLogin.value = 0
105 | return true
106 | }
107 |
108 | /**
109 | * Service login Xiaomi account with password.
110 | *
111 | * @param account: Xiaomi account
112 | * @param password: Xiaomi password
113 | * @param global: Global or China account
114 | * @param isLogin: Login status
115 | * @param captcha: Captcha if needed
116 | *
117 | * @return Login status
118 | */
119 | suspend fun serviceLoginAuth2(
120 | account: String,
121 | password: String,
122 | global: Boolean,
123 | isLogin: MutableState,
124 | captcha: String = "",
125 | ): Int {
126 | val md5Hash = md5Hash(password).uppercase()
127 | val sid = if (global) "miuiota_intl" else "miuiromota"
128 |
129 | val parameters = parameters {
130 | append("sid", sid)
131 | append("hash", md5Hash)
132 | append("user", account)
133 | append("_json", "true")
134 | append("_locale", if (global) "en_US" else "zh_CN")
135 | if (captcha.isNotEmpty()) append("captCode", captcha)
136 | }
137 | val response = client.submitForm(serviceLoginAuth2Url, parameters)
138 | if (!response.status.isSuccess()) return 2 // 2: 网络错误
139 |
140 | val content = Json.decodeFromString(removeResponsePrefix(response.bodyAsText()))
141 | val ssecurity = content["ssecurity"]?.jsonPrimitive?.content
142 | val captchaUrl = content["captchaUrl"]?.jsonPrimitive?.content
143 | val notificationUrl = content["notificationUrl"]?.jsonPrimitive?.content
144 | val result = content["result"]?.jsonPrimitive?.content
145 |
146 | if (captchaUrl != null && captchaUrl != "null") {
147 | prefSet("captchaUrl", captchaUrl)
148 | return 6 // 6: 需要验证码
149 | }
150 |
151 | if (notificationUrl != null && notificationUrl != "null") {
152 | val context = getQueryParam(notificationUrl, "context")
153 | if (context.isNullOrEmpty()) return 3 // 3: 登录失败
154 | prefSet("2FAContext", context)
155 | val response = client.get(notificationUrl.replace("fe/service/identity/authStart", "identity/list"))
156 | val identitySession = requireNotNull(response.setCookie().find { it.name == "identity_session" }?.value)
157 | prefSet("identity_session", identitySession)
158 | val listJson = Json.decodeFromString(removeResponsePrefix(response.bodyAsText()))
159 | val options = listJson["options"]?.jsonArray?.mapNotNull { it.jsonPrimitive.intOrNull } ?: emptyList()
160 | if (options.isEmpty()) return 3 // 3: 登录失败
161 | if (options.contains(4)) prefSet("notificationUrl", notificationUrl)
162 | prefSet("2FAOptions", Json.encodeToString(options))
163 | return 5 // 5: 需要二次验证
164 | }
165 |
166 | if ((result != null && result != "ok") || ssecurity.isNullOrBlank()) {
167 | return 3 // 3: 登录失败
168 | }
169 |
170 | val location = requireNotNull(content["location"]?.jsonPrimitive?.content)
171 | val response2 = client.get("$location&_userIdNeedEncrypt=true")
172 | if (!response2.status.isSuccess()) return 2 // 2: 网络错误
173 |
174 | val userId = requireNotNull(content["userId"]?.jsonPrimitive?.content)
175 | val cUserId = requireNotNull(content["cUserId"]?.jsonPrimitive?.content)
176 | val serviceToken =
177 | requireNotNull(response2.setCookie().find { it.name == "serviceToken" && it.value.isNotBlank() }?.value)
178 | if (serviceToken == "") return 4 // 4: 未返回 serviceToken
179 |
180 | val loginInfo = DataHelper.LoginData(
181 | accountType = if (global) "GL" else "CN",
182 | authResult = "1",
183 | description = "成功",
184 | ssecurity = ssecurity,
185 | serviceToken = serviceToken,
186 | userId = userId,
187 | cUserId = cUserId
188 | )
189 | prefSet("loginInfo", Json.encodeToString(loginInfo))
190 | isLogin.value = 1
191 | return 0 // 0: 登录成功
192 | }
193 |
194 | /**
195 | * Send 2FA ticket(phone or email).
196 | *
197 | * @return Send status
198 | */
199 | @OptIn(ExperimentalTime::class)
200 | suspend fun send2FATicket(flag: Int): Int {
201 | val sendTicketUrl = if (flag == 4) {
202 | "https://account.xiaomi.com/identity/auth/sendPhoneTicket"
203 | } else {
204 | "https://account.xiaomi.com/identity/auth/sendEmailTicket"
205 | }
206 | val parameters = parameters {
207 | append("retry", "0")
208 | append("icode", "")
209 | append("_json", "true")
210 | }
211 | val response = client.submitForm(sendTicketUrl, parameters) {
212 | parameter("_dc", Clock.System.now().toEpochMilliseconds())
213 | header("cookie", "identity_session=${prefGet("identity_session") ?: ""}")
214 | }
215 | val sendTicketText = response.bodyAsText()
216 | val sendTicketJson = Json.decodeFromString(removeResponsePrefix(sendTicketText))
217 | return sendTicketJson["code"]?.jsonPrimitive?.intOrNull ?: 3 // 3: 登录失败
218 | }
219 |
220 | /**
221 | * Verify 2FA ticket(phone or email).
222 | *
223 | * @param flag: 4 for phone, 8 for email
224 | * @param ticket: 2FA ticket
225 | *
226 | * @return Handle 2FA ticket status
227 | */
228 | @OptIn(ExperimentalTime::class)
229 | suspend fun verify2FATicket(flag: Int, ticket: String): Boolean {
230 | val apiPath = if (flag == 4) "/identity/auth/verifyPhone" else "/identity/auth/verifyEmail"
231 | val apiUrl = "$accountUrl$apiPath"
232 |
233 | val parameters = parameters {
234 | append("_flag", flag.toString())
235 | append("ticket", ticket)
236 | append("trust", "true")
237 | append("_json", "true")
238 | }
239 | val verifyResponse = client.submitForm(apiUrl, parameters) {
240 | parameter("_dc", Clock.System.now().toEpochMilliseconds())
241 | header("cookie", "identity_session=${prefGet("identity_session") ?: ""}")
242 | }
243 | val verifyBody = Json.decodeFromString(removeResponsePrefix(verifyResponse.bodyAsText()))
244 | if (verifyBody["code"]?.jsonPrimitive?.int == 0) {
245 | val location = requireNotNull(verifyBody["location"]?.jsonPrimitive?.content)
246 | client.get(location)
247 | return true
248 | }
249 | return false
250 | }
251 |
252 | /** Generate User-Agent(Xiaomi 17 Pro).
253 | *
254 | * @return User-Agent
255 | */
256 | fun generateUserAgent(): String {
257 | return "Dalvik/2.1.0 (Linux; U; Android 16; 25098PN5AC Build/BP2A.250605.031.A3)"
258 | }
259 |
260 | /**
261 | * Remove the prefix "&&&START&&&" from the response string.
262 | *
263 | * @param response: Response string
264 | *
265 | * @return Response string without the prefix
266 | */
267 | private fun removeResponsePrefix(response: String): String {
268 | return response.removePrefix("&&&START&&&")
269 | }
270 |
271 | /**
272 | * Get query parameter from URL.
273 | *
274 | * @param url: URL string
275 | * @param key: Query parameter key
276 | *
277 | * @return Query parameter value or null if not found
278 | */
279 | fun getQueryParam(url: String, key: String): String? {
280 | val query = url.substringAfter('?', "")
281 | return query.split('&')
282 | .map { it.split('=') }
283 | .firstOrNull { it.size == 2 && it[0] == key }
284 | ?.get(1)
285 | }
286 |
287 | /**
288 | * Generate MD5 hash.
289 | *
290 | * @param input: Input string
291 | *
292 | * @return MD5 hash
293 | */
294 | @OptIn(DelicateCryptographyApi::class)
295 | suspend fun md5Hash(input: String): String {
296 | val md = provider().get(MD5)
297 | return md.hasher().hash(input.encodeToByteArray()).joinToString("") {
298 | val hex = (it.toInt() and 0xFF).toString(16).uppercase()
299 | if (hex.length == 1) "0$hex" else hex
300 | }
301 | }
302 | }
303 |
--------------------------------------------------------------------------------