├── .github └── workflows │ └── android.yml ├── .gitignore ├── app ├── .gitignore ├── build.gradle.kts ├── lib │ └── Bouncycastle.jar ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ └── com │ │ └── github │ │ └── sky130 │ │ └── suiteki │ │ └── pro │ │ ├── MainActivity.kt │ │ ├── MainApplication.kt │ │ ├── SuitekiDestination.kt │ │ ├── basic │ │ └── SuitekiActivity.kt │ │ ├── device │ │ ├── huami │ │ │ ├── HuamiAppListHelper.kt │ │ │ ├── HuamiAuthService.kt │ │ │ ├── HuamiBleSupport.kt │ │ │ ├── HuamiDevice.kt │ │ │ ├── HuamiInstallHelper.kt │ │ │ ├── HuamiService.kt │ │ │ └── miband7 │ │ │ │ └── Miband7.kt │ │ └── xiaomi │ │ │ ├── XiaomiAbstractSupport.kt │ │ │ ├── XiaomiAppListHelper.kt │ │ │ ├── XiaomiAuthService.kt │ │ │ ├── XiaomiBleSupport.kt │ │ │ ├── XiaomiChannelHandler.kt │ │ │ ├── XiaomiDevice.kt │ │ │ ├── XiaomiFWHelper.kt │ │ │ ├── XiaomiInstallHelper.kt │ │ │ ├── XiaomiService.kt │ │ │ ├── XiaomiSppPacket.kt │ │ │ ├── XiaomiSppSupport.kt │ │ │ ├── miband8 │ │ │ └── MiBand8.kt │ │ │ ├── miband8pro │ │ │ └── MiBand8Pro.kt │ │ │ ├── redmiwatch4 │ │ │ └── RedmiWatch4.kt │ │ │ └── watchs3 │ │ │ └── WatchS3.kt │ │ ├── logic │ │ ├── ble │ │ │ ├── AbstractBleDevice.kt │ │ │ ├── ClassesReader.kt │ │ │ ├── InstallStatus.kt │ │ │ └── SuitekiManager.kt │ │ ├── database │ │ │ ├── AppDatabase.kt │ │ │ ├── dao │ │ │ │ └── DeviceDAO.kt │ │ │ └── model │ │ │ │ └── Device.kt │ │ └── handler │ │ │ └── CrashHandler.kt │ │ ├── screen │ │ ├── app │ │ │ └── AppScreen.kt │ │ ├── folder │ │ │ └── FolderScreen.kt │ │ └── main │ │ │ ├── MainScreen.kt │ │ │ ├── device │ │ │ └── DeviceScreen.kt │ │ │ ├── home │ │ │ └── HomeScreen.kt │ │ │ └── more │ │ │ └── MoreScreen.kt │ │ ├── ui │ │ ├── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ │ └── widget │ │ │ ├── CircularProgressIndicator.kt │ │ │ ├── SuitekiDialog.kt │ │ │ ├── SuitekiScaffold.kt │ │ │ └── SuitekiTopBar.kt │ │ └── util │ │ ├── BytesUtils.kt │ │ ├── CheckSums.kt │ │ ├── CryptoUtils.kt │ │ ├── ECDH_B163.java │ │ ├── StringUtils.kt │ │ ├── TextUtils.kt │ │ └── ZipUtils.kt │ ├── proto │ └── xiaomi.proto │ └── res │ ├── drawable │ ├── ic_launcher_background.xml │ ├── ic_launcher_foreground.xml │ └── ic_suiteki.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-mdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── mipmap-xxxhdpi │ ├── ic_launcher.webp │ └── ic_launcher_round.webp │ ├── values │ ├── colors.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ └── data_extraction_rules.xml ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: set up JDK 17 14 | uses: actions/setup-java@v3 15 | with: 16 | java-version: '17' 17 | distribution: 'temurin' 18 | cache: gradle 19 | 20 | - name: Grant execute permission for gradlew 21 | run: chmod +x gradlew 22 | - name: Build with Gradle 23 | run: ./gradlew build 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | .idea 17 | /app/bak/ 18 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /src/main/java/com/github/sky130/suiteki/pro/AppCenter.kt 3 | /src/main/java/com/github/sky130/suiteki/pro/AppCenter.kt 4 | -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import com.google.protobuf.gradle.id 2 | 3 | plugins { 4 | alias(libs.plugins.android.application) 5 | alias(libs.plugins.jetbrains.kotlin.android) 6 | alias(libs.plugins.ksp) 7 | alias(libs.plugins.google.protobuf) 8 | } 9 | 10 | android { 11 | namespace = "com.github.sky130.suiteki.pro" 12 | compileSdk = 34 13 | 14 | defaultConfig { 15 | applicationId = "com.github.sky130.suiteki.pro" 16 | minSdk = 21 17 | targetSdk = 34 18 | versionCode = 2 19 | versionName = "0.1.1-Dev" 20 | 21 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 22 | vectorDrawables { 23 | useSupportLibrary = true 24 | } 25 | ksp { 26 | arg("room.schemaLocation", "$projectDir/schemas") 27 | } 28 | } 29 | 30 | buildTypes { 31 | release { 32 | isMinifyEnabled = true 33 | isShrinkResources = true 34 | proguardFiles( 35 | getDefaultProguardFile("proguard-android-optimize.txt"), 36 | "proguard-rules.pro" 37 | ) 38 | } 39 | debug { 40 | isMinifyEnabled = false 41 | isShrinkResources = false 42 | isDebuggable = true 43 | applicationIdSuffix = ".dev" 44 | versionNameSuffix = "-DEV" 45 | } 46 | } 47 | compileOptions { 48 | sourceCompatibility = JavaVersion.VERSION_1_8 49 | targetCompatibility = JavaVersion.VERSION_1_8 50 | } 51 | kotlinOptions { 52 | jvmTarget = "1.8" 53 | } 54 | buildFeatures { 55 | compose = true 56 | } 57 | composeOptions { 58 | kotlinCompilerExtensionVersion = "1.5.12" 59 | } 60 | packaging { 61 | resources { 62 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 63 | } 64 | } 65 | sourceSets { 66 | // this["release"].java.srcDir(protobuf.generatedFilesBaseDir) 67 | this["debug"].java.srcDir(protobuf.generatedFilesBaseDir) 68 | } 69 | } 70 | 71 | dependencies { 72 | 73 | implementation(libs.androidx.core.ktx) 74 | implementation(libs.androidx.lifecycle.runtime.ktx) 75 | implementation(libs.androidx.activity.compose) 76 | implementation(platform(libs.androidx.compose.bom)) 77 | implementation(libs.androidx.ui) 78 | implementation(libs.androidx.ui.graphics) 79 | implementation(libs.androidx.ui.tooling.preview) 80 | implementation(libs.androidx.material3) 81 | 82 | 83 | implementation(libs.okhttp) 84 | implementation(libs.okio) 85 | 86 | 87 | 88 | implementation(libs.gson) 89 | implementation(libs.flexbox) 90 | debugImplementation(libs.ui.tooling) 91 | 92 | 93 | ksp(libs.androidx.room.compiler) 94 | implementation(libs.androidx.room.ktx) 95 | implementation(libs.androidx.room.runtime) 96 | 97 | implementation(libs.materialkolor) 98 | 99 | 100 | implementation(libs.material.icon) 101 | implementation(libs.navigation.compose) 102 | implementation(libs.navigation.animation) 103 | implementation(libs.fastble) 104 | 105 | implementation(libs.apache.commons.lang3) 106 | 107 | implementation(libs.flexible.bottomsheet.material3) 108 | 109 | implementation(libs.protobuf.java) 110 | 111 | implementation(libs.compose.destinations.core) 112 | ksp(libs.compose.destinations.ksp) 113 | 114 | implementation(libs.circularprogressbar.compose) 115 | 116 | implementation(libs.permission.flow.compose) 117 | 118 | implementation(libs.microsoft.appcenter.analytics) 119 | implementation(libs.microsoft.appcenter.crashes) 120 | 121 | 122 | 123 | implementation(files("lib/Bouncycastle.jar")) 124 | implementation(kotlin("reflect")) 125 | } 126 | 127 | 128 | 129 | protobuf { 130 | protoc { 131 | artifact = "com.google.protobuf:protoc:3.0.0" 132 | } 133 | generateProtoTasks { 134 | all().configureEach { 135 | builtins { 136 | id("java") {} 137 | } 138 | } 139 | } 140 | } 141 | 142 | tasks.withType { 143 | options.compilerArgs.apply{ 144 | add("-Xlint:deprecation") 145 | add("-Xlint:none") 146 | } 147 | options.isWarnings = false 148 | } 149 | 150 | afterEvaluate { 151 | tasks.named("kspDebugKotlin") { 152 | dependsOn("generateDebugProto") 153 | } 154 | tasks.named("kspReleaseKotlin") { 155 | dependsOn("generateReleaseProto") 156 | } 157 | tasks.named("generateReleaseProto") { 158 | dependsOn("compileDebugJavaWithJavac") 159 | } 160 | tasks.named("generateDebugLintReportModel") { 161 | dependsOn("generateReleaseProto") 162 | } 163 | tasks.named("lintAnalyzeDebug") { 164 | dependsOn("generateReleaseProto") 165 | } 166 | } 167 | 168 | 169 | 170 | -------------------------------------------------------------------------------- /app/lib/Bouncycastle.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/lib/Bouncycastle.jar -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | 23 | -keep class com.github.sky130.suiteki.pro.** {*;} -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 | 24 | 27 | 28 | 39 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro 2 | 3 | import android.Manifest 4 | import android.content.Intent 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.os.Environment 8 | import android.provider.Settings 9 | import androidx.compose.foundation.background 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.material3.ExperimentalMaterial3Api 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.runtime.Composable 14 | import androidx.compose.runtime.LaunchedEffect 15 | import androidx.compose.runtime.getValue 16 | import androidx.compose.runtime.mutableStateOf 17 | import androidx.compose.runtime.setValue 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.tooling.preview.Preview 20 | import com.github.sky130.suiteki.pro.basic.SuitekiActivity 21 | import com.github.sky130.suiteki.pro.ui.theme.SuitekiTheme 22 | import com.ramcosta.composedestinations.DestinationsNavHost 23 | import com.ramcosta.composedestinations.generated.NavGraphs 24 | import dev.shreyaspatil.permissionFlow.utils.launch 25 | import dev.shreyaspatil.permissionflow.compose.rememberMultiplePermissionState 26 | import dev.shreyaspatil.permissionflow.compose.rememberPermissionFlowRequestLauncher 27 | import kotlinx.coroutines.delay 28 | 29 | 30 | class MainActivity : SuitekiActivity() { 31 | 32 | @OptIn(ExperimentalMaterial3Api::class) 33 | @Composable 34 | @Preview 35 | override fun Content() { 36 | SuitekiTheme { 37 | val permissionLauncher = rememberPermissionFlowRequestLauncher() 38 | val list = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { 39 | arrayOf( 40 | Manifest.permission.BLUETOOTH, 41 | Manifest.permission.BLUETOOTH_ADMIN, 42 | Manifest.permission.READ_EXTERNAL_STORAGE 43 | ) 44 | } else { 45 | arrayOf( 46 | Manifest.permission.BLUETOOTH, 47 | Manifest.permission.BLUETOOTH_ADMIN, 48 | Manifest.permission.BLUETOOTH_SCAN, 49 | Manifest.permission.BLUETOOTH_ADVERTISE, 50 | Manifest.permission.BLUETOOTH_CONNECT 51 | ) 52 | } 53 | val state by rememberMultiplePermissionState(*list) 54 | LaunchedEffect(Unit) { 55 | requestExternalStorage() 56 | } 57 | if (state.allGranted) { 58 | if (isExternalStorageState) { 59 | DestinationsNavHost(navGraph = NavGraphs.root) 60 | } 61 | } else { 62 | Box(modifier = Modifier.background(MaterialTheme.colorScheme.background)) 63 | LaunchedEffect(Unit) { 64 | delay(100) 65 | permissionLauncher.launch(*list) 66 | } 67 | return@SuitekiTheme 68 | } 69 | } 70 | } 71 | 72 | private var isExternalStorageState by mutableStateOf(false) 73 | 74 | override fun onResume() { 75 | super.onResume() 76 | requestExternalStorage() 77 | } 78 | 79 | private fun requestExternalStorage() { 80 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 81 | if (!Environment.isExternalStorageManager()) { 82 | val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) 83 | intent.setData(Uri.parse("package:$packageName")) 84 | startActivity(intent) 85 | isExternalStorageState = false 86 | } else { 87 | isExternalStorageState = true 88 | } 89 | } 90 | isExternalStorageState = true 91 | } 92 | 93 | override fun init() { 94 | 95 | } 96 | 97 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro 2 | 3 | import android.R.attr.label 4 | import android.R.attr.text 5 | import android.annotation.SuppressLint 6 | import android.app.Application 7 | import android.content.ClipData 8 | import android.content.ClipboardManager 9 | import android.content.Context 10 | import android.content.Intent 11 | import android.content.Intent.FLAG_ACTIVITY_NEW_TASK 12 | import android.net.Uri 13 | import android.widget.Toast 14 | import androidx.core.content.ContextCompat.getSystemService 15 | import androidx.core.content.getSystemService 16 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 17 | import com.github.sky130.suiteki.pro.logic.handler.CrashHandler 18 | 19 | 20 | @SuppressLint("StaticFieldLeak") 21 | class MainApplication : Application() { 22 | companion object { 23 | private var mContext: Context? = null 24 | private var mApplication: Application? = null 25 | 26 | val context get() = mContext!! 27 | val application get() = mApplication!! 28 | 29 | fun openUrl(url: String) { 30 | val uri = Uri.parse(url) 31 | val intent = Intent(Intent.ACTION_VIEW, uri) 32 | intent.addFlags(FLAG_ACTIVITY_NEW_TASK) 33 | context.startActivity(intent) 34 | } 35 | 36 | fun String.copy() { 37 | val clipboard = context.getSystemService()!! 38 | val clip = ClipData.newPlainText(this, this) 39 | clipboard.setPrimaryClip(clip) 40 | } 41 | 42 | fun String.toast() { 43 | Toast.makeText(context, this, Toast.LENGTH_SHORT).show() 44 | } 45 | 46 | } 47 | 48 | override fun onCreate() { 49 | super.onCreate() 50 | mContext = this 51 | mApplication = this 52 | com.clj.fastble.BleManager.getInstance().enableLog(true).init(this) 53 | SuitekiManager.init() 54 | try { 55 | Class.forName("com.github.sky130.suiteki.pro.AppCenter") 56 | .getDeclaredMethod("justDoIt") 57 | .invoke(null) 58 | } catch (e: Exception) { 59 | e.printStackTrace() 60 | } 61 | CrashHandler.instance.init(this) 62 | } 63 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/SuitekiDestination.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro 2 | 3 | import androidx.compose.material.icons.Icons 4 | import androidx.compose.material.icons.filled.Apps 5 | import androidx.compose.material.icons.filled.Home 6 | import androidx.compose.material.icons.filled.Watch 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.ui.graphics.vector.ImageVector 9 | 10 | val suitekiScreens = listOf(Home, Device, More) 11 | 12 | interface SuitekiDestination { 13 | val icon: ImageVector 14 | val label: String 15 | val route: String 16 | } 17 | 18 | object Home : SuitekiDestination { 19 | override val icon = Icons.Filled.Home 20 | override val label = "主页" 21 | override val route = "home" 22 | } 23 | 24 | object Device : SuitekiDestination { 25 | override val icon = Icons.Filled.Watch 26 | override val label = "设备" 27 | override val route = "device" 28 | } 29 | 30 | object More : SuitekiDestination { 31 | override val icon = Icons.Filled.Apps 32 | override val label = "更多" 33 | override val route = "more" 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/basic/SuitekiActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.basic 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.runtime.Composable 8 | 9 | abstract class SuitekiActivity : ComponentActivity() { 10 | 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | init() 14 | enableEdgeToEdge() 15 | setContent { 16 | Content() 17 | } 18 | } 19 | 20 | @Composable 21 | abstract fun Content() 22 | 23 | open fun init(){} 24 | 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiAppListHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami 2 | 3 | import androidx.compose.runtime.mutableStateListOf 4 | import com.github.sky130.suiteki.pro.logic.ble.AppInfo 5 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 6 | import com.github.sky130.suiteki.pro.util.BytesUtils 7 | import org.apache.commons.lang3.ArrayUtils 8 | import java.nio.ByteBuffer 9 | import java.nio.ByteOrder 10 | 11 | class HuamiAppListHelper(val device: HuamiDevice) { 12 | 13 | companion object { 14 | const val CMD_RESPONSE: Byte = 0x04 15 | 16 | const val CMD_APPS: Byte = 0x02 17 | const val CMD_INCOMING: Byte = 0x00 18 | const val CMD_OUTGOING: Byte = 0x01 19 | 20 | const val CMD_APPS_LIST: Byte = 0x01 21 | const val CMD_APPS_DELETE: Byte = 0x03 22 | const val CMD_APPS_DELETING: Byte = 0x04 23 | const val CMD_APPS_LAUNCH: Byte = 0x06 24 | const val CMD_APPS_API_LEVEL: Byte = 0x05 25 | 26 | val displayItemNameLookup: HashMap = object : HashMap() { 27 | init { 28 | put("00000001", "personal_activity_intelligence") 29 | put("00000002", "hr") 30 | put("00000003", "workout") 31 | put("00000004", "weather") 32 | put("00000009", "alarm") 33 | put("00000010", "wallet") 34 | put("0000000A", "takephoto") 35 | put("0000000B", "music") 36 | put("0000000C", "stopwatch") 37 | put("0000000D", "countdown") 38 | put("0000000E", "findphone") 39 | put("0000000F", "mutephone") 40 | put("00000011", "alipay") 41 | put("00000013", "settings") 42 | put("00000014", "workout_history") 43 | put("00000015", "eventreminder") 44 | put("00000016", "compass") 45 | put("00000019", "pai") 46 | put("00000031", "wechat_pay") 47 | put("0000001A", "worldclock") 48 | put("0000001C", "stress") 49 | put("0000001D", "female_health") 50 | put("0000001E", "workout_status") 51 | put("00000020", "calendar") 52 | put("00000023", "sleep") 53 | put("00000024", "spo2") 54 | put("00000025", "phone") 55 | put("00000026", "events") 56 | put("00000033", "breathing") 57 | put("00000038", "pomodoro") 58 | put("0000003E", "todo") 59 | put("0000003F", "mi_ai") 60 | put("00000041", "barometer") 61 | put("00000042", "voice_memos") 62 | put("00000044", "sun_moon") 63 | put("00000045", "one_tap_measuring") 64 | put("00000047", "membership_cards") 65 | put("00000100", "alexa") 66 | put("00000101", "offline_voice") 67 | put("00000102", "flashlight") 68 | } 69 | } 70 | } 71 | 72 | val list = mutableStateListOf() 73 | 74 | fun handlePayload(payload: ByteArray) { 75 | when (payload[0]) { 76 | CMD_RESPONSE -> { 77 | decodeAndUpdateDisplayItems(payload) 78 | } 79 | } 80 | } 81 | 82 | fun launch(appId: String) { 83 | ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN).apply { 84 | put(0x07) 85 | putInt(BytesUtils.hexToInt(appId)) 86 | device.write(0x0023.toShort(), array(), extendedFlags = true, encrypt = true) 87 | } 88 | } 89 | 90 | fun uninstall(appId: String) { 91 | ByteBuffer.allocate(20).order(ByteOrder.LITTLE_ENDIAN).apply { 92 | 93 | put(CMD_APPS) 94 | put(CMD_OUTGOING) 95 | put(CMD_APPS_DELETE) 96 | put(0x00.toByte()) 97 | putInt(0x00) 98 | putInt(0x00) 99 | putInt(0x00) 100 | putInt(BytesUtils.hexToInt(appId)) 101 | 102 | device.write(0x00a0, array(), extendedFlags = true, encrypt = false) 103 | } 104 | list.removeAt(list.indexOfFirst { it.id == appId }) 105 | } 106 | 107 | 108 | private fun decodeAndUpdateDisplayItems(payload: ByteArray) { 109 | if (payload.isEmpty()) return 110 | if (payload[1] != 0x01.toByte()) return 111 | 112 | val numberScreens = payload[2].toInt() 113 | if (payload.size != 4 + numberScreens * 12) return 114 | 115 | list.clear() 116 | for (i in 0 until numberScreens) { 117 | val screenId = String(ArrayUtils.subarray(payload, 4 + i * 12, 4 + i * 12 + 8)) 118 | if (screenId in displayItemNameLookup) continue 119 | list.add(AppInfo(screenId, "unknown")) 120 | } 121 | } 122 | 123 | fun requestAppItems() { 124 | SuitekiManager.log("requestAppItems") 125 | device.write( 126 | 0x0026.toShort(), 127 | byteArrayOf(CMD_APPS_DELETE, CMD_OUTGOING), 128 | extendedFlags = true, 129 | encrypt = true 130 | ) 131 | } 132 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiAuthService.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.DeviceStatus 4 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 5 | import com.github.sky130.suiteki.pro.util.BytesUtils 6 | import com.github.sky130.suiteki.pro.util.CryptoUtils 7 | import com.github.sky130.suiteki.pro.util.ECDH_B163 8 | import org.apache.commons.lang3.ArrayUtils 9 | import java.nio.ByteBuffer 10 | import java.util.Random 11 | 12 | class HuamiAuthService(private val device: HuamiDevice) { 13 | private lateinit var publicEC: ByteArray 14 | private lateinit var sharedEC: ByteArray 15 | private var authKey =BytesUtils.getSecretKey(device.key) 16 | private var writeHandle: Byte = 0 17 | private var privateEC = ByteArray(24) 18 | private var remotePublicEC = ByteArray(48) 19 | private val remoteRandom = ByteArray(16) 20 | private val finalSharedSessionAES = ByteArray(16) 21 | private var encryptedSequenceNr = 0 22 | private var currentHandle: Byte? = null 23 | private var currentType = 0 24 | private var currentLength = 0 25 | private var reassemblyBuffer: ByteBuffer? = null 26 | 27 | 28 | fun startAuth() { 29 | Random().nextBytes(privateEC) 30 | publicEC = ECDH_B163.ecdh_generate_public(privateEC) 31 | val sendPubkeyCommand = ByteArray(48 + 4) 32 | sendPubkeyCommand[0] = 0x04 33 | sendPubkeyCommand[1] = 0x02 34 | sendPubkeyCommand[2] = 0x00 35 | sendPubkeyCommand[3] = 0x02 36 | System.arraycopy(publicEC, 0, sendPubkeyCommand, 4, 48) 37 | huamiWrite(0x0082.toShort(), sendPubkeyCommand, true, false) 38 | } 39 | 40 | fun huamiWrite( 41 | type: Short, data: ByteArray, extendedFlags: Boolean, 42 | encrypt: Boolean, 43 | ) { 44 | var data = data 45 | if (encrypt && authKey == null) return 46 | writeHandle++ 47 | var remaining = data.size 48 | val length = data.size 49 | var count: Byte = 0 50 | var header_size = 10 51 | if (extendedFlags) { 52 | header_size++ 53 | } 54 | if (extendedFlags && encrypt) { 55 | val messagekey = ByteArray(16) 56 | for (i in 0..15) { 57 | messagekey[i] = (authKey[i].toInt() xor writeHandle.toInt()).toByte() 58 | } 59 | var encrypted_length = length + 8 60 | val overflow = encrypted_length % 16 61 | if (overflow > 0) { 62 | encrypted_length += 16 - overflow 63 | } 64 | val encryptable_payload = ByteArray(encrypted_length) 65 | System.arraycopy(data, 0, encryptable_payload, 0, length) 66 | encryptable_payload[length] = (encryptedSequenceNr and 0xff).toByte() 67 | encryptable_payload[length + 1] = (encryptedSequenceNr shr 8 and 0xff).toByte() 68 | encryptable_payload[length + 2] = (encryptedSequenceNr shr 16 and 0xff).toByte() 69 | encryptable_payload[length + 3] = (encryptedSequenceNr shr 24 and 0xff).toByte() 70 | encryptedSequenceNr++ 71 | val checksum: Int = BytesUtils.getCRC32(encryptable_payload, 0, length + 4) 72 | encryptable_payload[length + 4] = (checksum and 0xff).toByte() 73 | encryptable_payload[length + 5] = (checksum shr 8 and 0xff).toByte() 74 | encryptable_payload[length + 6] = (checksum shr 16 and 0xff).toByte() 75 | encryptable_payload[length + 7] = (checksum shr 24 and 0xff).toByte() 76 | remaining = encrypted_length 77 | data = try { 78 | CryptoUtils.encryptAES(encryptable_payload, messagekey) 79 | } catch (e: Exception) { 80 | return 81 | } 82 | } 83 | while (remaining > 0) { 84 | val MAX_CHUNKLENGTH = 20 - header_size 85 | val copyBytes = Math.min(remaining, MAX_CHUNKLENGTH) 86 | val chunk = ByteArray(copyBytes + header_size) 87 | var flags: Byte = 0 88 | if (encrypt) { 89 | flags = (flags.toInt() or 0x08).toByte() 90 | } 91 | if (count.toInt() == 0) { 92 | flags = (flags.toInt() or 0x01).toByte() 93 | var i = 4 94 | if (extendedFlags) { 95 | i++ 96 | } 97 | chunk[i++] = (length and 0xff).toByte() 98 | chunk[i++] = (length shr 8 and 0xff).toByte() 99 | chunk[i++] = (length shr 16 and 0xff).toByte() 100 | chunk[i++] = (length shr 24 and 0xff).toByte() 101 | chunk[i++] = (type.toInt() and 0xff).toByte() 102 | chunk[i] = (type.toInt() shr 8 and 0xff).toByte() 103 | } 104 | if (remaining <= MAX_CHUNKLENGTH) { 105 | flags = (flags.toInt() or 0x06).toByte() // last chunk? 106 | } 107 | chunk[0] = 0x03 108 | chunk[1] = flags 109 | if (extendedFlags) { 110 | chunk[2] = 0 111 | chunk[3] = writeHandle 112 | chunk[4] = count 113 | } else { 114 | chunk[2] = writeHandle 115 | chunk[3] = count 116 | } 117 | System.arraycopy(data, data.size - remaining, chunk, header_size, copyBytes) 118 | write( 119 | chunk, 120 | HuamiService.UUID_SERVICE_MIBAND_SERVICE, 121 | HuamiService.UUID_CHARACTERISTIC_AUTH_WRITE 122 | ) 123 | remaining -= copyBytes 124 | header_size = 4 125 | if (extendedFlags) { 126 | header_size++ 127 | } 128 | count++ 129 | } 130 | } 131 | 132 | private fun decode(data: ByteArray) { 133 | var i = 0 134 | if (data[i++].toInt() != 0x03) { 135 | return 136 | } 137 | val flags = data[i++] 138 | val encrypted = flags.toInt() and 0x08 == 0x08 139 | val firstChunk = flags.toInt() and 0x01 == 0x01 140 | val lastChunk = flags.toInt() and 0x02 == 0x02 141 | i++ 142 | val handle = data[i++] 143 | if (currentHandle != null && currentHandle != handle) { 144 | return 145 | } 146 | val count = data[i++] 147 | if (firstChunk) { // beginning 148 | var full_length = 149 | data[i++].toInt() and 0xff or (data[i++].toInt() and 0xff shl 8) or (data[i++].toInt() and 0xff shl 16) or (data[i++].toInt() and 0xff shl 24) 150 | currentLength = full_length 151 | if (encrypted) { 152 | var encrypted_length = full_length + 8 153 | val overflow = encrypted_length % 16 154 | if (overflow > 0) { 155 | encrypted_length += 16 - overflow 156 | } 157 | full_length = encrypted_length 158 | } 159 | reassemblyBuffer = ByteBuffer.allocate(full_length) 160 | currentType = data[i++].toInt() and 0xff or (data[i++].toInt() and 0xff shl 8) 161 | currentHandle = handle 162 | } 163 | reassemblyBuffer!!.put(data, i, data.size - i) 164 | if (lastChunk) { // end 165 | var buf = reassemblyBuffer!!.array() 166 | if (encrypted) { 167 | if (authKey == null) { 168 | currentHandle = null 169 | currentType = 0 170 | return 171 | } 172 | val messagekey = ByteArray(16) 173 | for (j in 0..15) { 174 | messagekey[j] = (authKey!![j].toInt() xor handle.toInt()).toByte() 175 | } 176 | try { 177 | buf = CryptoUtils.decryptAES(buf, messagekey) 178 | buf = ArrayUtils.subarray(buf, 0, currentLength) 179 | } catch (e: Exception) { 180 | e.printStackTrace() 181 | currentHandle = null 182 | currentType = 0 183 | return 184 | } 185 | } 186 | try { 187 | val finalBuf = buf 188 | handle2021Payload(currentType.toShort(), finalBuf) 189 | } catch (e: Exception) { 190 | e.printStackTrace() 191 | } 192 | currentHandle = null 193 | currentType = 0 194 | } 195 | } 196 | 197 | private fun handle2021Payload(type: Short, payload: ByteArray) { 198 | device.handlePayload(payload) 199 | if (payload[0].toInt() == 0x10 && payload[1].toInt() == 0x04 && payload[2].toInt() == 0x01) { 200 | System.arraycopy(payload, 3, remoteRandom, 0, 16) 201 | System.arraycopy(payload, 19, remotePublicEC, 0, 48) 202 | sharedEC = ECDH_B163.ecdh_generate_shared(privateEC, remotePublicEC) 203 | val encryptedSequenceNumber = 204 | sharedEC[0].toInt() and 0xff or (sharedEC[1].toInt() and 0xff shl 8) or (sharedEC[2].toInt() and 0xff shl 16) or (sharedEC[3].toInt() and 0xff shl 24) 205 | val secretKey = authKey 206 | for (i in 0..15) { 207 | finalSharedSessionAES[i] = 208 | (sharedEC[i + 8].toInt() xor secretKey!![i].toInt()).toByte() 209 | } 210 | encryptedSequenceNr = encryptedSequenceNumber 211 | authKey = finalSharedSessionAES 212 | try { 213 | val encryptedRandom1: ByteArray = CryptoUtils.encryptAES(remoteRandom, secretKey) 214 | val encryptedRandom2: ByteArray = 215 | CryptoUtils.encryptAES(remoteRandom, finalSharedSessionAES) 216 | if (encryptedRandom1.size == 16 && encryptedRandom2.size == 16) { 217 | val command = ByteArray(33) 218 | command[0] = 0x05 219 | System.arraycopy(encryptedRandom1, 0, command, 1, 16) 220 | System.arraycopy(encryptedRandom2, 0, command, 17, 16) 221 | huamiWrite(0x0082.toShort(), command, true, false) 222 | } 223 | } catch (e: Exception) { 224 | e.printStackTrace() 225 | } 226 | } else if (payload[0].toInt() == 0x10 && payload[1].toInt() == 0x05 && payload[2].toInt() == 0x01) { 227 | device.status.value = DeviceStatus.Connected 228 | device.onAuth() 229 | return 230 | } else { 231 | if (device.status.value == DeviceStatus.Authing) device.status.value = 232 | DeviceStatus.AuthFailure 233 | } 234 | } 235 | 236 | fun write(bytes: ByteArray, service: String, characteristics: String) { 237 | SuitekiManager.log("write", service, characteristics, bytes) 238 | device.write(bytes, service, characteristics) 239 | } 240 | 241 | fun handleData(bytes: ByteArray, service: String, characteristics: String) { 242 | if (service != HuamiService.UUID_SERVICE_MIBAND_SERVICE && 243 | characteristics != HuamiService.UUID_CHARACTERISTIC_AUTH_NOTIFY 244 | ) return 245 | SuitekiManager.log("handleData", service, characteristics, bytes) 246 | decode(bytes) 247 | } 248 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiBleSupport.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import com.clj.fastble.BleManager 5 | import com.clj.fastble.callback.BleGattCallback 6 | import com.clj.fastble.callback.BleNotifyCallback 7 | import com.clj.fastble.callback.BleWriteCallback 8 | import com.clj.fastble.data.BleDevice 9 | import com.clj.fastble.exception.BleException 10 | import kotlinx.coroutines.CoroutineScope 11 | import kotlinx.coroutines.Job 12 | import kotlinx.coroutines.delay 13 | import kotlinx.coroutines.launch 14 | 15 | class HuamiBleSupport(private val device: HuamiDevice) : BleGattCallback() { 16 | private lateinit var bleDevice: BleDevice 17 | private val manager get() = BleManager.getInstance() 18 | private val job = Job() 19 | private val scope = CoroutineScope(job) 20 | 21 | fun start(){ 22 | manager.connect(device.mac, this) 23 | } 24 | 25 | override fun onStartConnect() {} 26 | 27 | override fun onConnectFail(bleDevice: BleDevice, exception: BleException) {} 28 | 29 | override fun onConnectSuccess(bleDevice: BleDevice, gatt: BluetoothGatt, status: Int) { 30 | scope.launch { 31 | this@HuamiBleSupport.bleDevice = bleDevice 32 | for ((service, characteristics) in device.uuids) { 33 | for (i in characteristics) { 34 | manager.notify(bleDevice, service, i, object : BleNotifyCallback() { 35 | override fun onNotifySuccess() { 36 | } 37 | 38 | override fun onNotifyFailure(exception: BleException) { 39 | } 40 | 41 | override fun onCharacteristicChanged(data: ByteArray) { 42 | device.handleChannel(data, service, i) 43 | } 44 | 45 | }) 46 | delay(250) 47 | } 48 | } 49 | device.auth() 50 | } 51 | } 52 | 53 | override fun onDisConnected( 54 | isActiveDisConnected: Boolean, device: BleDevice, gatt: BluetoothGatt, status: Int 55 | ) { 56 | 57 | } 58 | 59 | 60 | fun write(data: ByteArray, service: String, characteristics: String) { 61 | manager.write(bleDevice, service, characteristics, data, object : BleWriteCallback() { 62 | override fun onWriteSuccess(current: Int, total: Int, justWrite: ByteArray?) { 63 | 64 | } 65 | 66 | override fun onWriteFailure(exception: BleException?) { 67 | 68 | } 69 | }) 70 | } 71 | 72 | fun disconnect() { 73 | manager.disconnect(bleDevice) 74 | } 75 | 76 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiDevice.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("LeakingThis") 2 | 3 | package com.github.sky130.suiteki.pro.device.huami 4 | 5 | import com.github.sky130.suiteki.pro.logic.ble.AbstractSuitekiDevice 6 | import com.github.sky130.suiteki.pro.logic.ble.DeviceStatus 7 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 8 | import com.github.sky130.suiteki.pro.util.BytesUtils 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | 11 | open class HuamiDevice(name: String, mac: String, key: String) : AbstractSuitekiDevice( 12 | name, mac, key 13 | ) { 14 | override val version = MutableStateFlow("unknown") 15 | override val battery = MutableStateFlow("unknown") 16 | override val status = MutableStateFlow(DeviceStatus.Waiting) 17 | private val authService = HuamiAuthService(this) 18 | private val appListHelper = HuamiAppListHelper(this) 19 | private val bleSupport = HuamiBleSupport(this) 20 | private var installHelper: HuamiInstallHelper? = null 21 | override val appList get() = appListHelper.list 22 | val uuids = mapOf( 23 | HuamiService.UUID_SERVICE_MIBAND_SERVICE to listOf( 24 | HuamiService.UUID_CHARACTERISTIC_AUTH_NOTIFY, 25 | HuamiService.UUID_CHARACTERISTIC_APP 26 | ), 27 | HuamiService.UUID_SERVICE_FIRMWARE to listOf(HuamiService.UUID_CHARACTERISTIC_FIRMWARE_NOTIFY) 28 | ) 29 | 30 | override fun onStart() { 31 | bleSupport.start() 32 | } 33 | 34 | fun auth() { 35 | authService.startAuth() 36 | } 37 | 38 | fun onAuth() { 39 | appListHelper.requestAppItems() 40 | } 41 | 42 | fun handleChannel(data: ByteArray, service: String, characteristics: String) { 43 | authService.handleData(data, service, characteristics) 44 | if (service == HuamiService.UUID_SERVICE_FIRMWARE && characteristics == HuamiService.UUID_CHARACTERISTIC_FIRMWARE_NOTIFY) { 45 | installHelper?.handleBytes(data) 46 | } 47 | } 48 | 49 | fun handlePayload(data: ByteArray) { 50 | appListHelper.handlePayload(data) 51 | } 52 | 53 | open fun onDisconnect() { 54 | 55 | } 56 | 57 | override fun install(bytes: ByteArray) { 58 | installHelper = HuamiInstallHelper(this, bytes) 59 | installHelper?.start() 60 | } 61 | 62 | override fun deleteApp(id: String) { 63 | appListHelper.uninstall(id) 64 | } 65 | 66 | override fun requestAppList() { 67 | appListHelper.requestAppItems() 68 | } 69 | 70 | override fun launchApp(id: String) { 71 | appListHelper.launch(id) 72 | } 73 | 74 | fun installFinish() { 75 | installHelper = null 76 | } 77 | 78 | fun write(data: ByteArray, service: String, characteristics: String) { 79 | SuitekiManager.log("onWrite",service,characteristics,BytesUtils.bytesToHexStr(data).toString()) 80 | bleSupport.write(data, service, characteristics) 81 | } 82 | 83 | fun write( 84 | type: Short, data: ByteArray, extendedFlags: Boolean, encrypt: Boolean 85 | ) = authService.huamiWrite(type, data, extendedFlags, encrypt) 86 | 87 | 88 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiInstallHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami 2 | 3 | import android.util.Log 4 | import com.clj.fastble.BleManager 5 | import com.github.sky130.suiteki.pro.logic.ble.InstallStatus 6 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 7 | import com.github.sky130.suiteki.pro.util.BytesUtils 8 | 9 | class HuamiInstallHelper(val device: HuamiDevice, val bytes: ByteArray) { 10 | companion object { 11 | val d0 = byteArrayOf(-48) 12 | val d1 = byteArrayOf(-47) 13 | val d3 = byteArrayOf(-45, 1) 14 | val d5 = byteArrayOf(-43) 15 | val d6 = byteArrayOf(-42) 16 | } 17 | 18 | lateinit var d2: ByteArray 19 | private lateinit var splitBytes: ArrayList 20 | private val performList by lazy { listOf(d0, d1, d2, d3, d5, d6) } 21 | 22 | 23 | private var performCount = 0 24 | private var installCount = 0 25 | private val progress get() = ((installCount.toFloat() / splitBytes.size.toFloat()) * 100).toInt() 26 | 27 | fun start() { 28 | SuitekiManager.installStatus.value = InstallStatus.Nope 29 | d2 = BytesUtils.getD2(bytes, HuamiService.TYPE_D2_WATCHFACE) 30 | splitBytes = BytesUtils.splitBytes(bytes) 31 | doPerform() 32 | } 33 | 34 | private fun doPerform() { 35 | write(performList[performCount]) 36 | performCount++ 37 | } 38 | 39 | private fun install() { 40 | BleManager.getInstance().splitWriteNum = 244 41 | write(splitBytes[installCount], notify = false) 42 | installCount++ 43 | updateProgress() 44 | } 45 | 46 | private fun write(data: ByteArray, notify: Boolean = true) { 47 | device.write( 48 | data, 49 | HuamiService.UUID_SERVICE_FIRMWARE, 50 | if (notify) HuamiService.UUID_CHARACTERISTIC_FIRMWARE_NOTIFY 51 | else HuamiService.UUID_CHARACTERISTIC_FIRMWARE_WRITE 52 | ) 53 | Log.d("TAG", "< ${BytesUtils.bytesToHexStr(data).toString()}") 54 | } 55 | 56 | private fun updateProgress() { 57 | SuitekiManager.installStatus.value = InstallStatus.Installing(progress) 58 | } 59 | 60 | private fun installFailure(messages: String) { 61 | SuitekiManager.installStatus.value = InstallStatus.InstallFailure(progress, messages) 62 | } 63 | 64 | private fun installSuccess() { 65 | SuitekiManager.installStatus.value = InstallStatus.InstallSuccess(progress) 66 | } 67 | 68 | fun handleBytes(bytes: ByteArray) { 69 | Log.d("TAG", "> ${BytesUtils.bytesToHexStr(bytes).toString()}") 70 | when (BytesUtils.bytesToHexStr(bytes)) { 71 | "10D001050020", "10D00105002001", "10D10100", "10D2010000000000000000" -> doPerform() 72 | "10D203" -> { 73 | installFailure("验证失败") 74 | return 75 | } 76 | 77 | "10D347" -> { 78 | installFailure("空间不足") 79 | //空间不足 80 | return 81 | } 82 | 83 | "10D656" -> { 84 | installFailure("不支持的文件") 85 | //空间不足 86 | return 87 | } 88 | 89 | "10D301" -> { 90 | install() 91 | return 92 | } 93 | 94 | "10D501" -> { 95 | doPerform() 96 | return 97 | } 98 | 99 | "10D601" -> { 100 | installSuccess() 101 | device.installFinish() 102 | //安装完毕 103 | return 104 | } 105 | 106 | else -> { 107 | bytes.let { 108 | if (it[0].toInt() == 16 && it[1].toInt() == -44) { //10D4 109 | return if (installCount >= splitBytes.size) doPerform() else install() 110 | 111 | } 112 | if (it[0].toInt() == 16 && it[1].toInt() == -46 && it[2].toInt() == 1) { 113 | return doPerform() 114 | } 115 | } 116 | installFailure("未知原因\n${BytesUtils.bytesToHexStr(bytes)}") 117 | // 安装失败 118 | return 119 | 120 | } 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/HuamiService.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami 2 | 3 | object HuamiService { 4 | const val BASE_UUID = "0000%s-0000-1000-8000-00805f9b34fb" // 基础UUID 5 | const val UUID_SERVICE_FIRMWARE = "00001530-0000-3512-2118-0009af100700" // 固件服务 6 | const val UUID_CHARACTERISTIC_FIRMWARE_NOTIFY = 7 | "00001531-0000-3512-2118-0009af100700" // 发送指令以及订阅通知特征 8 | const val UUID_CHARACTERISTIC_FIRMWARE_WRITE = "00001532-0000-3512-2118-0009af100700" // 发送固件特征 9 | const val UUID_SERVICE_MIBAND_SERVICE = "0000fee0-0000-1000-8000-00805f9b34fb" // 米环服务 10 | const val UUID_CHARACTERISTIC_APP = "00000016-0000-3512-2118-0009af100700" // 小程序特征 11 | const val UUID_CHARACTERISTIC_AUTH_WRITE = "00000016-0000-3512-2118-0009af100700" // 验证发送特征 12 | const val UUID_CHARACTERISTIC_AUTH_NOTIFY = "00000017-0000-3512-2118-0009af100700" // 验证订阅通知特征 13 | const val UUID_SERVICE_GENERIC_ACCESS = 14 | "00001800-0000-1000-8000-00805f9b34fb" // 通用访问配置文件,用于获取基础设备信息 15 | const val UUID_CHARACTERISTIC_DEVICE_NAME = "00002a00-0000-1000-8000-00805f9b34fb" // 获取设备名称特征 16 | const val UUID_SERVICE_DEVICE_INFORMATION = 17 | "0000180a-0000-1000-8000-00805f9b34fb" // 设备信息服务,用于获取设备信息 18 | const val UUID_CHARACTERISTIC_SERIAL_NUMBER = "00002a25-0000-1000-8000-00805f9b34fb" // 设备SN码查询 19 | const val UUID_CHARACTERISTIC_ZEPP_OS_VERSION = 20 | "00002a28-0000-1000-8000-00805f9b34fb" // 设备系统版本查询 21 | const val UUID_SERVICE_DEVICE_BATTERY = 22 | "0000180f-0000-1000-8000-00805f9b34fb" // 设备电量服务,用于获取设备信息 23 | const val UUID_CHARACTERISTIC_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb" // 设备电量查询 24 | const val UUID_CLIENT_CHARACTERISTIC_CONFIG = "00002902-0000-1000-8000-00805f9b34fb" // 25 | const val STATUS_BLE_NOPE = 0x00 // 未连接 26 | const val STATUS_BLE_CONNECTING = 0x01 // 正在连接 27 | const val STATUS_BLE_CONNECTED = 0x02 // 连接完毕 28 | const val STATUS_BLE_AUTHING = 0x03 // 正在验证 29 | const val STATUS_BLE_AUTHED = 0x04 // 验证完毕 30 | const val STATUS_BLE_NORMAL = 0x05 // 准备工作 31 | const val STATUS_BLE_INSTALLING = 0x06 // 正在安装表盘 32 | const val STATUS_BLE_CONNECT_FAILURE = 0x07 // 连接失败 33 | const val STATUS_BLE_DISCONNECT = 0x08 // 断开连接 34 | const val TYPE_D2_WATCHFACE: Byte = 8 // 08 表盘 35 | const val TYPE_D2_FIRMWARE: Byte = -3 // FD 固件 36 | const val TYPE_D2_APP: Byte = -96 // A0 小程序 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/huami/miband7/Miband7.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.huami.miband7 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.Suiteki 4 | import com.github.sky130.suiteki.pro.device.huami.HuamiDevice 5 | 6 | @Suiteki(pattern = "^Xiaomi Smart Band 7 [A-Z0-9]{4}$") 7 | class Miband7(name: String, mac: String, key: String) : HuamiDevice(name, mac, key) -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiAbstractSupport.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import com.clj.fastble.callback.BleGattCallback 5 | import com.clj.fastble.data.BleDevice 6 | import com.clj.fastble.exception.BleException 7 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 8 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.Command 9 | 10 | abstract class XiaomiAbstractSupport(val device: XiaomiDevice) : BleGattCallback() { 11 | abstract fun start() 12 | 13 | abstract fun sendCommand(command: Command) 14 | 15 | fun sendCommand(type: Int, subtype: Int) { 16 | sendCommand( 17 | XiaomiProto.Command.newBuilder() 18 | .setType(type) 19 | .setSubtype(subtype) 20 | .build() 21 | ) 22 | } 23 | 24 | abstract fun sendDataChunk(data: ByteArray, onSend: () -> Unit = {}) 25 | 26 | override fun onStartConnect() {} 27 | 28 | override fun onConnectFail(bleDevice: BleDevice, exception: BleException) {} 29 | 30 | override fun onConnectSuccess(bleDevice: BleDevice, gatt: BluetoothGatt, status: Int) {} 31 | 32 | override fun onDisConnected( 33 | isActiveDisConnected: Boolean, device: BleDevice, gatt: BluetoothGatt, status: Int 34 | ) { 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiAppListHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import androidx.compose.runtime.mutableStateListOf 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_RPK_DELETE 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_RPK_LIST 6 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.RPK_COMMAND_TYPE 7 | import com.github.sky130.suiteki.pro.logic.ble.AppInfo 8 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.Command 9 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.RpkInfoList 10 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.RpkList 11 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.RpkMessage 12 | import com.google.protobuf.ByteString 13 | 14 | class XiaomiAppListHelper(val device: XiaomiDevice) { 15 | 16 | val list = mutableStateListOf() 17 | private val shaMap = hashMapOf() 18 | 19 | fun handleCommand(command: Command) { 20 | if (command.type != RPK_COMMAND_TYPE) return 21 | when (command.subtype) { 22 | CMD_RPK_LIST -> { 23 | handleRpkList(command.rpkMessage.rpkList) 24 | } 25 | } 26 | } 27 | 28 | fun delete(id: String) { 29 | device.support.sendCommand( 30 | Command.newBuilder().setType(RPK_COMMAND_TYPE).setSubtype( 31 | CMD_RPK_DELETE 32 | ).setRpkMessage( 33 | RpkMessage.newBuilder().setRpkDel( 34 | RpkInfoList.newBuilder().setId(id) 35 | .setSha(ByteString.copyFrom(shaMap[id]!!.toByteArray())).build() 36 | ).build() 37 | ).build() 38 | ) 39 | list.removeAt(list.indexOfFirst { it.id == id }) 40 | shaMap.remove(id) 41 | } 42 | 43 | fun requestRpkList() { 44 | device.support.sendCommand(RPK_COMMAND_TYPE, CMD_RPK_LIST) 45 | } 46 | 47 | private fun handleRpkList(rpkList: RpkList) { 48 | list.clear() 49 | for (i in rpkList.rpkInfoList) { 50 | list.add(AppInfo(i.id, i.name)) 51 | shaMap[i.id] = i.sha 52 | } 53 | } 54 | 55 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiAuthService.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import android.os.Build 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_BATTERY 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_DEVICE_INFO 6 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_DEVICE_STATE_GET 7 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.SYSTEM_COMMAND_TYPE 8 | import com.github.sky130.suiteki.pro.logic.ble.DeviceStatus 9 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 10 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 11 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.Command 12 | import com.github.sky130.suiteki.pro.util.BytesUtils 13 | import com.google.protobuf.ByteString 14 | import org.apache.commons.lang3.ArrayUtils 15 | import org.bouncycastle.shaded.crypto.CryptoException 16 | import org.bouncycastle.shaded.crypto.engines.AESEngine 17 | import org.bouncycastle.shaded.crypto.modes.CCMBlockCipher 18 | import org.bouncycastle.shaded.crypto.params.AEADParameters 19 | import org.bouncycastle.shaded.crypto.params.KeyParameter 20 | import java.nio.ByteBuffer 21 | import java.nio.ByteOrder 22 | import java.security.InvalidKeyException 23 | import java.security.NoSuchAlgorithmException 24 | import java.security.SecureRandom 25 | import java.util.Arrays 26 | import java.util.Locale 27 | import javax.crypto.Mac 28 | import javax.crypto.SecretKey 29 | import javax.crypto.spec.SecretKeySpec 30 | 31 | class XiaomiAuthService(val device: XiaomiDevice) { 32 | 33 | companion object { 34 | const val COMMAND_TYPE: Int = 1 35 | const val CMD_SEND_USERID: Int = 5 36 | const val CMD_NONCE: Int = 26 37 | const val CMD_AUTH: Int = 27 38 | } 39 | 40 | val status get() = device.status 41 | private val secretKey = ByteArray(16) 42 | private val nonce = ByteArray(16) 43 | private val encryptionKey = ByteArray(16) 44 | private val decryptionKey = ByteArray(16) 45 | private val encryptionNonce = ByteArray(4) 46 | private val decryptionNonce = ByteArray(4) 47 | var encryptionInitialized = false 48 | var encryptedIndex = 1 49 | 50 | 51 | fun startEncryptedHandshake() { 52 | System.arraycopy( 53 | BytesUtils.getSecretKey(device.key), 0, secretKey, 0, 16 54 | ) 55 | SecureRandom().nextBytes(nonce) 56 | sendCommand(buildNonceCommand(nonce)) 57 | } 58 | 59 | fun startClearTextHandshake() { 60 | val auth = XiaomiProto.Auth.newBuilder().setUserId(device.key).build() 61 | 62 | val command = 63 | Command.newBuilder().setType(COMMAND_TYPE).setSubtype(CMD_SEND_USERID).setAuth(auth) 64 | .build() 65 | 66 | sendCommand(command) 67 | } 68 | 69 | private fun handleWatchNonce(watchNonce: XiaomiProto.WatchNonce): Command? { 70 | val step2hmac: ByteArray = computeAuthStep3Hmac( 71 | secretKey, nonce, watchNonce.nonce.toByteArray() 72 | ) 73 | 74 | System.arraycopy(step2hmac, 0, decryptionKey, 0, 16) 75 | System.arraycopy(step2hmac, 16, encryptionKey, 0, 16) 76 | System.arraycopy(step2hmac, 32, decryptionNonce, 0, 4) 77 | System.arraycopy(step2hmac, 36, encryptionNonce, 0, 4) 78 | 79 | 80 | val decryptionConfirmation: ByteArray = hmacSHA256( 81 | decryptionKey, ArrayUtils.addAll(watchNonce.nonce.toByteArray(), *nonce) 82 | ) 83 | if (!decryptionConfirmation.contentEquals(watchNonce.hmac.toByteArray())) { 84 | return null 85 | } 86 | 87 | val authDeviceInfo: XiaomiProto.AuthDeviceInfo = 88 | XiaomiProto.AuthDeviceInfo.newBuilder().setUnknown1(0) // TODO ? 89 | .setPhoneApiLevel(Build.VERSION.SDK_INT.toFloat()).setPhoneName(Build.MODEL) 90 | .setUnknown3(224) // TODO ? 91 | // TODO region should be actual device region? 92 | .setRegion(Locale.getDefault().language.substring(0, 2).uppercase()).build() 93 | 94 | val encryptedNonces: ByteArray = hmacSHA256( 95 | encryptionKey, ArrayUtils.addAll(nonce, *watchNonce.nonce.toByteArray()) 96 | ) 97 | val encryptedDeviceInfo = encrypt(authDeviceInfo.toByteArray(), 0) 98 | val authStep3: XiaomiProto.AuthStep3 = XiaomiProto.AuthStep3.newBuilder() 99 | .setEncryptedNonces(ByteString.copyFrom(encryptedNonces)) 100 | .setEncryptedDeviceInfo(ByteString.copyFrom(encryptedDeviceInfo)).build() 101 | 102 | val cmd = Command.newBuilder() 103 | cmd.setType(COMMAND_TYPE) 104 | cmd.setSubtype(CMD_AUTH) 105 | 106 | val auth = XiaomiProto.Auth.newBuilder() 107 | auth.setAuthStep3(authStep3) 108 | 109 | return cmd.setAuth(auth.build()).build() 110 | } 111 | 112 | fun buildNonceCommand(nonce: ByteArray?): Command { 113 | val phoneNonce = XiaomiProto.PhoneNonce.newBuilder() 114 | phoneNonce.setNonce(ByteString.copyFrom(nonce)) 115 | 116 | val auth = XiaomiProto.Auth.newBuilder() 117 | auth.setPhoneNonce(phoneNonce.build()) 118 | 119 | val command = Command.newBuilder() 120 | command.setType(COMMAND_TYPE) 121 | command.setSubtype(CMD_NONCE) 122 | command.setAuth(auth.build()) 123 | return command.build() 124 | } 125 | 126 | 127 | fun computeAuthStep3Hmac( 128 | secretKey: ByteArray?, phoneNonce: ByteArray?, watchNonce: ByteArray 129 | ): ByteArray { 130 | val miwearAuthBytes = "miwear-auth".toByteArray() 131 | 132 | val mac: Mac 133 | try { 134 | mac = Mac.getInstance("HmacSHA256") 135 | // Compute the actual key and re-initialize the mac 136 | mac.init(SecretKeySpec(ArrayUtils.addAll(phoneNonce, *watchNonce), "HmacSHA256")) 137 | val hmacKeyBytes = mac.doFinal(secretKey) 138 | val key = SecretKeySpec(hmacKeyBytes, "HmacSHA256") 139 | mac.init(key) 140 | } catch (e: NoSuchAlgorithmException) { 141 | throw IllegalStateException("Failed to initialize hmac for auth step 2", e) 142 | } catch (e: InvalidKeyException) { 143 | throw IllegalStateException("Failed to initialize hmac for auth step 2", e) 144 | } 145 | 146 | val output = ByteArray(64) 147 | var tmp = ByteArray(0) 148 | var b: Byte = 1 149 | var i = 0 150 | while (i < output.size) { 151 | mac.update(tmp) 152 | mac.update(miwearAuthBytes) 153 | mac.update(b) 154 | tmp = mac.doFinal() 155 | var j = 0 156 | while (j < tmp.size && i < output.size) { 157 | output[i] = tmp[j] 158 | j++ 159 | i++ 160 | } 161 | b++ 162 | } 163 | return output 164 | } 165 | 166 | fun hmacSHA256(key: ByteArray?, input: ByteArray): ByteArray { 167 | try { 168 | val mac = Mac.getInstance("HmacSHA256") 169 | mac.init(SecretKeySpec(key, "HmacSHA256")) 170 | return mac.doFinal(input) 171 | } catch (e: Exception) { 172 | throw java.lang.RuntimeException("Failed to hmac", e) 173 | } 174 | } 175 | 176 | fun encrypt(arr: ByteArray, i: Int): ByteArray { 177 | val packetNonce: ByteBuffer = 178 | ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN).put(encryptionNonce).putInt(0) 179 | .putInt(i) 180 | 181 | try { 182 | return encrypt(encryptionKey, packetNonce.array(), arr) 183 | } catch (e: CryptoException) { 184 | throw RuntimeException("failed to encrypt", e) 185 | } 186 | } 187 | 188 | fun decrypt(arr: ByteArray): ByteArray { 189 | val packetNonce = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN) 190 | packetNonce.put(decryptionNonce) 191 | packetNonce.putInt(0) 192 | packetNonce.putInt(0) 193 | 194 | try { 195 | return decrypt(decryptionKey, packetNonce.array(), arr) 196 | } catch (e: CryptoException) { 197 | throw java.lang.RuntimeException("failed to decrypt", e) 198 | } 199 | } 200 | 201 | fun encrypt(key: ByteArray?, nonce: ByteArray?, payload: ByteArray): ByteArray { 202 | val cipher: CCMBlockCipher = createBlockCipher(true, SecretKeySpec(key, "AES"), nonce) 203 | val out = ByteArray(cipher.getOutputSize(payload.size)) 204 | val outBytes: Int = cipher.processBytes(payload, 0, payload.size, out, 0) 205 | cipher.doFinal(out, outBytes) 206 | return out 207 | } 208 | 209 | fun decrypt( 210 | key: ByteArray?, nonce: ByteArray?, encryptedPayload: ByteArray 211 | ): ByteArray { 212 | val cipher: CCMBlockCipher = createBlockCipher(false, SecretKeySpec(key, "AES"), nonce) 213 | val decrypted = ByteArray(cipher.getOutputSize(encryptedPayload.size)) 214 | cipher.doFinal( 215 | decrypted, cipher.processBytes(encryptedPayload, 0, encryptedPayload.size, decrypted, 0) 216 | ) 217 | return decrypted 218 | } 219 | 220 | fun createBlockCipher( 221 | forEncrypt: Boolean, secretKey: SecretKey, nonce: ByteArray? 222 | ): CCMBlockCipher { 223 | val aesFastEngine = AESEngine() 224 | aesFastEngine.init(forEncrypt, KeyParameter(secretKey.encoded)) 225 | val blockCipher = CCMBlockCipher(aesFastEngine) 226 | blockCipher.init( 227 | forEncrypt, AEADParameters(KeyParameter(secretKey.encoded), 32, nonce, null) 228 | ) 229 | return blockCipher 230 | } 231 | 232 | fun handleCommand(cmd: Command) { 233 | if (cmd.type != COMMAND_TYPE) return 234 | SuitekiManager.log("cmd.type != COMMAND_TYPE", cmd.type != COMMAND_TYPE) 235 | when (cmd.subtype) { 236 | CMD_NONCE -> { 237 | val command = handleWatchNonce(cmd.auth.watchNonce) ?: return SuitekiManager.log("handleWatchNonce is null") 238 | sendCommand(command) 239 | } 240 | 241 | CMD_AUTH, CMD_SEND_USERID -> { 242 | if (cmd.subtype == CMD_AUTH || cmd.auth.status == 1) { 243 | encryptionInitialized = (cmd.subtype == CMD_AUTH) 244 | initialize() 245 | status.value = DeviceStatus.Connected 246 | } else { 247 | status.value = DeviceStatus.Disconnect 248 | } 249 | } 250 | 251 | else -> Unit 252 | } 253 | } 254 | 255 | private fun initialize() { 256 | device.support.apply { 257 | sendCommand(SYSTEM_COMMAND_TYPE, CMD_DEVICE_INFO) 258 | } 259 | device.onAuth() 260 | } 261 | 262 | 263 | fun handleData(bytes: ByteArray) { 264 | try { 265 | handleCommand(Command.parseFrom(decrypt(bytes))) 266 | } catch (_: Exception) { 267 | 268 | } 269 | } 270 | 271 | private fun sendCommand(command: Command) { 272 | device.support.sendCommand( 273 | command 274 | ) 275 | } 276 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiBleSupport.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import com.clj.fastble.BleManager 5 | import com.clj.fastble.callback.BleNotifyCallback 6 | import com.clj.fastble.callback.BleWriteCallback 7 | import com.clj.fastble.data.BleDevice 8 | import com.clj.fastble.exception.BleException 9 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 10 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.Command 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Job 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.launch 15 | import java.nio.ByteBuffer 16 | import java.nio.ByteOrder 17 | import kotlin.math.ceil 18 | 19 | class XiaomiBleSupport(device: XiaomiDevice) : XiaomiAbstractSupport(device) { 20 | 21 | override fun sendCommand(command: Command) { 22 | device.authService.apply { 23 | val currentData = encrypt(command.toByteArray(), encryptedIndex).let { 24 | ByteBuffer.allocate(2 + it.size).order(ByteOrder.LITTLE_ENDIAN) 25 | .putShort(encryptedIndex++.toShort()) 26 | .put(it) 27 | .array() 28 | }.let { 29 | ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN).putShort(0.toShort()) 30 | .put(0.toByte()).put(1.toByte()) 31 | .putShort( 32 | ceil((it.size / (244 - 2).toFloat()).toDouble()).toInt().toShort() 33 | ).array() 34 | } 35 | manager.write( 36 | bleDevice, 37 | XiaomiService.UUID_SERVICE, 38 | XiaomiService.UUID_CHARACTERISTIC_COMMAND_WRITE, 39 | currentData, 40 | object : BleWriteCallback() { 41 | override fun onWriteSuccess(current: Int, total: Int, justWrite: ByteArray?) { 42 | 43 | } 44 | 45 | override fun onWriteFailure(exception: BleException?) { 46 | 47 | } 48 | } 49 | ) 50 | } 51 | } 52 | 53 | 54 | override fun sendDataChunk(data: ByteArray, onSend: () -> Unit) { 55 | manager.write( 56 | bleDevice, 57 | XiaomiService.UUID_SERVICE, 58 | XiaomiService.UUID_CHARACTERISTIC_DATA_UPLOAD, 59 | data, 60 | object : BleWriteCallback() { 61 | override fun onWriteSuccess(current: Int, total: Int, justWrite: ByteArray?) { 62 | onSend() 63 | } 64 | 65 | override fun onWriteFailure(exception: BleException?) { 66 | 67 | } 68 | } 69 | ) 70 | } 71 | 72 | private lateinit var bleDevice: BleDevice 73 | private val manager get() = BleManager.getInstance() 74 | private val job = Job() 75 | private val scope = CoroutineScope(job) 76 | private val uuids = mapOf( 77 | XiaomiService.UUID_SERVICE to listOf(XiaomiService.UUID_CHARACTERISTIC_COMMAND_READ), 78 | ) 79 | 80 | override fun start() { 81 | manager.connect(device.mac, this) 82 | } 83 | 84 | override fun onStartConnect() {} 85 | 86 | override fun onConnectFail(bleDevice: BleDevice, exception: BleException) {} 87 | 88 | override fun onConnectSuccess(bleDevice: BleDevice, gatt: BluetoothGatt, status: Int) { 89 | scope.launch { 90 | this@XiaomiBleSupport.bleDevice = bleDevice 91 | for ((service, characteristics) in uuids) { 92 | for (i in characteristics) { 93 | manager.notify(bleDevice, service, i, object : BleNotifyCallback() { 94 | override fun onNotifySuccess() { 95 | } 96 | 97 | override fun onNotifyFailure(exception: BleException) { 98 | } 99 | 100 | override fun onCharacteristicChanged(data: ByteArray) { 101 | device.apply { 102 | handleCommand( 103 | XiaomiProto.Command.parseFrom( 104 | if (isEncrypted) { 105 | authService.decrypt(data) 106 | } else { 107 | data 108 | } 109 | ) 110 | ) 111 | } 112 | } 113 | 114 | }) 115 | delay(250) 116 | } 117 | } 118 | device.auth() 119 | } 120 | } 121 | 122 | override fun onDisConnected( 123 | isActiveDisConnected: Boolean, bleDevice: BleDevice, gatt: BluetoothGatt, status: Int 124 | ) { 125 | device.onDisconnect() 126 | } 127 | 128 | 129 | fun write(data: ByteArray, service: String, characteristics: String) { 130 | manager.write(bleDevice, service, characteristics, data, object : BleWriteCallback() { 131 | override fun onWriteSuccess(current: Int, total: Int, justWrite: ByteArray?) { 132 | 133 | } 134 | 135 | override fun onWriteFailure(exception: BleException?) { 136 | 137 | } 138 | }) 139 | } 140 | 141 | fun disconnect() { 142 | manager.disconnect(bleDevice) 143 | } 144 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiChannelHandler.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | interface XiaomiChannelHandler { 4 | fun handle(payload: ByteArray?) 5 | } 6 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiDevice.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("LeakingThis") 2 | 3 | package com.github.sky130.suiteki.pro.device.xiaomi 4 | 5 | import com.github.sky130.suiteki.pro.logic.ble.AbstractSuitekiDevice 6 | import com.github.sky130.suiteki.pro.logic.ble.DeviceStatus 7 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 8 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.Job 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | 13 | abstract class XiaomiDevice(name: String, mac: String, key: String) : AbstractSuitekiDevice( 14 | name, mac, key 15 | ) { 16 | override val version = MutableStateFlow("unknown") 17 | override val battery = MutableStateFlow("unknown") 18 | override val status = MutableStateFlow(DeviceStatus.Waiting) 19 | abstract val isEncrypted: Boolean 20 | internal val authService = XiaomiAuthService(this) 21 | abstract val support: XiaomiAbstractSupport 22 | private val job = Job() 23 | private val appListHelper = XiaomiAppListHelper(this) 24 | private var installHelper: XiaomiInstallHelper? = null 25 | override val appList get() = appListHelper.list 26 | val scope = CoroutineScope(job) 27 | 28 | open fun handleCommand(command: XiaomiProto.Command) { 29 | SuitekiManager.log("handleCommand", command.toString()) 30 | authService.handleCommand(command) 31 | appListHelper.handleCommand(command) 32 | installHelper?.handleCommand(command) 33 | } 34 | 35 | fun auth() { 36 | status.value = DeviceStatus.Authing 37 | if (isEncrypted) { 38 | authService.startEncryptedHandshake() 39 | } else { 40 | authService.startClearTextHandshake() 41 | } 42 | } 43 | 44 | open fun onDisconnect() { 45 | status.value = DeviceStatus.Disconnect 46 | } 47 | 48 | open fun onAuth(){ 49 | appListHelper.requestRpkList() 50 | } 51 | 52 | open fun onInstallFinish() { 53 | installHelper = null 54 | } 55 | 56 | override fun deleteApp(id: String) { 57 | appListHelper.delete(id) 58 | } 59 | 60 | override fun onStart() { 61 | support.start() 62 | } 63 | 64 | override fun install(bytes: ByteArray) { 65 | installHelper = XiaomiInstallHelper(this, bytes).apply { 66 | install() 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiFWHelper.kt: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2023-2024 José Rebelo 2 | 3 | This file is part of Gadgetbridge. 4 | 5 | Gadgetbridge is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Gadgetbridge is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . */ 17 | package com.github.sky130.suiteki.pro.device.xiaomi 18 | 19 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_FIRMWARE_INSTALL 20 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_RPK_INSTALL 21 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_WATCHFACE_INSTALL 22 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.RPK_COMMAND_TYPE 23 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.SYSTEM_COMMAND_TYPE 24 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.TYPE_FIRMWARE 25 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.TYPE_RPK 26 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.TYPE_WATCHFACE 27 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.WATCHFACE_COMMAND_TYPE 28 | import com.github.sky130.suiteki.pro.util.BytesUtils 29 | import com.github.sky130.suiteki.pro.util.StringUtils.untilNullTerminator 30 | import com.github.sky130.suiteki.pro.util.ZipUtils 31 | import com.github.sky130.suiteki.pro.util.hashBytes 32 | import com.github.sky130.suiteki.pro.util.toHex 33 | import org.json.JSONObject 34 | import java.io.File 35 | 36 | 37 | class XiaomiFWHelper(var bytes: ByteArray?) { 38 | var subType = -1 39 | private set 40 | var fileType = -1 41 | private set 42 | var type = -1 43 | private set 44 | var id: String? = null 45 | private set 46 | var packageName: String? = null 47 | private set 48 | var name: String? = null 49 | private set 50 | var versionName: String? = null 51 | private set 52 | var versionCode: Int? = null 53 | private set 54 | var md5: String? = null 55 | private set 56 | 57 | 58 | fun init() { 59 | parseBytes() 60 | } 61 | 62 | val details: String 63 | get() = if (name != null) name!! else (versionName ?: "UNKNOWN") 64 | 65 | fun unsetFwBytes() { 66 | this.bytes = null 67 | } 68 | 69 | private fun parseBytes() { 70 | if (parseAsWatchface()) { 71 | checkNotNull(id) 72 | subType = CMD_WATCHFACE_INSTALL 73 | type = WATCHFACE_COMMAND_TYPE 74 | fileType = TYPE_WATCHFACE 75 | } else if (parseAsFirmware()) { 76 | checkNotNull(versionName) 77 | fileType = TYPE_FIRMWARE 78 | type = SYSTEM_COMMAND_TYPE 79 | subType = CMD_FIRMWARE_INSTALL 80 | } else if (parseAsRpk()) { 81 | subType = CMD_RPK_INSTALL 82 | type = RPK_COMMAND_TYPE 83 | fileType = TYPE_RPK 84 | } 85 | } 86 | 87 | private fun parseAsWatchface(): Boolean { 88 | val bytes = this.bytes!! 89 | if (bytes[0] == 90.toByte() && bytes[1] == (-91).toByte()) { 90 | id = untilNullTerminator(bytes, 40) 91 | val name = untilNullTerminator(bytes, 104) 92 | if (id != null && name != null) { 93 | return try { 94 | id!!.toInt() 95 | true 96 | } catch (e: NumberFormatException) { 97 | false 98 | } 99 | } 100 | } 101 | return false 102 | } 103 | 104 | private fun parseAsFirmware(): Boolean { 105 | val start = listOf(96, 90, 90, 126) 106 | for ((i, b) in start.withIndex()) { 107 | if (bytes!![i] != b) return false 108 | } 109 | versionName = bytes!!.decodeToString(4, 11) 110 | md5 = hashBytes(bytes!!, "MD5").toHex() 111 | return true 112 | } 113 | 114 | private fun parseAsRpk(): Boolean { 115 | val tempFile = File.createTempFile("cache", "rpk") 116 | try { 117 | tempFile.writeBytes(bytes!!) 118 | JSONObject(ZipUtils.extractFrom(tempFile, "manifest.json")!!).apply { 119 | packageName = getString("package").isEmptyThrow() 120 | name = getString("name").isEmptyThrow() 121 | versionName = getString("versionName").isEmptyThrow() 122 | versionCode = getInt("versionCode") 123 | id = packageName 124 | } 125 | } catch (_: Exception) { 126 | return false 127 | } finally { 128 | tempFile.delete() 129 | } 130 | return true 131 | } 132 | 133 | private fun String.isEmptyThrow(e: Exception = Exception()) = apply { 134 | ifEmpty { throw e } 135 | } 136 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiInstallHelper.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_FIRMWARE_INSTALL 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_RPK_INSTALL 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_UPLOAD_START 6 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.CMD_WATCHFACE_INSTALL 7 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.RPK_COMMAND_TYPE 8 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.SYSTEM_COMMAND_TYPE 9 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.TYPE_FIRMWARE 10 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.UPLOAD_COMMAND_TYPE 11 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiService.WATCHFACE_COMMAND_TYPE 12 | import com.github.sky130.suiteki.pro.logic.ble.InstallStatus 13 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 14 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 15 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto.Command 16 | import com.github.sky130.suiteki.pro.util.CheckSums 17 | import com.google.protobuf.ByteString 18 | import kotlinx.coroutines.Dispatchers 19 | import kotlinx.coroutines.launch 20 | import org.apache.commons.lang3.ArrayUtils 21 | import java.nio.ByteBuffer 22 | import java.nio.ByteOrder 23 | import kotlin.math.ceil 24 | import kotlin.math.min 25 | 26 | class XiaomiInstallHelper(val device: XiaomiDevice, private val fw: ByteArray) { 27 | private val helper by lazy { XiaomiFWHelper(fw) } 28 | private val support get() = device.support 29 | private var chunkSize = 2048 30 | 31 | 32 | fun install() { 33 | device.scope.launch(Dispatchers.IO) { 34 | helper.init() 35 | if (helper.fileType == TYPE_FIRMWARE) { 36 | support.sendCommand( 37 | Command.newBuilder() 38 | .setType(helper.type) 39 | .setSubtype(helper.subType) 40 | .setSystem( 41 | XiaomiProto.System.newBuilder() 42 | .setFirmwareInstallRequest( 43 | XiaomiProto.FirmwareInstallRequest.newBuilder() 44 | .setUnknown1(0) 45 | .setUnknown2(0) 46 | .setVersion(helper.versionName) 47 | .setMd5(helper.md5) 48 | .build() 49 | ).build() 50 | ).build() 51 | ) 52 | return@launch 53 | } 54 | support.sendCommand( 55 | Command.newBuilder().setType(helper.type).setSubtype(helper.subType) 56 | .apply { 57 | when (helper.type) { 58 | WATCHFACE_COMMAND_TYPE -> { 59 | setWatchface( 60 | XiaomiProto.Watchface.newBuilder().setWatchfaceInstallStart( 61 | XiaomiProto.WatchfaceInstallStart.newBuilder() 62 | .setId(helper.id).setSize(helper.bytes!!.size) 63 | ) 64 | ) 65 | } 66 | 67 | RPK_COMMAND_TYPE -> { 68 | setRpkMessage( 69 | XiaomiProto.RpkMessage.newBuilder().setRpkInfo( 70 | XiaomiProto.RpkInfo.newBuilder().setId(helper.id) 71 | .setSize(helper.bytes!!.size).setUnknown2(3) 72 | ) 73 | ) 74 | } 75 | } 76 | }.build() 77 | ) 78 | } 79 | } 80 | 81 | private fun requestUpload() { 82 | support.sendCommand( 83 | Command.newBuilder() 84 | .setType(UPLOAD_COMMAND_TYPE) 85 | .setSubtype(CMD_UPLOAD_START) 86 | .setDataUpload( 87 | XiaomiProto.DataUpload.newBuilder() 88 | .setDataUploadRequest( 89 | XiaomiProto.DataUploadRequest.newBuilder() 90 | .setType(helper.fileType) 91 | .setMd5Sum(ByteString.copyFrom(CheckSums.md5(fw))) 92 | .setSize(fw.size) 93 | ) 94 | ).build() 95 | ) 96 | } 97 | 98 | private fun handleWatchFaceCommand(cmd: Command) { 99 | when (cmd.subtype) { 100 | CMD_WATCHFACE_INSTALL -> { 101 | requestUpload() 102 | } 103 | } 104 | } 105 | 106 | private fun handleUploadCommand(cmd: Command) { 107 | when (cmd.subtype) { 108 | 109 | CMD_UPLOAD_START -> { 110 | val dataUploadAck = cmd.dataUpload.dataUploadAck 111 | // 112 | // if (dataUploadAck.unknown2 != 0 || dataUploadAck.resumePosition != 0) { 113 | // installFailure(0, "Unknown Error") 114 | // return 115 | // } 116 | 117 | 118 | if (dataUploadAck.unknown2 != 0) { 119 | installFailure(0, "Unknown Error") 120 | return 121 | } 122 | 123 | chunkSize = if (dataUploadAck.hasChunkSize()) { 124 | dataUploadAck.chunkSize 125 | } else { 126 | 2048 127 | } 128 | 129 | doUpload(dataUploadAck.resumePosition) 130 | } 131 | } 132 | } 133 | 134 | private fun handleRpkCommand(cmd: Command) { 135 | when (cmd.subtype) { 136 | CMD_RPK_INSTALL -> { 137 | requestUpload() 138 | } 139 | } 140 | } 141 | 142 | private fun handleSystemCommand(cmd: Command) { 143 | when (cmd.subtype) { 144 | CMD_FIRMWARE_INSTALL -> { 145 | requestUpload() 146 | } 147 | } 148 | } 149 | 150 | fun handleCommand(cmd: Command) { 151 | when (cmd.type) { 152 | WATCHFACE_COMMAND_TYPE -> handleWatchFaceCommand(cmd) 153 | RPK_COMMAND_TYPE -> handleRpkCommand(cmd) 154 | SYSTEM_COMMAND_TYPE -> handleSystemCommand(cmd) 155 | UPLOAD_COMMAND_TYPE -> handleUploadCommand(cmd) 156 | } 157 | } 158 | 159 | private fun doUpload(index: Int = 0) { 160 | SuitekiManager.log("doUpload") 161 | // type + md5 + size + bytes + crc32 162 | val buf1 = ByteBuffer.allocate(2 + 16 + 4 + fw.size - index).order(ByteOrder.LITTLE_ENDIAN) 163 | val md5 = CheckSums.md5(fw) 164 | if (md5 == null) { 165 | installFailure(0, "MD5 Missing") 166 | return 167 | } 168 | 169 | buf1.put(0.toByte()) 170 | buf1.put(helper.subType.toByte()) 171 | buf1.put(md5) 172 | buf1.putInt(fw.size) 173 | buf1.put(ArrayUtils.subarray(fw, index, fw.size)) 174 | 175 | val buf2 = ByteBuffer.allocate(buf1.capacity() + 4).order(ByteOrder.LITTLE_ENDIAN) 176 | buf2.put(buf1.array()) 177 | buf2.putInt(CheckSums.getCRC32(buf1.array())) 178 | 179 | val payload = buf2.array() 180 | val partSize = chunkSize - 4 // 2 + 2 at beginning of each for total and progress 181 | val totalParts = ceil((payload.size / partSize.toFloat()).toDouble()).toInt() 182 | 183 | var i = 0 184 | while (i * partSize < payload.size) { 185 | val currentPart = i + 1 186 | val startIndex = i * partSize 187 | val endIndex = min((currentPart * partSize).toDouble(), payload.size.toDouble()) 188 | val chunkToSend = ByteArray((4 + endIndex - startIndex).toInt()) 189 | writeUint16(chunkToSend, 0, totalParts) 190 | writeUint16(chunkToSend, 2, currentPart) 191 | System.arraycopy(payload, startIndex, chunkToSend, 4, (endIndex - startIndex).toInt()) 192 | 193 | support.sendDataChunk( 194 | chunkToSend 195 | ) { 196 | val progress = ((currentPart.toFloat() / totalParts.toFloat()) * 100).toInt() 197 | SuitekiManager.log("doUploadProgress", progress) 198 | 199 | if (currentPart >= totalParts) { 200 | installSuccess(100) 201 | } else { 202 | updateProgress(progress) 203 | } 204 | } 205 | i++ 206 | } 207 | 208 | } 209 | 210 | private fun updateProgress(progress: Int) { 211 | SuitekiManager.installStatus.value = InstallStatus.Installing(progress) 212 | } 213 | 214 | private fun installFailure(progress: Int, messages: String) { 215 | SuitekiManager.installStatus.value = InstallStatus.InstallFailure(progress, messages) 216 | device.onInstallFinish() 217 | } 218 | 219 | private fun installSuccess(progress: Int) { 220 | SuitekiManager.installStatus.value = InstallStatus.InstallSuccess(progress) 221 | device.onInstallFinish() 222 | } 223 | 224 | fun writeUint16(array: ByteArray, offset: Int, value: Int) { 225 | array[offset] = value.toByte() 226 | array[offset + 1] = (value shr 8).toByte() 227 | } 228 | 229 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiService.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | object XiaomiService { 4 | const val UUID_SERVICE = "0000fe95-0000-1000-8000-00805f9b34fb" 5 | const val UUID_CHARACTERISTIC_COMMAND_READ = "00000051-0000-1000-8000-00805f9b34fb" 6 | const val UUID_CHARACTERISTIC_COMMAND_WRITE = "00000052-0000-1000-8000-00805f9b34fb" 7 | const val UUID_CHARACTERISTIC_ACTIVITY_DATA = "00000053-0000-1000-8000-00805f9b34fb" 8 | const val UUID_CHARACTERISTIC_DATA_UPLOAD = "00000055-0000-1000-8000-00805f9b34fb" 9 | 10 | const val WATCHFACE_COMMAND_TYPE = 4 11 | const val UPLOAD_COMMAND_TYPE = 22 12 | const val RPK_COMMAND_TYPE = 20 13 | 14 | const val CMD_WATCHFACE_LIST = 0 15 | const val CMD_WATCHFACE_SET = 1 16 | const val CMD_WATCHFACE_DELETE = 2 17 | const val CMD_WATCHFACE_INSTALL = 4 18 | 19 | const val CMD_RPK_LIST = 0 20 | const val CMD_RPK_INSTALL = 1 21 | const val CMD_RPK_INSTALL_FINISH = 2 22 | const val CMD_RPK_DELETE = 3 23 | 24 | 25 | const val TYPE_WATCHFACE = 16 26 | const val TYPE_FIRMWARE = 32 27 | const val TYPE_RPK = 64 28 | 29 | const val CMD_UPLOAD_START = 0 30 | 31 | 32 | const val SYSTEM_COMMAND_TYPE: Int = 2 33 | const val CMD_BATTERY: Int = 1 34 | const val CMD_DEVICE_INFO: Int = 2 35 | const val CMD_CLOCK: Int = 3 36 | const val CMD_FIRMWARE_INSTALL: Int = 5 37 | const val CMD_LANGUAGE: Int = 6 38 | const val CMD_CAMERA_REMOTE_GET: Int = 7 39 | const val CMD_CAMERA_REMOTE_SET: Int = 8 40 | const val CMD_PASSWORD_GET: Int = 9 41 | const val CMD_MISC_SETTING_GET: Int = 14 42 | const val CMD_MISC_SETTING_SET: Int = 15 43 | const val CMD_FIND_PHONE: Int = 17 44 | const val CMD_FIND_WATCH: Int = 18 45 | const val CMD_PASSWORD_SET: Int = 21 46 | const val CMD_DISPLAY_ITEMS_GET: Int = 29 47 | const val CMD_DISPLAY_ITEMS_SET: Int = 30 48 | const val CMD_WORKOUT_TYPES_GET: Int = 39 49 | const val CMD_MISC_SETTING_SET_FROM_BAND: Int = 42 50 | const val CMD_SILENT_MODE_GET: Int = 43 51 | const val CMD_SILENT_MODE_SET_FROM_PHONE: Int = 44 52 | const val CMD_SILENT_MODE_SET_FROM_WATCH: Int = 45 53 | const val CMD_WIDGET_SCREENS_GET: Int = 51 54 | const val CMD_WIDGET_SCREENS_SET: Int = 52 55 | const val CMD_WIDGET_PARTS_GET: Int = 53 56 | const val CMD_DEVICE_STATE_GET: Int = 78 57 | const val CMD_DEVICE_STATE: Int = 79 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiSppPacket.kt: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2023 Yoran Vulker 2 | 3 | This file is part of Gadgetbridge. 4 | 5 | Gadgetbridge is free software: you can redistribute it and/or modify 6 | it under the terms of the GNU Affero General Public License as published 7 | by the Free Software Foundation, either version 3 of the License, or 8 | (at your option) any later version. 9 | 10 | Gadgetbridge is distributed in the hope that it will be useful, 11 | but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | GNU Affero General Public License for more details. 14 | 15 | You should have received a copy of the GNU Affero General Public License 16 | along with this program. If not, see . */ 17 | package com.github.sky130.suiteki.pro.device.xiaomi 18 | 19 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 20 | import java.nio.ByteBuffer 21 | import java.nio.ByteOrder 22 | import java.util.Locale 23 | import java.util.concurrent.atomic.AtomicInteger 24 | 25 | 26 | class XiaomiSppPacket { 27 | var payload: ByteArray? = null 28 | private set 29 | private var flag = false 30 | private var needsResponse = false 31 | var channel: Int = 0 32 | private set 33 | private var opCode = 0 34 | private var frameSerial = 0 35 | var dataType: Int = 0 36 | private set 37 | 38 | class Builder { 39 | private var payload: ByteArray? = null 40 | private var flag = false 41 | private var needsResponse = false 42 | private var channel = -1 43 | private var opCode = -1 44 | private var frameSerial = -1 45 | private var dataType = -1 46 | 47 | fun build(): XiaomiSppPacket { 48 | val result = XiaomiSppPacket() 49 | 50 | result.channel = channel 51 | result.flag = flag 52 | result.needsResponse = needsResponse 53 | result.opCode = opCode 54 | result.frameSerial = frameSerial 55 | result.dataType = dataType 56 | result.payload = payload 57 | 58 | return result 59 | } 60 | 61 | fun channel(channel: Int): Builder { 62 | this.channel = channel 63 | return this 64 | } 65 | 66 | fun flag(flag: Boolean): Builder { 67 | this.flag = flag 68 | return this 69 | } 70 | 71 | fun needsResponse(needsResponse: Boolean): Builder { 72 | this.needsResponse = needsResponse 73 | return this 74 | } 75 | 76 | fun opCode(opCode: Int): Builder { 77 | this.opCode = opCode 78 | return this 79 | } 80 | 81 | fun frameSerial(frameSerial: Int): Builder { 82 | this.frameSerial = frameSerial 83 | return this 84 | } 85 | 86 | fun dataType(dataType: Int): Builder { 87 | this.dataType = dataType 88 | return this 89 | } 90 | 91 | fun payload(payload: ByteArray?): Builder { 92 | this.payload = payload 93 | return this 94 | } 95 | } 96 | 97 | fun needsResponse(): Boolean { 98 | return needsResponse 99 | } 100 | 101 | fun hasFlag(): Boolean { 102 | return this.flag 103 | } 104 | 105 | override fun toString(): String { 106 | return String.format( 107 | Locale.ROOT, 108 | "SppPacket{ channel=0x%x, flag=%b, needsResponse=%b, opCode=0x%x, frameSerial=0x%x, dataType=0x%x, payloadSize=%d }", 109 | channel, flag, needsResponse, opCode, frameSerial, dataType, payload!!.size 110 | ) 111 | } 112 | 113 | fun encode(authService: XiaomiAuthService, encryptionCounter: AtomicInteger): ByteArray { 114 | var payload = this.payload 115 | 116 | if (dataType == DATA_TYPE_ENCRYPTED && channel == CHANNEL_PROTO_TX) { 117 | val packetCounter = encryptionCounter.incrementAndGet() 118 | payload = authService.encrypt(payload!!, packetCounter) 119 | payload = ByteBuffer.allocate(payload.size + 2).order(ByteOrder.LITTLE_ENDIAN) 120 | .putShort(packetCounter.toShort()).put(payload).array() 121 | } else if (dataType == DATA_TYPE_ENCRYPTED) { 122 | payload = authService.encrypt(payload!!, 0.toShort().toInt()) 123 | } 124 | 125 | val buffer = ByteBuffer.allocate(11 + payload!!.size).order(ByteOrder.LITTLE_ENDIAN) 126 | buffer.put(PACKET_PREAMBLE) 127 | 128 | buffer.put((channel and 0xf).toByte()) 129 | buffer.put(((if (flag) 0x80 else 0) or (if (needsResponse) 0x40 else 0)).toByte()) 130 | buffer.putShort((payload.size + 3).toShort()) 131 | 132 | buffer.put((opCode and 0xff).toByte()) 133 | buffer.put((frameSerial and 0xff).toByte()) 134 | buffer.put((dataType and 0xff).toByte()) 135 | 136 | buffer.put(payload) 137 | 138 | buffer.put(PACKET_EPILOGUE) 139 | return buffer.array() 140 | } 141 | 142 | companion object { 143 | val PACKET_PREAMBLE: ByteArray = byteArrayOf(0xba.toByte(), 0xdc.toByte(), 0xfe.toByte()) 144 | val PACKET_EPILOGUE: ByteArray = byteArrayOf(0xef.toByte()) 145 | 146 | const val CHANNEL_VERSION: Int = 0 147 | 148 | /** 149 | * Channel ID for PROTO messages received from device 150 | */ 151 | const val CHANNEL_PROTO_RX: Int = 1 152 | 153 | /** 154 | * Channel ID for PROTO messages sent to device 155 | */ 156 | const val CHANNEL_PROTO_TX: Int = 2 157 | const val CHANNEL_FITNESS: Int = 3 158 | const val CHANNEL_VOICE: Int = 4 159 | const val CHANNEL_MASS: Int = 5 160 | const val CHANNEL_OTA: Int = 7 161 | 162 | const val DATA_TYPE_PLAIN: Int = 0 163 | const val DATA_TYPE_ENCRYPTED: Int = 1 164 | const val DATA_TYPE_AUTH: Int = 2 165 | 166 | fun fromXiaomiCommand( 167 | command: XiaomiProto.Command, 168 | frameCounter: Int, 169 | needsResponse: Boolean 170 | ): XiaomiSppPacket { 171 | return newBuilder().channel(CHANNEL_PROTO_TX).flag(true).needsResponse(needsResponse) 172 | .dataType( 173 | if (command.type == XiaomiAuthService.COMMAND_TYPE && command.subtype >= 17) DATA_TYPE_AUTH else DATA_TYPE_ENCRYPTED 174 | ).frameSerial(frameCounter).opCode(2).payload(command.toByteArray()).build() 175 | } 176 | 177 | fun newBuilder(): Builder { 178 | return Builder() 179 | } 180 | 181 | fun decode(packet: ByteArray): XiaomiSppPacket? { 182 | if (packet.size < 11) { 183 | return null 184 | } 185 | 186 | val buffer = ByteBuffer.wrap(packet).order(ByteOrder.LITTLE_ENDIAN) 187 | val preamble = ByteArray(PACKET_PREAMBLE.size) 188 | buffer[preamble] 189 | 190 | if (!PACKET_PREAMBLE.contentEquals(preamble)) { 191 | return null 192 | } 193 | 194 | var channel = buffer.get() 195 | 196 | if ((channel.toInt() and 0xf0) != 0) { 197 | channel = 0x0f 198 | } 199 | 200 | val flags = buffer.get() 201 | val flag = (flags.toInt() and 0x80) != 0 202 | val needsResponse = (flags.toInt() and 0x40) != 0 203 | 204 | if ((flags.toInt() and 0x0f) != 0) { 205 | } 206 | 207 | // payload header is included in size 208 | val payloadLength = (buffer.getShort().toInt() and 0xffff) - 3 209 | 210 | if (payloadLength + 11 > packet.size) { 211 | return null 212 | } 213 | 214 | val opCode = buffer.get().toInt() and 0xff 215 | val frameSerial = buffer.get().toInt() and 0xff 216 | val dataType = buffer.get().toInt() and 0xff 217 | val payload = ByteArray(payloadLength) 218 | buffer[payload] 219 | 220 | val epilogue = ByteArray(PACKET_EPILOGUE.size) 221 | buffer[epilogue] 222 | 223 | if (!PACKET_EPILOGUE.contentEquals(epilogue)) { 224 | return null 225 | } 226 | 227 | val result = XiaomiSppPacket() 228 | result.channel = channel.toInt() 229 | result.flag = flag 230 | result.needsResponse = needsResponse 231 | result.opCode = opCode 232 | result.frameSerial = frameSerial 233 | result.dataType = dataType 234 | result.payload = payload 235 | 236 | return result 237 | } 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/XiaomiSppSupport.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi 2 | 3 | import android.annotation.SuppressLint 4 | import android.bluetooth.BluetoothAdapter 5 | import android.bluetooth.BluetoothDevice 6 | import android.bluetooth.BluetoothSocket 7 | import android.os.Handler 8 | import android.os.HandlerThread 9 | import android.os.Looper 10 | import android.os.Message 11 | import android.os.Process.THREAD_PRIORITY_BACKGROUND 12 | import com.github.sky130.suiteki.pro.device.huami.HuamiService.BASE_UUID 13 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppPacket.Companion.CHANNEL_MASS 14 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppPacket.Companion.CHANNEL_PROTO_RX 15 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppPacket.Companion.DATA_TYPE_ENCRYPTED 16 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppPacket.Companion.PACKET_PREAMBLE 17 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 18 | import com.github.sky130.suiteki.pro.proto.xiaomi.XiaomiProto 19 | import kotlinx.coroutines.Dispatchers 20 | import kotlinx.coroutines.flow.flow 21 | import kotlinx.coroutines.flow.flowOn 22 | import kotlinx.coroutines.flow.launchIn 23 | import kotlinx.coroutines.flow.onEach 24 | import kotlinx.coroutines.launch 25 | import kotlinx.coroutines.withContext 26 | import okhttp3.internal.closeQuietly 27 | import java.io.ByteArrayOutputStream 28 | import java.io.IOException 29 | import java.lang.String 30 | import java.nio.ByteBuffer 31 | import java.nio.ByteOrder 32 | import java.util.Arrays 33 | import java.util.UUID 34 | import java.util.concurrent.atomic.AtomicInteger 35 | import kotlin.concurrent.Volatile 36 | 37 | class XiaomiSppSupport(device: XiaomiDevice) : XiaomiAbstractSupport(device), XiaomiChannelHandler { 38 | private val adapter = BluetoothAdapter.getDefaultAdapter() 39 | lateinit var dev: BluetoothDevice 40 | private val frameCounter = AtomicInteger(0) 41 | private val encryptionCounter = AtomicInteger(0) 42 | private val serviceUUID = UUID.fromString(String.format(BASE_UUID, "1101")) 43 | private lateinit var socket: BluetoothSocket 44 | private val scope get() = device.scope 45 | 46 | @Volatile 47 | private var mDisposed = false 48 | private val outputStream by lazy { socket.outputStream } 49 | private val inputStream by lazy { socket.inputStream } 50 | private val mChannelHandlers = HashMap() 51 | 52 | 53 | private lateinit var writeHandler: Handler 54 | private val readThread = object : Thread("Read Thread") { 55 | override fun run() { 56 | val buffer = ByteArray(1024) 57 | var nRead: Int 58 | while (!mDisposed) { 59 | try { 60 | inputStream?.let { 61 | nRead = it.read(buffer) 62 | if (nRead == -1) { 63 | throw IOException("End of stream") 64 | } 65 | onSocketRead(buffer.copyOf(nRead)) 66 | } 67 | } catch (ex: IOException) { 68 | break 69 | } 70 | } 71 | device.onDisconnect() 72 | } 73 | } 74 | 75 | @SuppressLint("MissingPermission") 76 | private val writeHandlerThread = 77 | object : HandlerThread("Write Handler", THREAD_PRIORITY_BACKGROUND) { 78 | override fun onLooperPrepared() { 79 | writeHandler = object : Handler(looper) { 80 | override fun handleMessage(msg: Message) { 81 | when (msg.what) { 82 | 0 -> { 83 | try { 84 | socket.connect() 85 | readThread.start() 86 | device.auth() 87 | } catch (e: Exception) { 88 | device.onDisconnect() 89 | SuitekiManager.log( 90 | e.message.toString(), 91 | e.stackTrace.joinToString() 92 | ) 93 | } 94 | } 95 | 96 | 1 -> { 97 | val obj = msg.obj as XiaomiSppPacket 98 | try { 99 | outputStream?.let { stream -> 100 | obj.encode(device.authService, encryptionCounter).let { 101 | stream.write(it) 102 | stream.flush() 103 | } 104 | } 105 | } catch (e: Exception) { 106 | SuitekiManager.log( 107 | "outputStreamError", 108 | e.message.toString(), 109 | e.stackTrace.joinToString() 110 | ) 111 | if (::socket.isInitialized) { 112 | SuitekiManager.log( 113 | socket.isConnected 114 | ) 115 | } 116 | } 117 | } 118 | 119 | 2 -> { 120 | val obj = msg.obj as Pair Unit> 121 | try { 122 | outputStream?.let { stream -> 123 | obj.first.encode(device.authService, encryptionCounter) 124 | .let { 125 | stream.write(it) 126 | stream.flush() 127 | } 128 | } 129 | } catch (e: Exception) { 130 | SuitekiManager.log( 131 | "outputStreamError", 132 | e.message.toString(), 133 | e.stackTrace.joinToString() 134 | ) 135 | if (::socket.isInitialized) { 136 | SuitekiManager.log( 137 | socket.isConnected 138 | ) 139 | } 140 | } finally { 141 | obj.second() 142 | } 143 | } 144 | } 145 | } 146 | } 147 | } 148 | } 149 | 150 | init { 151 | mChannelHandlers[CHANNEL_PROTO_RX] = this 152 | } 153 | 154 | 155 | override fun handle(payload: ByteArray?) { 156 | payload?.let { 157 | try { 158 | device.handleCommand(XiaomiProto.Command.parseFrom(it)) 159 | } catch (e: Exception) { 160 | return 161 | } 162 | } 163 | } 164 | 165 | 166 | val buffer: ByteArrayOutputStream = ByteArrayOutputStream() 167 | 168 | private fun onSocketRead(data: ByteArray) { 169 | SuitekiManager.log("onSocketRead", data) 170 | try { 171 | buffer.write(data) 172 | } catch (_: IOException) { 173 | } 174 | processBuffer() 175 | } 176 | 177 | private fun findNextPossiblePreamble(haystack: ByteArray): Int { 178 | var i = 1 179 | while (i + 2 < haystack.size) { 180 | // check if first byte matches 181 | if (haystack[i] == PACKET_PREAMBLE[0]) { 182 | return i 183 | } 184 | i++ 185 | } 186 | 187 | // did not find preamble 188 | return -1 189 | } 190 | 191 | private fun processBuffer() { 192 | // wait until at least an empty packet is in the buffer 193 | while (buffer.size() >= 11) { 194 | // start preamble compare 195 | val bufferState = buffer.toByteArray() 196 | val headerBuffer = ByteBuffer.wrap(bufferState, 0, 7).order(ByteOrder.LITTLE_ENDIAN) 197 | val preamble = ByteArray(PACKET_PREAMBLE.size) 198 | headerBuffer[preamble] 199 | 200 | if (!Arrays.equals(PACKET_PREAMBLE, preamble)) { 201 | val preambleOffset: Int = findNextPossiblePreamble(bufferState) 202 | 203 | if (preambleOffset == -1) { 204 | buffer.reset() 205 | } else { 206 | val remaining = ByteArray(bufferState.size - preambleOffset) 207 | System.arraycopy(bufferState, preambleOffset, remaining, 0, remaining.size) 208 | buffer.reset() 209 | try { 210 | buffer.write(remaining) 211 | } catch (_: IOException) { 212 | } 213 | } 214 | 215 | // continue processing at beginning of new buffer 216 | continue 217 | } 218 | 219 | headerBuffer.getShort() // skip flags and channel ID 220 | val payloadSize = headerBuffer.getShort().toInt() and 0xffff 221 | val packetSize = payloadSize + 8 // payload size includes payload header 222 | 223 | if (bufferState.size < packetSize) { 224 | return 225 | } 226 | val receivedPacket = XiaomiSppPacket.decode(bufferState) // remaining bytes unaffected 227 | 228 | onPacketReceived(receivedPacket) 229 | 230 | // extract remaining bytes from buffer 231 | val remaining = ByteArray(bufferState.size - packetSize) 232 | System.arraycopy(bufferState, packetSize, remaining, 0, remaining.size) 233 | 234 | buffer.reset() 235 | 236 | try { 237 | buffer.write(remaining) 238 | } catch (_: IOException) { 239 | } 240 | } 241 | } 242 | 243 | private fun onPacketReceived(packet: XiaomiSppPacket?) { 244 | if (packet == null) { 245 | return 246 | } 247 | var payload: ByteArray = packet.payload!! 248 | 249 | if (packet.dataType == 1) { 250 | payload = device.authService.decrypt(payload) 251 | } 252 | 253 | val channel: Int = packet.channel 254 | mChannelHandlers[channel]?.apply { 255 | handle(payload) 256 | } 257 | } 258 | 259 | fun dispose() { 260 | if (mDisposed) { 261 | return 262 | } 263 | mDisposed = true 264 | } 265 | 266 | @SuppressLint("MissingPermission") 267 | override fun start() { 268 | SuitekiManager.log("onStartConnect") 269 | writeHandlerThread.start() 270 | adapter.cancelDiscovery() 271 | dev = adapter.getRemoteDevice(device.mac) 272 | socket = dev.createInsecureRfcommSocketToServiceRecord(serviceUUID) 273 | writeHandler.obtainMessage(0).sendToTarget() 274 | } 275 | 276 | override fun sendCommand(command: XiaomiProto.Command) { 277 | SuitekiManager.log("sendCommand", command.toString()) 278 | writeHandler.obtainMessage( 279 | 1, XiaomiSppPacket.fromXiaomiCommand( 280 | command, frameCounter.getAndIncrement(), false 281 | ) 282 | ).sendToTarget() 283 | } 284 | 285 | override fun sendDataChunk(data: ByteArray, onSend: () -> Unit) { 286 | writeHandler.obtainMessage( 287 | 2, 288 | XiaomiSppPacket.newBuilder().channel(CHANNEL_MASS).needsResponse(false).flag(true) 289 | .opCode(2).frameSerial(frameCounter.getAndIncrement()).dataType(DATA_TYPE_ENCRYPTED) 290 | .payload(data).build() to onSend 291 | ).sendToTarget() 292 | } 293 | 294 | 295 | } 296 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/miband8/MiBand8.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi.miband8 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.Suiteki 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiDevice 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiBleSupport 6 | 7 | @Suiteki(pattern = "^Xiaomi Smart Band 8 [A-Z0-9]{4}$") 8 | class MiBand8(name: String, mac: String, key: String) : XiaomiDevice(name, mac, key) { 9 | override val isEncrypted = true 10 | override val support = XiaomiBleSupport(this) 11 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/miband8pro/MiBand8Pro.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi.miband8pro 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.Suiteki 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiDevice 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppSupport 6 | 7 | @Suiteki(pattern = "^Xiaomi Smart Band 8 Pro [0-9A-F]{4}$") 8 | class MiBand8Pro(name: String, mac: String, key: String) : XiaomiDevice( 9 | name, mac, key, 10 | ) { 11 | override val isEncrypted = true 12 | override val support = XiaomiSppSupport(this) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/redmiwatch4/RedmiWatch4.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi.redmiwatch4 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.Suiteki 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiDevice 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppSupport 6 | 7 | @Suiteki(pattern = "^Redmi Watch 4 [0-9A-F]{4}$") 8 | class RedmiWatch4(name: String, mac: String, key: String) : XiaomiDevice( 9 | name, mac, key, 10 | ) { 11 | override val isEncrypted = true 12 | override val support = XiaomiSppSupport(this) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/device/xiaomi/watchs3/WatchS3.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.device.xiaomi.watchs3 2 | 3 | import com.github.sky130.suiteki.pro.logic.ble.Suiteki 4 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiDevice 5 | import com.github.sky130.suiteki.pro.device.xiaomi.XiaomiSppSupport 6 | 7 | @Suiteki(pattern = "^Xiaomi Watch S3( eSIM)? [0-9A-F]{4}\$") 8 | class WatchS3(name: String, mac: String, key: String) : XiaomiDevice( 9 | name, mac, key, 10 | ) { 11 | override val isEncrypted = true 12 | override val support = XiaomiSppSupport(this) 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/ble/AbstractBleDevice.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.ble 2 | 3 | import androidx.compose.runtime.snapshots.SnapshotStateList 4 | import kotlinx.coroutines.flow.StateFlow 5 | 6 | abstract class AbstractSuitekiDevice( 7 | val name: String, 8 | val mac: String, 9 | val key: String, 10 | ) { 11 | abstract val version: StateFlow 12 | abstract val battery: StateFlow 13 | abstract val status: StateFlow 14 | abstract val appList: SnapshotStateList 15 | abstract fun onStart() 16 | abstract fun install(bytes: ByteArray) 17 | open fun deleteApp(id: String){} 18 | open fun launchApp(id:String){} 19 | open fun requestAppList(){} 20 | } 21 | 22 | data class AppInfo(val id: String,val name: String) 23 | 24 | @Target(AnnotationTarget.CLASS) 25 | @Retention(AnnotationRetention.RUNTIME) 26 | annotation class Suiteki(val pattern: String) 27 | 28 | 29 | enum class DeviceStatus { 30 | Connected, Disconnect, Authing, Waiting, AuthFailure 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/ble/ClassesReader.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.ble 2 | 3 | import android.content.Context 4 | import dalvik.system.DexFile 5 | import java.io.File 6 | import java.io.IOException 7 | 8 | object ClassesReader { 9 | 10 | fun applicationDexFile(packageCodePath: String): Set { 11 | val dexFiles: MutableSet = HashSet() 12 | val dir = File(packageCodePath).parentFile!! 13 | val files = dir.listFiles()!! 14 | for (file in files) { 15 | try { 16 | val absolutePath = file.absolutePath 17 | if (!absolutePath.contains(".")) continue 18 | val suffix = absolutePath.substring(absolutePath.lastIndexOf(".")) 19 | if (suffix != ".apk") continue 20 | val dexFile = createDexFile(file.absolutePath) ?: continue 21 | dexFiles.add(dexFile) 22 | } catch (e: Exception) { 23 | e.printStackTrace() 24 | } 25 | } 26 | return dexFiles 27 | } 28 | 29 | fun createDexFile(path: String?): DexFile? { 30 | return try { 31 | DexFile(path) 32 | } catch (e: IOException) { 33 | null 34 | } 35 | } 36 | 37 | fun reader(packageName: String, context: Context): List> { 38 | return reader(packageName, context.packageCodePath) 39 | } 40 | 41 | fun reader(packageName: String, packageCodePath: String): List> { 42 | val classes: MutableList> = ArrayList() 43 | val dexFiles = applicationDexFile(packageCodePath) 44 | val classLoader = Thread.currentThread().contextClassLoader 45 | for (dexFile in dexFiles) { 46 | val entries = dexFile.entries() 47 | while (entries.hasMoreElements()) { 48 | try { 49 | val currentClassPath = entries.nextElement() 50 | if (currentClassPath == null || currentClassPath.isEmpty() || currentClassPath.indexOf( 51 | packageName 52 | ) != 0 53 | ) continue 54 | val entryClass = 55 | Class.forName(currentClassPath, true, classLoader) ?: continue 56 | classes.add(entryClass) 57 | } catch (e: Exception) { 58 | e.printStackTrace() 59 | } 60 | } 61 | } 62 | return classes 63 | } 64 | } 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/ble/InstallStatus.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.ble 2 | 3 | sealed class InstallStatus(open val progress: Int) { 4 | 5 | data object Nope : InstallStatus(0) 6 | 7 | data class Installing(override val progress: Int) : InstallStatus(progress) 8 | 9 | data class InstallFailure(override val progress: Int, val message: String) : 10 | InstallStatus(progress) 11 | 12 | data class InstallSuccess(override val progress: Int) : InstallStatus(progress) 13 | 14 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/ble/SuitekiManager.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.ble 2 | 3 | import android.util.ArrayMap 4 | import android.util.Log 5 | import androidx.compose.runtime.mutableStateOf 6 | import com.github.sky130.suiteki.pro.MainApplication.Companion.context 7 | import com.github.sky130.suiteki.pro.logic.database.model.Device 8 | import com.github.sky130.suiteki.pro.util.BytesUtils 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.StateFlow 12 | 13 | object SuitekiManager { 14 | private val classMap = ArrayMap>>() 15 | private val flow = MutableStateFlow(null) 16 | val bleDevice: StateFlow get() = flow 17 | val logList = mutableListOf() 18 | val installStatus = mutableStateOf(InstallStatus.Nope) 19 | 20 | fun log(vararg str: Any) { 21 | logList.add(str.joinToString("\n") { 22 | if (it is ByteArray) { 23 | BytesUtils.bytesToHexStr(it).toString() 24 | } else { 25 | it.toString() 26 | } 27 | }) 28 | } 29 | 30 | fun waitForAuth() = if (bleDevice.value?.status?.value == DeviceStatus.Connected){ 31 | Unit 32 | }else{ 33 | null 34 | } 35 | 36 | fun connect(device: Device) { 37 | for ((i, p) in classMap) { 38 | if (i.toRegex().matches(device.name)) { 39 | flow.value = p.second.getConstructor( 40 | String::class.java, 41 | String::class.java, 42 | String::class.java, 43 | ).newInstance(device.name, device.mac, device.key).apply { 44 | onStart() 45 | } 46 | break 47 | } 48 | } 49 | } 50 | 51 | fun init() { 52 | ClassesReader.reader("com.github.sky130.suiteki.pro", context) 53 | .filter { !it.name.contains("$") }.forEach { 54 | for (i in it.annotations) { 55 | if (i is Suiteki) { 56 | Log.d("TAG", i.pattern) 57 | classMap[i.pattern] = i to (it as Class) 58 | } 59 | 60 | } 61 | } 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/database/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.database 2 | 3 | import androidx.room.Database 4 | import androidx.room.Room 5 | import androidx.room.RoomDatabase 6 | import com.github.sky130.suiteki.pro.MainApplication.Companion.context 7 | import com.github.sky130.suiteki.pro.logic.database.dao.DeviceDAO 8 | import com.github.sky130.suiteki.pro.logic.database.model.Device 9 | 10 | @Database( 11 | version = 11, 12 | entities = [Device::class], 13 | exportSchema = true, 14 | ) 15 | abstract class AppDatabase : RoomDatabase() { 16 | 17 | abstract fun device(): DeviceDAO 18 | 19 | companion object { 20 | val instance by lazy { 21 | Room.databaseBuilder( 22 | context, AppDatabase::class.java, "app_database" 23 | ).apply { 24 | fallbackToDestructiveMigration() 25 | }.build() 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/database/dao/DeviceDAO.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.database.dao 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Delete 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import com.github.sky130.suiteki.pro.logic.database.model.Device 9 | import kotlinx.coroutines.flow.Flow 10 | 11 | @Dao 12 | interface DeviceDAO { 13 | 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | suspend fun insert(item: Device) 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | suspend fun insert(items: List) 19 | 20 | @Delete 21 | suspend fun delete(item: Device) 22 | 23 | @Query("select * from suiteki_device ORDER BY `index`") 24 | fun getList(): Flow> 25 | 26 | @Query("delete from suiteki_device") 27 | suspend fun deleteAll() 28 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/database/model/Device.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.database.model 2 | 3 | import androidx.annotation.Keep 4 | import androidx.room.Entity 5 | import androidx.room.Index 6 | import androidx.room.PrimaryKey 7 | 8 | @Entity(tableName = "suiteki_device", indices = [Index(value = ["mac"], unique = true)]) 9 | @Keep 10 | data class Device(val name: String, val mac: String, val key: String){ 11 | @PrimaryKey(autoGenerate = true) 12 | var index: Int = 0 13 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/logic/handler/CrashHandler.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.logic.handler 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.pm.PackageManager 6 | import android.os.Build 7 | import android.os.Process 8 | import com.github.sky130.suiteki.pro.MainApplication.Companion.context 9 | import java.io.BufferedWriter 10 | import java.io.File 11 | import java.io.FileWriter 12 | import java.io.IOException 13 | import java.io.PrintWriter 14 | import java.lang.reflect.InvocationTargetException 15 | 16 | class CrashHandler private constructor() : Thread.UncaughtExceptionHandler { 17 | private var mDefaultCrashHandler: Thread.UncaughtExceptionHandler? = null 18 | private lateinit var mContext: Context 19 | 20 | fun init(context: Context) { 21 | mDefaultCrashHandler = Thread.getDefaultUncaughtExceptionHandler() 22 | Thread.setDefaultUncaughtExceptionHandler(this) 23 | mContext = context.applicationContext 24 | } 25 | 26 | override fun uncaughtException(thread: Thread, ex: Throwable) { 27 | try { 28 | Thread.sleep(1000L) 29 | dumpExceptionToSDCard(ex) 30 | } catch (e: IOException) { 31 | e.printStackTrace() 32 | } 33 | Process.killProcess(Process.myPid()) 34 | } 35 | 36 | @SuppressLint("SimpleDateFormat") 37 | @Throws(IOException::class) 38 | private fun dumpExceptionToSDCard(e: Throwable) { 39 | val dir = File(PATH) 40 | if (!dir.exists()) dir.mkdirs() 41 | val current = System.currentTimeMillis() 42 | val file = File("$PATH${current}.log") 43 | try { 44 | e.printStackTrace() 45 | PrintWriter(BufferedWriter(FileWriter(file))).use { pw -> 46 | pw.println(e::class.java.name) 47 | pw.println("----------------") 48 | dumpPhoneInfo(pw) 49 | pw.println("----------------") 50 | e.printStackTrace(pw) 51 | if (e is InvocationTargetException){ 52 | pw.println("----------------") 53 | e.cause?.printStackTrace(pw) 54 | } 55 | } 56 | } catch (_: Exception) { 57 | } 58 | } 59 | 60 | private fun dumpPhoneInfo(pw: PrintWriter) { 61 | val pm = mContext.packageManager 62 | val pi = pm.getPackageInfo(mContext.packageName, PackageManager.GET_ACTIVITIES) 63 | pw.apply { 64 | print("App Version: ") 65 | print(pi.versionName) 66 | print("_") 67 | println(pi.versionCode) 68 | print("OS Version: ") 69 | print(Build.VERSION.RELEASE) 70 | print("_") 71 | println(Build.VERSION.SDK_INT) 72 | print("Vendor: ") 73 | println(Build.MANUFACTURER) 74 | print("Model: ") 75 | println(Build.MODEL) 76 | print("CPU ABI: ") 77 | println(Build.SUPPORTED_ABIS.contentToString()) 78 | } 79 | } 80 | 81 | data class LogFile(val title: String) 82 | 83 | companion object { 84 | val PATH by lazy { context.getExternalFilesDir("")!!.path + "/Crash/" } 85 | @SuppressLint("StaticFieldLeak") 86 | val instance = CrashHandler() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/screen/app/AppScreen.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.screen.app 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Column 6 | import androidx.compose.foundation.layout.Row 7 | import androidx.compose.foundation.layout.Spacer 8 | import androidx.compose.foundation.layout.fillMaxSize 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.height 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.lazy.LazyColumn 13 | import androidx.compose.foundation.lazy.items 14 | import androidx.compose.material.icons.Icons 15 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 16 | import androidx.compose.material.icons.automirrored.filled.Launch 17 | import androidx.compose.material.icons.filled.Delete 18 | import androidx.compose.material.icons.filled.Launch 19 | import androidx.compose.material.icons.filled.Refresh 20 | import androidx.compose.material3.ElevatedCard 21 | import androidx.compose.material3.ExperimentalMaterial3Api 22 | import androidx.compose.material3.FloatingActionButton 23 | import androidx.compose.material3.Icon 24 | import androidx.compose.material3.IconButton 25 | import androidx.compose.material3.Scaffold 26 | import androidx.compose.material3.Text 27 | import androidx.compose.material3.TopAppBar 28 | import androidx.compose.runtime.Composable 29 | import androidx.compose.runtime.collectAsState 30 | import androidx.compose.runtime.getValue 31 | import androidx.compose.ui.Alignment 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.unit.dp 34 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 35 | import com.github.sky130.suiteki.pro.screen.main.home.DisconnectScreen 36 | import com.ramcosta.composedestinations.annotation.Destination 37 | import com.ramcosta.composedestinations.annotation.RootGraph 38 | import com.ramcosta.composedestinations.navigation.DestinationsNavigator 39 | 40 | @OptIn(ExperimentalMaterial3Api::class) 41 | @Composable 42 | @Destination 43 | fun AppScreen(navigator: DestinationsNavigator) { 44 | val device by SuitekiManager.bleDevice.collectAsState() 45 | 46 | Scaffold(floatingActionButton = { 47 | FloatingActionButton(onClick = { device?.requestAppList() }) { 48 | Icon(Icons.Default.Refresh, contentDescription = null) 49 | } 50 | }, topBar = { 51 | TopAppBar(title = { Text("应用管理") }, navigationIcon = { 52 | IconButton(onClick = { navigator.popBackStack() }) { 53 | Icon(Icons.AutoMirrored.Default.ArrowBack, null) 54 | } 55 | }) 56 | }) { padding -> 57 | Column( 58 | modifier = Modifier 59 | .padding(padding) 60 | .fillMaxSize() 61 | .padding(horizontal = 10.dp) 62 | ) { 63 | if (device == null) { 64 | DisconnectScreen() 65 | } else { 66 | val ble by SuitekiManager.bleDevice.collectAsState() 67 | LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { 68 | items(device!!.appList) { 69 | ElevatedCard(onClick = { 70 | 71 | }, modifier = Modifier.fillMaxWidth()) { 72 | Box( 73 | modifier = Modifier 74 | .padding(10.dp) 75 | .fillMaxWidth() 76 | ) { 77 | Column { 78 | Text(it.name) 79 | Spacer(Modifier.height(5.dp)) 80 | Text(it.id) 81 | } 82 | 83 | Row( 84 | modifier = Modifier.align( 85 | Alignment.CenterEnd 86 | ), horizontalArrangement = Arrangement.spacedBy(5.dp) 87 | ) { 88 | 89 | IconButton( 90 | onClick = { ble?.deleteApp(it.id) }, 91 | ) { 92 | Icon(Icons.Default.Delete, null) 93 | } 94 | IconButton( 95 | onClick = { ble?.launchApp(it.id) }, 96 | ) { 97 | Icon(Icons.AutoMirrored.Filled.Launch, null) 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/screen/folder/FolderScreen.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.screen.folder 2 | 3 | import android.annotation.SuppressLint 4 | import android.util.Log 5 | import androidx.compose.foundation.Image 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.Column 9 | import androidx.compose.foundation.layout.IntrinsicSize 10 | import androidx.compose.foundation.layout.Row 11 | import androidx.compose.foundation.layout.Spacer 12 | import androidx.compose.foundation.layout.fillMaxHeight 13 | import androidx.compose.foundation.layout.fillMaxSize 14 | import androidx.compose.foundation.layout.fillMaxWidth 15 | import androidx.compose.foundation.layout.height 16 | import androidx.compose.foundation.layout.padding 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.foundation.layout.wrapContentSize 19 | import androidx.compose.foundation.lazy.LazyColumn 20 | import androidx.compose.foundation.lazy.LazyRow 21 | import androidx.compose.foundation.lazy.items 22 | import androidx.compose.foundation.lazy.itemsIndexed 23 | import androidx.compose.material.icons.Icons 24 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 25 | import androidx.compose.material.icons.filled.FolderOpen 26 | import androidx.compose.material.icons.filled.FolderZip 27 | import androidx.compose.material.icons.outlined.FolderZip 28 | import androidx.compose.material.icons.outlined.UploadFile 29 | import androidx.compose.material3.ElevatedCard 30 | import androidx.compose.material3.ExperimentalMaterial3Api 31 | import androidx.compose.material3.Icon 32 | import androidx.compose.material3.IconButton 33 | import androidx.compose.material3.OutlinedCard 34 | import androidx.compose.material3.Scaffold 35 | import androidx.compose.material3.Text 36 | import androidx.compose.material3.TopAppBar 37 | import androidx.compose.runtime.Composable 38 | import androidx.compose.runtime.LaunchedEffect 39 | import androidx.compose.runtime.Stable 40 | import androidx.compose.runtime.getValue 41 | import androidx.compose.runtime.mutableStateListOf 42 | import androidx.compose.runtime.mutableStateOf 43 | import androidx.compose.runtime.remember 44 | import androidx.compose.runtime.setValue 45 | import androidx.compose.runtime.toMutableStateList 46 | import androidx.compose.ui.Alignment 47 | import androidx.compose.ui.Modifier 48 | import androidx.compose.ui.unit.dp 49 | import com.github.sky130.suiteki.pro.ui.widget.SuitekiScaffold 50 | import com.github.sky130.suiteki.pro.ui.widget.SuitekiTopBar 51 | import com.ramcosta.composedestinations.annotation.Destination 52 | import com.ramcosta.composedestinations.annotation.RootGraph 53 | import com.ramcosta.composedestinations.navigation.DestinationsNavigator 54 | import com.ramcosta.composedestinations.result.ResultBackNavigator 55 | import java.io.File 56 | 57 | @SuppressLint("MutableCollectionMutableState") 58 | @OptIn(ExperimentalMaterial3Api::class) 59 | @Composable 60 | @Destination 61 | fun FolderScreen( 62 | resultNavigator: ResultBackNavigator 63 | ) { 64 | val files = remember { 65 | mutableStateListOf(File("/sdcard")) 66 | } 67 | 68 | val fileList = remember { 69 | mutableStateListOf() 70 | } 71 | 72 | 73 | fun File.refresh() { 74 | fileList.clear() 75 | fileList.addAll(listFiles().filter { 76 | it.isDirectory || it.extension.lowercase() in listOf( 77 | "rpk", 78 | "bin", 79 | "zip", 80 | "face" 81 | ) 82 | }.sortedWith(compareBy { it.isDirectory }.thenBy { it.name })) 83 | } 84 | 85 | LaunchedEffect(Unit) { 86 | files.last().refresh() 87 | } 88 | 89 | Scaffold(topBar = { 90 | TopAppBar(title = { Text(text = "选择文件") }, navigationIcon = { 91 | IconButton(onClick = { resultNavigator.navigateBack() }) { 92 | Icon(Icons.AutoMirrored.Default.ArrowBack, null) 93 | } 94 | }) 95 | }) { 96 | Box(modifier = Modifier.padding(it)) { 97 | Column( 98 | modifier = Modifier 99 | .fillMaxSize() 100 | .padding(horizontal = 10.dp) 101 | ) { 102 | LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { 103 | itemsIndexed(files) { index, item -> 104 | OutlinedCard( 105 | onClick = { 106 | if (index + 1 == files.size) return@OutlinedCard 107 | for (i in index + 1..(start = true) 61 | fun MainScreen( 62 | navigator: DestinationsNavigator, 63 | resultRecipient: ResultRecipient 64 | ) { 65 | val navController = rememberNavController() 66 | Scaffold(bottomBar = { 67 | BottomBar(navController = navController) 68 | }) { innerPadding -> 69 | DestinationsNavHost( 70 | navGraph = NavGraphs.main, 71 | modifier = Modifier 72 | .padding(innerPadding) 73 | .fillMaxSize(), 74 | navController = navController, 75 | defaultTransitions = BasicTransitions(enterTransition = { 76 | fadeIn(animationSpec = tween(400), initialAlpha = 0f) 77 | }, 78 | exitTransition = { 79 | fadeOut(animationSpec = tween(400), targetAlpha = 0f) 80 | }), 81 | dependenciesContainerBuilder = { 82 | dependency(createExternalNavigator(navigator)) 83 | } 84 | ) { 85 | composable(HomeScreenDestination) { 86 | HomeScreen( 87 | createExternalNavigator(navigator), 88 | resultRecipient = resultRecipient 89 | ) 90 | } 91 | } 92 | } 93 | } 94 | 95 | class BasicTransitions( 96 | override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition, 97 | override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition 98 | ) : NavHostAnimatedDestinationStyle() 99 | 100 | @Composable 101 | fun BottomBar( 102 | navController: NavHostController 103 | ) { 104 | NavigationBar { 105 | MainItem.entries.forEach { destination -> 106 | val isCurrentDestOnBackStack by navController.isRouteOnBackStackAsState(destination.direction) 107 | NavigationBarItem( 108 | selected = isCurrentDestOnBackStack, 109 | onClick = { 110 | if (isCurrentDestOnBackStack) { 111 | return@NavigationBarItem 112 | } 113 | navController.navigate(destination.direction) { 114 | popUpTo(NavGraphs.main) { 115 | saveState = true 116 | } 117 | launchSingleTop = true 118 | restoreState = true 119 | } 120 | }, 121 | icon = { 122 | Icon( 123 | destination.icon, contentDescription = destination.title 124 | ) 125 | }, 126 | label = { Text(destination.title) }, 127 | ) 128 | } 129 | } 130 | } 131 | 132 | enum class MainItem( 133 | val direction: DirectionDestinationSpec, val title: String, val icon: ImageVector 134 | ) { 135 | Home(HomeScreenDestination, "主页", Icons.Default.Home), 136 | 137 | Device(DeviceScreenDestination, "设备", Icons.Default.Watch), 138 | 139 | More(MoreScreenDestination, "更多", Icons.Default.Apps), 140 | } 141 | 142 | @NavHostGraph(route = "main") 143 | annotation class MainGraph 144 | 145 | data class ExternalNavigator(val navigator: DestinationsNavigator) 146 | 147 | fun createExternalNavigator(navController: DestinationsNavigator) = ExternalNavigator(navController) -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/screen/main/home/HomeScreen.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.screen.main.home 2 | 3 | import android.util.Log 4 | import androidx.compose.animation.core.animateFloatAsState 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.Spacer 10 | import androidx.compose.foundation.layout.fillMaxSize 11 | import androidx.compose.foundation.layout.fillMaxWidth 12 | import androidx.compose.foundation.layout.height 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.foundation.layout.width 16 | import androidx.compose.foundation.lazy.LazyColumn 17 | import androidx.compose.foundation.lazy.grid.GridCells 18 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 19 | import androidx.compose.foundation.lazy.items 20 | import androidx.compose.material.icons.Icons 21 | import androidx.compose.material.icons.filled.AppRegistration 22 | import androidx.compose.material.icons.filled.Bluetooth 23 | import androidx.compose.material.icons.filled.Code 24 | import androidx.compose.material.icons.filled.FolderOpen 25 | import androidx.compose.material.icons.filled.Link 26 | import androidx.compose.material.icons.filled.Watch 27 | import androidx.compose.material.icons.outlined.Block 28 | import androidx.compose.material3.AlertDialog 29 | import androidx.compose.material3.Button 30 | import androidx.compose.material3.ElevatedCard 31 | import androidx.compose.material3.ExperimentalMaterial3Api 32 | import androidx.compose.material3.Icon 33 | import androidx.compose.material3.MaterialTheme 34 | import androidx.compose.material3.OutlinedButton 35 | import androidx.compose.material3.OutlinedCard 36 | import androidx.compose.material3.Text 37 | import androidx.compose.runtime.Composable 38 | import androidx.compose.runtime.LaunchedEffect 39 | import androidx.compose.runtime.MutableState 40 | import androidx.compose.runtime.collectAsState 41 | import androidx.compose.runtime.getValue 42 | import androidx.compose.runtime.mutableStateOf 43 | import androidx.compose.runtime.remember 44 | import androidx.compose.runtime.rememberCoroutineScope 45 | import androidx.compose.runtime.setValue 46 | import androidx.compose.ui.Alignment 47 | import androidx.compose.ui.Modifier 48 | import androidx.compose.ui.graphics.vector.ImageVector 49 | import androidx.compose.ui.text.font.FontStyle 50 | import androidx.compose.ui.unit.dp 51 | import com.github.sky130.suiteki.pro.logic.ble.DeviceStatus 52 | import com.github.sky130.suiteki.pro.logic.ble.InstallStatus 53 | import com.github.sky130.suiteki.pro.logic.ble.InstallStatus.* 54 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager 55 | import com.github.sky130.suiteki.pro.logic.ble.SuitekiManager.waitForAuth 56 | import com.github.sky130.suiteki.pro.screen.main.ExternalNavigator 57 | import com.github.sky130.suiteki.pro.screen.main.MainGraph 58 | import com.github.sky130.suiteki.pro.screen.main.more.AppCard 59 | import com.github.sky130.suiteki.pro.ui.widget.DialogState 60 | import com.github.sky130.suiteki.pro.ui.widget.SuitekiDialog 61 | import com.github.sky130.suiteki.pro.ui.widget.SuitekiScaffold 62 | import com.github.sky130.suiteki.pro.ui.widget.SuitekiTopBar 63 | import com.github.sky130.suiteki.pro.ui.widget.rememberDialogState 64 | import com.github.sky130.suiteki.pro.util.TextUtils.copyText 65 | import com.hitanshudhawan.circularprogressbar.CircularProgressBar 66 | import com.ramcosta.composedestinations.annotation.Destination 67 | import com.ramcosta.composedestinations.annotation.NavGraph 68 | import com.ramcosta.composedestinations.annotation.RootGraph 69 | import com.ramcosta.composedestinations.generated.NavGraphs 70 | import com.ramcosta.composedestinations.generated.destinations.AppScreenDestination 71 | import com.ramcosta.composedestinations.generated.destinations.FolderScreenDestination 72 | import com.ramcosta.composedestinations.navigation.DestinationsNavigator 73 | import com.ramcosta.composedestinations.rememberNavHostEngine 74 | import com.ramcosta.composedestinations.result.ResultRecipient 75 | import com.ramcosta.composedestinations.result.onResult 76 | import kotlinx.coroutines.Dispatchers 77 | import kotlinx.coroutines.launch 78 | import kotlinx.coroutines.withContext 79 | import java.io.File 80 | 81 | @OptIn(ExperimentalMaterial3Api::class) 82 | @Composable 83 | @Destination(start = true) 84 | fun HomeScreen( 85 | navigator: ExternalNavigator, 86 | resultRecipient: ResultRecipient 87 | ) { 88 | val scope = rememberCoroutineScope() 89 | val installDialogState = rememberDialogState() 90 | resultRecipient.onResult { resultValue -> 91 | Log.d("TAG", "got result ${resultValue.name}") 92 | installDialogState.show() 93 | scope.launch(Dispatchers.IO) { 94 | val bytes = resultValue.readBytes() 95 | withContext(Dispatchers.Main) { 96 | SuitekiManager.bleDevice.value?.install(bytes) 97 | } 98 | } 99 | } 100 | val visible = rememberDialogState() 101 | 102 | DeviceDialog(visible) 103 | InstallDialog(state = installDialogState, visible) 104 | 105 | SuitekiScaffold( 106 | topBar = { 107 | SuitekiTopBar(title = "主页") 108 | } 109 | ) { 110 | Column( 111 | modifier = Modifier 112 | .padding(horizontal = 10.dp) 113 | .fillMaxSize() 114 | ) { 115 | val ble by SuitekiManager.bleDevice.collectAsState(null) 116 | if (ble == null) { 117 | DisconnectScreen() 118 | } else { 119 | ble?.let { 120 | val authStatus by it.status.collectAsState(DeviceStatus.Waiting) 121 | ElevatedCard(modifier = Modifier.fillMaxWidth()) { 122 | Column( 123 | modifier = Modifier.padding(15.dp), 124 | verticalArrangement = Arrangement.spacedBy(3.dp) 125 | ) { 126 | Text("当前连接", style = MaterialTheme.typography.titleLarge) 127 | Text(it.name) 128 | Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { 129 | 130 | SimpleChip( 131 | onClick = { /*TODO*/ }, 132 | icon = Icons.Default.Bluetooth, 133 | label = it.mac 134 | ) 135 | SimpleChip( 136 | onClick = { /*TODO*/ }, 137 | icon = Icons.Default.Link, 138 | label = authStatus.name 139 | ) 140 | 141 | } 142 | 143 | } 144 | } 145 | 146 | 147 | Spacer(modifier = Modifier.height(10.dp)) 148 | LazyVerticalGrid( 149 | columns = GridCells.Fixed(2), 150 | modifier = Modifier.fillMaxWidth(), 151 | verticalArrangement = Arrangement.spacedBy(10.dp), 152 | horizontalArrangement = Arrangement.spacedBy(10.dp) 153 | ) { 154 | item { 155 | AppCard( 156 | modifier = Modifier.fillMaxWidth(), 157 | "安装", 158 | Icons.Default.FolderOpen 159 | ) { 160 | waitForAuth() ?: return@AppCard 161 | navigator.navigator.navigate(FolderScreenDestination) 162 | } 163 | } 164 | item { 165 | AppCard( 166 | modifier = Modifier.fillMaxWidth(), 167 | "调试", 168 | Icons.Default.Code 169 | ) { 170 | visible.show() 171 | } 172 | } 173 | item { 174 | AppCard( 175 | modifier = Modifier.fillMaxWidth(), 176 | "应用管理", 177 | Icons.Default.AppRegistration 178 | ) { 179 | waitForAuth() ?: return@AppCard 180 | navigator.navigator.navigate(AppScreenDestination) 181 | } 182 | } 183 | } 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | @Composable 191 | fun DisconnectScreen() { 192 | Column( 193 | modifier = Modifier.fillMaxSize(), 194 | verticalArrangement = Arrangement.Center, 195 | horizontalAlignment = Alignment.CenterHorizontally 196 | ) { 197 | Icon(Icons.Outlined.Block, null, modifier = Modifier.size(100.dp)) 198 | Spacer(modifier = Modifier.height(10.dp)) 199 | Text("请先选择设备", style = MaterialTheme.typography.headlineMedium) 200 | } 201 | } 202 | 203 | @Composable 204 | fun InstallDialog(state: DialogState, log: DialogState) { 205 | SuitekiDialog(state = state, title = "安装文件", onDismissRequest = { }) { 206 | LaunchedEffect(Unit) { 207 | SuitekiManager.installStatus.value = Nope 208 | } 209 | val status by SuitekiManager.installStatus 210 | Column( 211 | modifier = Modifier.fillMaxWidth(), 212 | horizontalAlignment = Alignment.CenterHorizontally, 213 | verticalArrangement = Arrangement.Center 214 | ) { 215 | CircularProgressBar( 216 | modifier = Modifier.size(130.dp), 217 | progress = status.progress.toFloat(), 218 | progressMax = 100f, 219 | progressBarColor = MaterialTheme.colorScheme.primary, 220 | progressBarWidth = 15.dp, 221 | backgroundProgressBarColor = MaterialTheme.colorScheme.surfaceVariant, 222 | backgroundProgressBarWidth = 15.dp, 223 | roundBorder = true, 224 | startAngle = 0f, 225 | ) 226 | Spacer(modifier = Modifier.height(15.dp)) 227 | when (status) { 228 | is InstallSuccess -> { 229 | Text(text = "安装完成", style = MaterialTheme.typography.titleLarge) 230 | } 231 | 232 | is InstallFailure -> { 233 | Text(text = "安装失败", style = MaterialTheme.typography.titleLarge) 234 | Text(text = (status as InstallFailure).message, style = MaterialTheme.typography.titleMedium) 235 | 236 | } 237 | 238 | is Installing -> { 239 | Text(text = "${status.progress}%", style = MaterialTheme.typography.titleLarge) 240 | } 241 | 242 | Nope -> {} 243 | } 244 | Button(onClick = { log.show() }, modifier = Modifier.padding(top = 15.dp)) { 245 | Text(text = "日志") 246 | } 247 | if (status is InstallSuccess || status is InstallFailure) { 248 | Button(onClick = { state.dismiss() }, modifier = Modifier.padding(top = 15.dp)) { 249 | Text(text = "返回") 250 | } 251 | } 252 | } 253 | } 254 | } 255 | 256 | 257 | @Composable 258 | fun SimpleChip(onClick: () -> Unit, icon: ImageVector, label: String) { 259 | OutlinedCard(onClick = onClick) { 260 | Row(modifier = Modifier.padding(9.dp)) { 261 | Icon(icon, null) 262 | Spacer(modifier = Modifier.width(5.dp)) 263 | Text(label) 264 | } 265 | } 266 | } 267 | 268 | @Composable 269 | fun DeviceDialog(state: DialogState) { 270 | val list = remember { 271 | SuitekiManager.logList 272 | } 273 | SuitekiDialog( 274 | state = state, 275 | onDismissRequest = {}, 276 | button = { 277 | OutlinedButton(onClick = { state.dismiss() }) { 278 | Text(text = "取消") 279 | } 280 | OutlinedButton(onClick = { list.joinToString("\n\n").copyText() }) { 281 | Text(text = "复制") 282 | } 283 | }, 284 | icon = Icons.Filled.Watch, 285 | title = "设备日志", 286 | ) { 287 | 288 | 289 | LazyColumn( 290 | modifier = Modifier.fillMaxSize(), 291 | verticalArrangement = Arrangement.spacedBy(15.dp) 292 | ) { 293 | items(list) { 294 | Text(text = it) 295 | } 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) 12 | val SuitekiColor = Color(0xFF6186FC) -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.theme 2 | 3 | import androidx.compose.foundation.isSystemInDarkTheme 4 | import androidx.compose.material3.MaterialTheme 5 | import androidx.compose.runtime.Composable 6 | import com.materialkolor.DynamicMaterialTheme 7 | import com.materialkolor.PaletteStyle 8 | import com.materialkolor.rememberDynamicColorScheme 9 | 10 | @Composable 11 | fun SuitekiTheme( 12 | darkTheme: Boolean = isSystemInDarkTheme(), 13 | content: @Composable () -> Unit 14 | ) { 15 | DynamicMaterialTheme( 16 | seedColor = SuitekiColor, 17 | useDarkTheme = darkTheme, 18 | content = content, 19 | contrastLevel = 0.05 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/widget/CircularProgressIndicator.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.widget 2 | 3 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/widget/SuitekiDialog.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.widget 2 | 3 | import androidx.compose.foundation.layout.fillMaxSize 4 | import androidx.compose.material3.AlertDialog 5 | import androidx.compose.material3.AlertDialogDefaults 6 | import androidx.compose.material3.Icon 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.graphics.Shape 15 | import androidx.compose.ui.graphics.vector.ImageVector 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.window.DialogProperties 18 | 19 | @Composable 20 | fun BaseSuitekiDialog( 21 | state: DialogState, 22 | onDismissRequest: () -> Unit, 23 | confirmButton: @Composable () -> Unit, 24 | modifier: Modifier = Modifier, 25 | dismissButton: @Composable (() -> Unit)? = null, 26 | icon: @Composable (() -> Unit)? = null, 27 | title: @Composable (() -> Unit)? = null, 28 | text: @Composable (() -> Unit)? = null, 29 | shape: Shape = AlertDialogDefaults.shape, 30 | containerColor: Color = AlertDialogDefaults.containerColor, 31 | iconContentColor: Color = AlertDialogDefaults.iconContentColor, 32 | titleContentColor: Color = AlertDialogDefaults.titleContentColor, 33 | textContentColor: Color = AlertDialogDefaults.textContentColor, 34 | tonalElevation: Dp = AlertDialogDefaults.TonalElevation, 35 | properties: DialogProperties = DialogProperties() 36 | ) { 37 | val visible by remember { 38 | state.visible 39 | } 40 | if (!visible) return 41 | AlertDialog( 42 | onDismissRequest, confirmButton, modifier, dismissButton, 43 | icon = icon, 44 | title = title, 45 | text = text, 46 | shape = shape, 47 | containerColor = containerColor, 48 | tonalElevation = tonalElevation, 49 | // Note that a button content color is provided here from the dialog's token, but in 50 | // most cases, TextButtons should be used for dismiss and confirm buttons. 51 | // TextButtons will not consume this provided content color value, and will used their 52 | // own defined or default colors. 53 | iconContentColor = iconContentColor, 54 | titleContentColor = titleContentColor, 55 | textContentColor = textContentColor, 56 | properties = properties 57 | ) 58 | } 59 | 60 | @Composable 61 | fun SuitekiDialog( 62 | state: DialogState, 63 | title: String, 64 | icon: ImageVector? = null, 65 | onDismissRequest: () -> Unit, 66 | modifier: Modifier = Modifier, 67 | button: @Composable () -> Unit = {}, 68 | content: @Composable () -> Unit 69 | ) = BaseSuitekiDialog( 70 | state = state, 71 | onDismissRequest = onDismissRequest, 72 | confirmButton = button, 73 | modifier = modifier, 74 | title = { Text(text = title) }, 75 | icon = { icon?.let { Icon(it, title) } }, 76 | text = content 77 | ) 78 | 79 | @Composable 80 | fun rememberDialogState() = remember { DialogState() } 81 | 82 | class DialogState() { 83 | val visible = mutableStateOf(false) 84 | 85 | fun show() { 86 | visible.value = true 87 | } 88 | 89 | fun dismiss() { 90 | visible.value = false 91 | } 92 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/widget/SuitekiScaffold.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.PaddingValues 5 | import androidx.compose.foundation.layout.WindowInsets 6 | import androidx.compose.foundation.layout.padding 7 | import androidx.compose.material3.Scaffold 8 | import androidx.compose.material3.ScaffoldDefaults 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Modifier 11 | import androidx.compose.ui.unit.dp 12 | 13 | @Composable 14 | fun SuitekiScaffold( 15 | modifier: Modifier = Modifier, 16 | fab: @Composable () -> Unit = {}, 17 | topBar: @Composable () -> Unit = {}, 18 | content: @Composable () -> Unit, 19 | ) { 20 | Scaffold( 21 | modifier = modifier, 22 | topBar = topBar, 23 | contentWindowInsets = WindowInsets(0.dp, 0.dp, 0.dp, 0.dp), 24 | floatingActionButton = fab, 25 | content = { 26 | Box(modifier = Modifier.padding(it)){ 27 | content() 28 | } 29 | } 30 | ) 31 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/ui/widget/SuitekiTopBar.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.ui.widget 2 | 3 | import androidx.compose.foundation.layout.RowScope 4 | import androidx.compose.foundation.layout.WindowInsets 5 | import androidx.compose.material3.ExperimentalMaterial3Api 6 | import androidx.compose.material3.Text 7 | import androidx.compose.material3.TopAppBar 8 | import androidx.compose.material3.TopAppBarColors 9 | import androidx.compose.material3.TopAppBarDefaults 10 | import androidx.compose.material3.TopAppBarScrollBehavior 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.Modifier 13 | import androidx.compose.ui.unit.Dp 14 | import androidx.compose.ui.unit.dp 15 | 16 | @OptIn(ExperimentalMaterial3Api::class) 17 | @Composable 18 | fun SuitekiTopBar( 19 | title: String, 20 | modifier: Modifier = Modifier, 21 | navigationIcon: @Composable () -> Unit = {}, 22 | actions: @Composable RowScope.() -> Unit = {}, 23 | expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, 24 | scrollBehavior: TopAppBarScrollBehavior? = null 25 | ) { 26 | TopAppBar( 27 | title = { Text(text = title) }, 28 | modifier = modifier, 29 | navigationIcon = navigationIcon, 30 | actions = actions, 31 | expandedHeight = expandedHeight, 32 | scrollBehavior = scrollBehavior, 33 | windowInsets = WindowInsets(top = 0.dp) 34 | ) 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/BytesUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.util 2 | 3 | import java.nio.ByteBuffer 4 | import java.security.MessageDigest 5 | import java.util.zip.CRC32 6 | import kotlin.text.Charsets.UTF_8 7 | 8 | fun ByteArray.toHex() = joinToString(separator = "") { byte -> "%02x".format(byte) } 9 | 10 | fun hashBytes(data: ByteArray, algorithm: String): ByteArray = 11 | MessageDigest.getInstance(algorithm).digest(data) 12 | 13 | object BytesUtils { 14 | const val WATCHFACE = 0 15 | const val APP = 1 16 | const val FIRMWARE = 2 17 | fun hexToInt(hex: String): Int { 18 | return hex.toInt(16) 19 | } 20 | 21 | 22 | 23 | fun getSecretKey(key: String): ByteArray { 24 | val authKeyBytes = byteArrayOf( 25 | 0x30, 26 | 0x31, 27 | 0x32, 28 | 0x33, 29 | 0x34, 30 | 0x35, 31 | 0x36, 32 | 0x37, 33 | 0x38, 34 | 0x39, 35 | 0x40, 36 | 0x41, 37 | 0x42, 38 | 0x43, 39 | 0x44, 40 | 0x45 41 | ) 42 | var authKey = "" 43 | authKey = if (!key.startsWith("0x")) "0x$key" else key 44 | var srcBytes = authKey.trim { it <= ' ' }.toByteArray() 45 | if (authKey.length == 34 && authKey.startsWith("0x")) { 46 | srcBytes = hexToBytes(authKey.substring(2)) 47 | } 48 | System.arraycopy(srcBytes, 0, authKeyBytes, 0, srcBytes.size.coerceAtMost(16)) 49 | return authKeyBytes 50 | } 51 | 52 | 53 | fun addTwoArray(btX: ByteArray, btY: ByteArray): ByteArray { 54 | //定义目标数组 目标数组应该等于将要拼接的两个数组的总长度 55 | val btZ = ByteArray(btX.size + btY.size) 56 | System.arraycopy(btX, 0, btZ, 0, btX.size) 57 | System.arraycopy(btY, 0, btZ, btX.size, btY.size) 58 | return btZ 59 | } 60 | 61 | fun bytesToHexStr(byteArray: ByteArray?): String? { 62 | if (byteArray == null) { 63 | return null 64 | } 65 | val hexArray = "0123456789ABCDEF".toCharArray() 66 | val hexChars = CharArray(byteArray.size * 2) 67 | for (j in byteArray.indices) { 68 | val v = byteArray[j].toInt() and 0xFF 69 | hexChars[j * 2] = hexArray[v ushr 4] 70 | hexChars[j * 2 + 1] = hexArray[v and 0x0F] 71 | } 72 | return String(hexChars) 73 | } 74 | 75 | fun bytesToInt(byteArray: ByteArray?): Int { 76 | if (byteArray == null) { 77 | return 0 78 | } 79 | val hexArray = "0123456789ABCDEF".toCharArray() 80 | val hexChars = CharArray(byteArray.size * 2) 81 | for (j in byteArray.indices) { 82 | val v = byteArray[j].toInt() and 0xFF 83 | hexChars[j * 2] = hexArray[v ushr 4] 84 | hexChars[j * 2 + 1] = hexArray[v and 0x0F] 85 | } 86 | return String(hexChars).toInt(16) 87 | } 88 | 89 | fun intToBytes(a: Int): ByteArray { 90 | return hexToBytes(String.format("%06x", a)) 91 | } 92 | 93 | fun longToBytes(a: Long?): ByteArray { 94 | return hexToBytes(String.format("%08x", a)) 95 | } 96 | 97 | val HEX_CHARS: CharArray = "0123456789ABCDEF".toCharArray() 98 | 99 | 100 | fun hexdump(buffer: ByteArray, offset: Int, length: Int): String { 101 | var length = length 102 | if (length == -1) { 103 | length = buffer.size - offset 104 | } 105 | 106 | val hexChars = CharArray(length * 2) 107 | for (i in 0 until length) { 108 | val v = buffer[i + offset].toInt() and 0xFF 109 | hexChars[i * 2] = HEX_CHARS[v ushr 4] 110 | hexChars[i * 2 + 1] = HEX_CHARS[v and 0x0F] 111 | } 112 | return String(hexChars) 113 | } 114 | 115 | fun getD2(data_length: Int, CRC: Long): ByteArray { 116 | val a1 = byteArrayOf(-46, 8) 117 | val a2 = intToBytes(data_length) 118 | val a3 = byteArrayOf(0) 119 | val a4 = longToBytes(CRC) 120 | val a5 = byteArrayOf(0, 32, 0, -1) 121 | val byteBuffer = ByteBuffer.allocate(a1.size + a2.size + a3.size + a4.size + a5.size) 122 | byteBuffer.put(a1).put(a2).put(a3).put(a4).put(a5) 123 | return byteBuffer.array() 124 | } 125 | 126 | fun getCRC32(seq: ByteArray?, offset: Int, length: Int): Int { 127 | val crc = CRC32() 128 | crc.update(seq, offset, length) 129 | return crc.value.toInt() 130 | } 131 | 132 | fun getD2(bytes: ByteArray, mode: Byte): ByteArray { 133 | val arrayOfByte = ByteArray(14) 134 | arrayOfByte[0] = -46 135 | // switch (mode) { 136 | // case APP: 137 | // arrayOfByte[1] = -96;//A0 138 | // break; 139 | // case FIRMWARE: 140 | // arrayOfByte[1] = -3;//FD 141 | // break; 142 | // default://WATCHFACE 143 | // arrayOfByte[1] = 8;//08 144 | // break; 145 | // } 146 | arrayOfByte[1] = mode 147 | var arrayOfByte1 = fromUint32(bytes.size) 148 | arrayOfByte[2] = arrayOfByte1[0] 149 | arrayOfByte[3] = arrayOfByte1[1] 150 | arrayOfByte[4] = arrayOfByte1[2] 151 | arrayOfByte[5] = arrayOfByte1[3] 152 | val crc = CRC32() 153 | crc.update(bytes) 154 | arrayOfByte1 = fromUint32(crc.value.toInt()) 155 | arrayOfByte[6] = arrayOfByte1[0] 156 | arrayOfByte[7] = arrayOfByte1[1] 157 | arrayOfByte[8] = arrayOfByte1[2] 158 | arrayOfByte[9] = arrayOfByte1[3] 159 | arrayOfByte[10] = 0 160 | arrayOfByte[11] = 32 161 | arrayOfByte[12] = 0 162 | arrayOfByte[13] = -1 163 | return arrayOfByte 164 | } 165 | 166 | fun hexToBytes(inHex: String): ByteArray { 167 | var inHex = inHex 168 | var hexlen = inHex.length 169 | val result: ByteArray 170 | if (hexlen % 2 == 1) { 171 | //奇数 172 | hexlen++ 173 | result = ByteArray(hexlen / 2) 174 | inHex = "0$inHex" 175 | } else { 176 | //偶数 177 | result = ByteArray(hexlen / 2) 178 | } 179 | var j = 0 180 | var i = 0 181 | while (i < hexlen) { 182 | result[j] = hexToByte(inHex.substring(i, i + 2)) 183 | j++ 184 | i += 2 185 | } 186 | return result 187 | } 188 | 189 | fun hexToByte(inHex: String): Byte { 190 | return inHex.toInt(16).toByte() 191 | } 192 | 193 | fun fromUint32(paramInt: Int): ByteArray { 194 | return byteArrayOf( 195 | (paramInt and 0xFF).toByte(), 196 | (paramInt shr 8 and 0xFF).toByte(), 197 | (paramInt shr 16 and 0xFF).toByte(), 198 | (paramInt shr 24 and 0xFF).toByte() 199 | ) 200 | } 201 | 202 | fun getAppBytes(paramInt: Int): ByteArray { 203 | val arrayOfByte1 = fromUint32(paramInt) //APP_ID 204 | val arrayOfByte2 = 205 | hexToBytes("030700570014000000A000020103000000000000000000000000002B670000") 206 | arrayOfByte2[arrayOfByte2.size - 4] = arrayOfByte1[0] 207 | arrayOfByte2[arrayOfByte2.size - 3] = arrayOfByte1[1] 208 | arrayOfByte2[arrayOfByte2.size - 2] = arrayOfByte1[2] 209 | arrayOfByte2[arrayOfByte2.size - 1] = arrayOfByte1[3] 210 | return arrayOfByte2 211 | } 212 | 213 | fun getAppBytes(byte: ByteArray): ByteArray { 214 | val arrayOfByte2 = 215 | hexToBytes("030700570014000000A000020103000000000000000000000000002B670000") 216 | arrayOfByte2[arrayOfByte2.size - 4] = byte[0] 217 | arrayOfByte2[arrayOfByte2.size - 3] = byte[1] 218 | arrayOfByte2[arrayOfByte2.size - 2] = byte[2] 219 | arrayOfByte2[arrayOfByte2.size - 1] = byte[3] 220 | return arrayOfByte2 221 | } 222 | 223 | // fun spiltBytes(original: ByteArray): ArrayList { 224 | // var length = original.size 225 | // val result = ArrayList() 226 | // var index = 0 227 | // var count = 0 228 | // while (length > 0) if (count < 33) if (length >= 244) { 229 | // val sub = ByteArray(244) 230 | // System.arraycopy(original, index, sub, 0, 244) 231 | // result.add(sub) 232 | // index += 244 233 | // length -= 244 234 | // count++ 235 | // } else break else if (length >= 140) { 236 | // val sub = ByteArray(140) 237 | // System.arraycopy(original, index, sub, 0, 140) 238 | // result.add(sub) 239 | // index += 140 240 | // length -= 140 241 | // count = 0 242 | // } else break 243 | // if (length > 0) { 244 | // val sub = ByteArray(length) 245 | // System.arraycopy(original, index, sub, 0, length) 246 | // result.add(sub) 247 | // } 248 | // return result 249 | // } 250 | 251 | fun splitBytes(original: ByteArray, num: Int = 8192): ArrayList { 252 | val byteArrayList = ArrayList() 253 | var startIndex = 0 254 | 255 | while (startIndex < original.size) { 256 | val endIndex = minOf(startIndex + num, original.size) 257 | byteArrayList.add(original.sliceArray(startIndex until endIndex)) 258 | startIndex = endIndex 259 | } 260 | 261 | return byteArrayList 262 | } 263 | 264 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/CheckSums.kt: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien 2 | Gaignon, Daniele Gobbetti, José Rebelo, Petr Vaněk 3 | 4 | This file is part of Gadgetbridge. 5 | 6 | Gadgetbridge is free software: you can redistribute it and/or modify 7 | it under the terms of the GNU Affero General Public License as published 8 | by the Free Software Foundation, either version 3 of the License, or 9 | (at your option) any later version. 10 | 11 | Gadgetbridge is distributed in the hope that it will be useful, 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | GNU Affero General Public License for more details. 15 | 16 | You should have received a copy of the GNU Affero General Public License 17 | along with this program. If not, see . */ 18 | package com.github.sky130.suiteki.pro.util 19 | 20 | import java.io.ByteArrayOutputStream 21 | import java.io.FileInputStream 22 | import java.io.IOException 23 | import java.io.InputStream 24 | import java.security.MessageDigest 25 | import java.security.NoSuchAlgorithmException 26 | import java.util.zip.CRC32 27 | import kotlin.math.max 28 | 29 | object CheckSums { 30 | fun getCRC8(seq: ByteArray): Int { 31 | var len = seq.size 32 | var i = 0 33 | var crc: Byte = 0x00 34 | 35 | while (len-- > 0) { 36 | var extract = seq[i++] 37 | for (tempI in 8 downTo 1) { 38 | var sum = ((crc.toInt() and 0xff) xor (extract.toInt() and 0xff)).toByte() 39 | sum = ((sum.toInt() and 0xff) and 0x01).toByte() 40 | crc = ((crc.toInt() and 0xff) ushr 1).toByte() 41 | if (sum.toInt() != 0) { 42 | crc = ((crc.toInt() and 0xff) xor 0x8c).toByte() 43 | } 44 | extract = ((extract.toInt() and 0xff) ushr 1).toByte() 45 | } 46 | } 47 | return (crc.toInt() and 0xff) 48 | } 49 | 50 | //thanks http://stackoverflow.com/questions/13209364/convert-c-crc16-to-java-crc16 51 | fun getCRC16(seq: ByteArray): Int { 52 | return getCRC16(seq, 0xFFFF) 53 | } 54 | 55 | fun getCRC16(seq: ByteArray, crc: Int): Int { 56 | var crc = crc 57 | for (b in seq) { 58 | crc = ((crc ushr 8) or (crc shl 8)) and 0xffff 59 | crc = crc xor (b.toInt() and 0xff) //byte to int, trunc sign 60 | crc = crc xor ((crc and 0xff) shr 4) 61 | crc = crc xor ((crc shl 12) and 0xffff) 62 | crc = crc xor (((crc and 0xFF) shl 5) and 0xffff) 63 | } 64 | crc = crc and 0xffff 65 | return crc 66 | } 67 | 68 | fun getCRC16ansi(seq: ByteArray): Int { 69 | var crc = 0xffff 70 | val polynomial = 0xA001 71 | 72 | for (i in seq.indices) { 73 | crc = crc xor (seq[i].toInt() and 0xFF) 74 | for (j in 0..7) { 75 | crc = if ((crc and 1) != 0) { 76 | crc ushr 1 xor polynomial 77 | } else { 78 | crc ushr 1 79 | } 80 | } 81 | } 82 | 83 | return crc and 0xFFFF 84 | } 85 | 86 | fun getCRC32(seq: ByteArray?): Int { 87 | val crc = CRC32() 88 | crc.update(seq) 89 | return crc.value.toInt() 90 | } 91 | 92 | fun getCRC32(seq: ByteArray?, offset: Int, length: Int): Int { 93 | val crc = CRC32() 94 | crc.update(seq, offset, length) 95 | return crc.value.toInt() 96 | } 97 | 98 | @Throws(IOException::class) 99 | @JvmStatic 100 | fun main(args: Array) { 101 | require(!(args == null || args.size == 0)) { "Pass the files to be checksummed as arguments" } 102 | for (name in args) { 103 | FileInputStream(name).use { `in` -> 104 | val bytes = readAll(`in`, (1000 * 1000).toLong()) 105 | println(name + " : " + getCRC16(bytes)) 106 | } 107 | } 108 | } 109 | 110 | // copy&paste of FileUtils.readAll() to have it free from Android dependencies 111 | @Throws(IOException::class) 112 | private fun readAll(`in`: InputStream, maxLen: Long): ByteArray { 113 | val out = ByteArrayOutputStream( 114 | max(8192.0, `in`.available().toDouble()).toInt() 115 | ) 116 | val buf = ByteArray(8192) 117 | var read: Int 118 | var totalRead: Long = 0 119 | while ((`in`.read(buf).also { read = it }) > 0) { 120 | out.write(buf, 0, read) 121 | totalRead += read.toLong() 122 | if (totalRead > maxLen) { 123 | throw IOException("Too much data to read into memory. Got already $totalRead") 124 | } 125 | } 126 | return out.toByteArray() 127 | } 128 | 129 | // https://github.com/ThePBone/GalaxyBudsClient/blob/master/GalaxyBudsClient/Utils/CRC16.cs 130 | private val Crc16Tab = intArrayOf( 131 | 0, 4129, 8258, 12387, 16516, 20645, 24774, 28903, 33032, 37161, 41290, 132 | 45419, 49548, 53677, 57806, 61935, 4657, 528, 12915, 8786, 21173, 17044, 29431, 25302, 133 | 37689, 33560, 45947, 41818, 54205, 50076, 62463, 58334, 9314, 13379, 1056, 5121, 25830, 134 | 29895, 17572, 21637, 42346, 46411, 34088, 38153, 58862, 62927, 50604, 54669, 13907, 135 | 9842, 5649, 1584, 30423, 26358, 22165, 18100, 46939, 42874, 38681, 34616, 63455, 59390, 136 | 55197, 51132, 18628, 22757, 26758, 30887, 2112, 6241, 10242, 14371, 51660, 55789, 137 | 59790, 63919, 35144, 39273, 43274, 47403, 23285, 19156, 31415, 27286, 6769, 2640, 138 | 14899, 10770, 56317, 52188, 64447, 60318, 39801, 35672, 47931, 43802, 27814, 31879, 139 | 19684, 23749, 11298, 15363, 3168, 7233, 60846, 64911, 52716, 56781, 44330, 48395, 140 | 36200, 40265, 32407, 28342, 24277, 20212, 15891, 11826, 7761, 3696, 65439, 61374, 141 | 57309, 53244, 48923, 44858, 40793, 36728, 37256, 33193, 45514, 41451, 53516, 49453, 142 | 61774, 57711, 4224, 161, 12482, 8419, 20484, 16421, 28742, 24679, 33721, 37784, 41979, 143 | 46042, 49981, 54044, 58239, 62302, 689, 4752, 8947, 13010, 16949, 21012, 25207, 29270, 144 | 46570, 42443, 38312, 34185, 62830, 58703, 54572, 50445, 13538, 9411, 5280, 1153, 29798, 145 | 25671, 21540, 17413, 42971, 47098, 34713, 38840, 59231, 63358, 50973, 55100, 9939, 146 | 14066, 1681, 5808, 26199, 30326, 17941, 22068, 55628, 51565, 63758, 59695, 39368, 147 | 35305, 47498, 43435, 22596, 18533, 30726, 26663, 6336, 2273, 14466, 10403, 52093, 148 | 56156, 60223, 64286, 35833, 39896, 43963, 48026, 19061, 23124, 27191, 31254, 2801, 149 | 6864, 10931, 14994, 64814, 60687, 56684, 52557, 48554, 44427, 40424, 36297, 31782, 150 | 27655, 23652, 19525, 15522, 11395, 7392, 3265, 61215, 65342, 53085, 57212, 44955, 151 | 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 16050, 3793, 7920 152 | ) 153 | 154 | // // https://github.com/ThePBone/GalaxyBudsClient/blob/master/GalaxyBudsClient/Utils/CRC16.cs 155 | fun crc16_ccitt(data: ByteArray): Int { 156 | var i2 = 0 157 | for (i3 in data.indices) i2 = 158 | Crc16Tab[(i2 shr 8) xor data[i3].toInt() and 255] xor (i2 shl 8) 159 | 160 | return 65535 and i2 161 | } 162 | 163 | fun md5(data: ByteArray?): ByteArray? { 164 | val md: MessageDigest 165 | try { 166 | md = MessageDigest.getInstance("MD5") 167 | } catch (e: NoSuchAlgorithmException) { 168 | return null 169 | } 170 | md.update(data) 171 | return md.digest() 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/CryptoUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.util 2 | 3 | import android.annotation.SuppressLint 4 | import java.security.InvalidKeyException 5 | import java.security.NoSuchAlgorithmException 6 | import javax.crypto.BadPaddingException 7 | import javax.crypto.Cipher 8 | import javax.crypto.IllegalBlockSizeException 9 | import javax.crypto.NoSuchPaddingException 10 | import javax.crypto.spec.SecretKeySpec 11 | 12 | object CryptoUtils { 13 | @Throws( 14 | InvalidKeyException::class, 15 | NoSuchPaddingException::class, 16 | NoSuchAlgorithmException::class, 17 | BadPaddingException::class, 18 | IllegalBlockSizeException::class 19 | ) 20 | fun encryptAES(value: ByteArray?, secretKey: ByteArray?): ByteArray { 21 | @SuppressLint("GetInstance") val ecipher = Cipher.getInstance("AES/ECB/NoPadding") 22 | val newKey = SecretKeySpec(secretKey, "AES") 23 | ecipher.init(Cipher.ENCRYPT_MODE, newKey) 24 | return ecipher.doFinal(value) 25 | } 26 | 27 | @Throws( 28 | InvalidKeyException::class, 29 | NoSuchPaddingException::class, 30 | NoSuchAlgorithmException::class, 31 | BadPaddingException::class, 32 | IllegalBlockSizeException::class 33 | ) 34 | fun decryptAES(value: ByteArray?, secretKey: ByteArray?): ByteArray { 35 | @SuppressLint("GetInstance") val ecipher = Cipher.getInstance("AES/ECB/NoPadding") 36 | val newKey = SecretKeySpec(secretKey, "AES") 37 | ecipher.init(Cipher.DECRYPT_MODE, newKey) 38 | return ecipher.doFinal(value) 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/StringUtils.kt: -------------------------------------------------------------------------------- 1 | /* Copyright (C) 2017-2024 Andreas Shimokawa, Arjan Schrijver, Carsten 2 | Pfeiffer, Daniel Dakhno, Daniele Gobbetti, João Paulo Barraca, José Rebelo, 3 | Nephiel, Roi Greenberg, Taavi Eomäe, Zhong Jianxin 4 | 5 | This file is part of Gadgetbridge. 6 | 7 | Gadgetbridge is free software: you can redistribute it and/or modify 8 | it under the terms of the GNU Affero General Public License as published 9 | by the Free Software Foundation, either version 3 of the License, or 10 | (at your option) any later version. 11 | 12 | Gadgetbridge is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | GNU Affero General Public License for more details. 16 | 17 | You should have received a copy of the GNU Affero General Public License 18 | along with this program. If not, see . */ 19 | package com.github.sky130.suiteki.pro.util 20 | 21 | import org.apache.commons.lang3.ArrayUtils 22 | import java.io.ByteArrayOutputStream 23 | import java.nio.ByteBuffer 24 | import java.nio.CharBuffer 25 | import java.nio.charset.StandardCharsets 26 | import kotlin.math.min 27 | 28 | object StringUtils { 29 | fun truncate(s: String?, maxLength: Int): String { 30 | if (s == null) { 31 | return "" 32 | } 33 | 34 | val length = min(s.length.toDouble(), maxLength.toDouble()).toInt() 35 | if (length < 0) { 36 | return "" 37 | } 38 | 39 | return s.substring(0, length) 40 | } 41 | 42 | /** 43 | * Truncate a string to a certain maximum number of bytes, assuming UTF-8 encoding. 44 | * Does not include the null terminator. Due to multi-byte characters, it's possible 45 | * that the resulting array is smaller than len, but never larger. 46 | */ 47 | fun truncateToBytes(s: String, len: Int): ByteArray { 48 | if (isNullOrEmpty(s)) { 49 | return byteArrayOf() 50 | } 51 | 52 | var i = 0 53 | while (++i < s.length) { 54 | val subString = s.substring(0, i + 1) 55 | if (subString.toByteArray(StandardCharsets.UTF_8).size > len) { 56 | break 57 | } 58 | } 59 | 60 | return s.substring(0, i).toByteArray(StandardCharsets.UTF_8) 61 | } 62 | 63 | fun utf8ByteLength(string: String?, length: Int): Int { 64 | if (string == null) { 65 | return 0 66 | } 67 | val outBuf = ByteBuffer.allocate(length) 68 | val inBuf = CharBuffer.wrap(string.toCharArray()) 69 | StandardCharsets.UTF_8.newEncoder().encode(inBuf, outBuf, true) 70 | return outBuf.position() 71 | } 72 | 73 | @JvmOverloads 74 | fun pad(s: String?, length: Int, padChar: Char = ' '): String { 75 | var s = s 76 | val sBuilder = StringBuilder(s) 77 | while (sBuilder.length < length) { 78 | sBuilder.append(padChar) 79 | } 80 | s = sBuilder.toString() 81 | return s 82 | } 83 | 84 | /** 85 | * Joins the given elements and adds a separator between each element in the resulting string. 86 | * There will be no separator at the start or end of the string. There will be no consecutive 87 | * separators (even in case an element is null or empty). 88 | * @param separator the separator string 89 | * @param elements the elements to concatenate to a new string 90 | * @return the joined strings, separated by the separator 91 | */ 92 | fun join(separator: String?, vararg elements: String?): StringBuilder { 93 | val builder = StringBuilder() 94 | if (elements == null) { 95 | return builder 96 | } 97 | var hasAdded = false 98 | for (element in elements) { 99 | if (element != null && element.length > 0) { 100 | if (hasAdded) { 101 | builder.append(separator) 102 | } 103 | builder.append(element) 104 | hasAdded = true 105 | } 106 | } 107 | return builder 108 | } 109 | 110 | fun getFirstOf(first: String, second: String): String { 111 | if (first != null && first.length > 0) { 112 | return first 113 | } 114 | if (second != null) { 115 | return second 116 | } 117 | return "" 118 | } 119 | 120 | fun isNullOrEmpty(string: String?): Boolean { 121 | return string == null || string.isEmpty() 122 | } 123 | 124 | fun isEmpty(string: String?): Boolean { 125 | return string != null && string.length == 0 126 | } 127 | 128 | fun ensureNotNull(message: String?): String { 129 | if (message != null) { 130 | return message 131 | } 132 | return "" 133 | } 134 | 135 | fun terminateNull(input: String?): String { 136 | if (input == null || input.length == 0) { 137 | return String(byteArrayOf(0.toByte())) 138 | } 139 | val lastChar = input[input.length - 1] 140 | if (lastChar.code == 0) return input 141 | 142 | val newArray = ByteArray(input.toByteArray().size + 1) 143 | System.arraycopy(input.toByteArray(), 0, newArray, 0, input.toByteArray().size) 144 | 145 | newArray[newArray.size - 1] = 0 146 | 147 | return String(newArray) 148 | } 149 | 150 | fun untilNullTerminator(bytes: ByteArray, startOffset: Int): String? { 151 | for (i in startOffset until bytes.size) { 152 | if (bytes[i].toInt() == 0) { 153 | return String(ArrayUtils.subarray(bytes, startOffset, i)) 154 | } 155 | } 156 | 157 | return null 158 | } 159 | 160 | fun untilNullTerminator(buf: ByteBuffer): String? { 161 | val baos = ByteArrayOutputStream() 162 | 163 | while (buf.position() < buf.limit()) { 164 | val b = buf.get() 165 | 166 | if (b.toInt() == 0) { 167 | return baos.toString() 168 | } 169 | 170 | baos.write(b.toInt()) 171 | } 172 | 173 | return null 174 | } 175 | 176 | 177 | /** 178 | * Creates a shortened version of an Android package name by using only the first 179 | * character of every non-last part of the package name. 180 | * Example: "nodomain.freeyourgadget.gadgetbridge" is shortened to "n.f.gadgetbridge" 181 | * @param packageName the original package name 182 | * @return the shortened package name 183 | */ 184 | fun shortenPackageName(packageName: String): String { 185 | val parts = packageName.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() 186 | val result = StringBuilder() 187 | for (index in parts.indices) { 188 | if (index == parts.size - 1) { 189 | result.append(parts[index]) 190 | break 191 | } 192 | result.append(parts[index][0]).append(".") 193 | } 194 | return result.toString() 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/TextUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.util 2 | 3 | import android.content.ClipData 4 | import android.content.ClipboardManager 5 | import android.content.Context 6 | import com.github.sky130.suiteki.pro.MainApplication.Companion.context 7 | import java.util.regex.Matcher 8 | import java.util.regex.Pattern 9 | 10 | 11 | object TextUtils { 12 | 13 | fun String.copyText(): Boolean { 14 | return try { 15 | val clipboard: ClipboardManager = 16 | (context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?)!! 17 | val clipData = ClipData.newPlainText(null, this) 18 | clipboard.setPrimaryClip(clipData) 19 | true 20 | } catch (e: Exception) { 21 | e.printStackTrace() 22 | false 23 | } 24 | } 25 | 26 | fun getRegexMatchText(str: String, left: String, right: String): String { 27 | val temp = getRegexMatchTextArray(str, left, right) 28 | return if (temp.isEmpty()) "" 29 | else temp[0]!! 30 | } 31 | 32 | fun getRegexMatchTextArray(str: String, left: String, right: String): Array { 33 | return if ("" != str && "" != left && "" != right) regexMatch( 34 | str, 35 | "(?<=\\Q$left\\E).*?(?=\\Q$right\\E)" 36 | ) else arrayOfNulls(0) 37 | } 38 | 39 | private fun regexMatch(text: String, statement: String): Array { 40 | val pn: Pattern = Pattern.compile(statement, 40) 41 | val mr: Matcher = pn.matcher(text) 42 | val list = arrayListOf() 43 | while (mr.find()) { 44 | list.add(mr.group()) 45 | } 46 | val strings = arrayOfNulls(list.size) 47 | return list.toArray(strings) 48 | } 49 | } -------------------------------------------------------------------------------- /app/src/main/java/com/github/sky130/suiteki/pro/util/ZipUtils.kt: -------------------------------------------------------------------------------- 1 | package com.github.sky130.suiteki.pro.util 2 | 3 | 4 | import java.io.File 5 | import java.util.zip.ZipFile 6 | 7 | object ZipUtils { 8 | 9 | fun extractFrom(file: File, fileName: String): String? { 10 | ZipFile(file).use { zipFile -> 11 | val entry = zipFile.getEntry(fileName) ?: return null 12 | return zipFile.getInputStream(entry).bufferedReader().use { it.readText() } 13 | } 14 | } 15 | 16 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 16 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_suiteki.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sky130/Suiteki-Pro/8753dfac79e9f34753620e123ce9f064b079b248/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6186FC 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Suiteki Pro 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |