diff --git a/app/build.gradle b/app/build.gradle index 5b1d1ef..d81b68d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,12 +5,12 @@ plugins { android { namespace 'com.shiqi.testquickjs' - compileSdk 33 + compileSdk 36 defaultConfig { applicationId "com.shiqi.testquickjs" minSdk 24 - targetSdk 33 + targetSdk 36 versionCode 1 versionName "1.0" @@ -37,7 +37,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.4.5' + kotlinCompilerExtensionVersion '1.5.15' } packagingOptions { resources { @@ -54,7 +54,7 @@ dependencies { implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.activity:activity-compose:1.5.1' - implementation platform('androidx.compose:compose-bom:2022.10.00') + implementation platform('androidx.compose:compose-bom:2024.06.00') implementation 'androidx.compose.ui:ui' implementation 'androidx.compose.ui:ui-graphics' implementation 'androidx.compose.ui:ui-tooling-preview' diff --git a/app/src/main/assets/battery.js b/app/src/main/assets/battery.js new file mode 100644 index 0000000..51fafb3 --- /dev/null +++ b/app/src/main/assets/battery.js @@ -0,0 +1,54 @@ +/** + * 模擬電池電量 (Battery Level) 的變化 + */ + +//// 使用 'var' 宣告為全域變數,以便 Java/Kotlin 層可以透過 globalObject 訪問 +//var batteryLevel = 100; +// +//// 上次電量變化的時間戳 +//var lastBatteryUpdateTime = 0; +// +//// 模擬電量下降 +//function decreaseBattery() { +// const currentTime = Date.now(); // 使用內建的 Date.now() +// +// // 每 3 秒 (3000 毫秒) 更新一次電量 +// if (currentTime - lastBatteryUpdateTime > 3000) { +// // 隨機減少 1% 或 2% +// batteryLevel -= Math.floor(Math.random() * 2) + 1; +// +// // 確保電量不會低於 20% +// if (batteryLevel < 20) { +// batteryLevel = 100; // 如果電量過低,重新充滿以循環模擬 +// } +// +// lastBatteryUpdateTime = currentTime; +// console.log('[battery.js] Battery level was updated to: ' + batteryLevel); +// } +// +// // 即使不更新,也回傳目前的值 +// return batteryLevel; +//} +registerGatt("2A19", (function () { + + let level = 100; + let last = 0; + + function next() { + const now = Clock.millis(); + if (now - last >= 5000) { // 每 5 秒掉一點 + level -= Math.random() * 0.2; + if (level < 0) level = 100; + last = now; + } + } + + function getValue() { + return Math.round(level); + } + + return { + next, + getValue + }; +})()); \ No newline at end of file diff --git a/app/src/main/assets/clock.js b/app/src/main/assets/clock.js new file mode 100644 index 0000000..a17b78f --- /dev/null +++ b/app/src/main/assets/clock.js @@ -0,0 +1,17 @@ +var Clock = (function () { + + let now = 0; + + function tick(t) { + now = t; + } + + function millis() { + return now; + } + + return { + tick, + millis + }; +})(); diff --git a/app/src/main/assets/gatt-core.js b/app/src/main/assets/gatt-core.js new file mode 100644 index 0000000..3e7db90 --- /dev/null +++ b/app/src/main/assets/gatt-core.js @@ -0,0 +1,30 @@ +// 全域 registry(一開始是空的) +const __gattRegistry = {}; + +// 提供給各 js module 註冊用 +function registerGatt(uuid, impl) { + if (__gattRegistry[uuid]) { + console.log("Gatt already registered:", uuid); + } + __gattRegistry[uuid] = impl; +} + +// 統一對外 API(Java 只會用這兩個) +function next(uuid) { + const m = __gattRegistry[uuid]; + if (!m || !m.next) { + console.log("next(): no gatt for", uuid); + return; + } + m.next(); +} + +function getValue(uuid) { + const m = __gattRegistry[uuid]; + if (!m || !m.getValue) { + console.log("getValue(): no gatt for", uuid); + return -1; + } + return m.getValue(); +} + diff --git a/app/src/main/assets/hrm.js b/app/src/main/assets/hrm.js new file mode 100644 index 0000000..0896923 --- /dev/null +++ b/app/src/main/assets/hrm.js @@ -0,0 +1,72 @@ +/* + 舊的程式碼:每次呼叫 next() 都會更新 bpm + let bpm=72; + let dir=1; + + function next(){ + bpm+=dir*(Math.random()*2); + if(bpm>85) dir=-1; + if(bpm<65) dir=1; + return Math.round(bpm); + } +*/ + +// --- 新的程式碼 --- + +//// 全域變數,用於儲存心率值、方向和上次更新的時間戳 +//var bpm = 72; +//var dir = 1; +//var lastUpdateTime = 0; +// +//// Date.now() 返回自 1970 年 1 月 1 日 00:00:00 UTC 以來的毫秒數 +//function now_ms() { +// return Date.now(); +//} +// +//// 核心函式,現在包含了計時邏輯 +//function next() { +// const currentTime = now_ms(); +// +// if (currentTime - lastUpdateTime > 15000) { +// bpm += dir * (Math.random() * 2); +// if (bpm > 85) dir = -1; +// if (bpm < 65) dir = 1; +// lastUpdateTime = currentTime; +// +// console.log('[hrm.js] BPM was updated to: ' + Math.round(bpm)); +// } +// +// // 為了保持 evaluate("next()") 能工作,我們仍然回傳 bpm +// return Math.round(bpm); +//} + +registerGatt("2A37", (function () { + + let bpm = 72; + let dir = 1; + let last = 0; + + function next() { + const now = Clock.millis(); + if (last === 0) { + last = now; + return; + } + + if (now - last >= 1000) { + bpm += dir * (Math.random() * 2); + if (bpm > 85) dir = -1; + if (bpm < 65) dir = 1; + last = now; + } + } + + function getValue() { + return Math.round(bpm); + } + + return { + next, + getValue + }; +})()); \ No newline at end of file diff --git a/app/src/main/java/com/shiqi/testquickjs/BatteryGattValue.kt b/app/src/main/java/com/shiqi/testquickjs/BatteryGattValue.kt new file mode 100644 index 0000000..5294895 --- /dev/null +++ b/app/src/main/java/com/shiqi/testquickjs/BatteryGattValue.kt @@ -0,0 +1,65 @@ +package com.shiqi.testquickjs + +import android.util.Log +import com.shiqi.quickjs.JSContext +import com.shiqi.quickjs.JSNumber +import kotlin.math.roundToInt + +/** + * 一個純粹的業務邏輯類,專注於從一個準備好的 JSContext 中獲取電池電量數據。 + */ +class BatteryGattValue( + private val jsContext: JSContext +) : IGattValue { + + private val TAG = "BatteryGattValue" + + private val level: Int get() { + Log.d(TAG, "[DEBUG] get() called. Using 2-step evaluation for battery.") + + //// 步驟 1: 執行 decreaseBattery(),觸發其副作用(更新 batteryLevel 全域變數) +// jsContext.evaluate("decreaseBattery()") +// executePendingJobs() // 確保 console.log 被執行 +// +// // 步驟 2: 透過 globalObject 明確地獲取 'batteryLevel' 變數。 +// val result = jsContext.globalObject.getProperty("batteryLevel") +// +// if (result is JSNumber) { +// // JS 中的 number 預設是 double,安全地獲取並轉換 +// val doubleValue = result.getDouble() +// val intValue = doubleValue.roundToInt() +// Log.d(TAG, "[DEBUG] Successfully get 'batteryLevel' from globalObject. Value: $intValue") +// return intValue +// } +// +// val resultType = result?.javaClass?.simpleName ?: "null" +// Log.e(TAG, "[DEBUG] FAILED! Could not get 'batteryLevel' from globalObject. Result was '$resultType'.") +// return -1 // 回傳錯誤碼 + jsContext.evaluate("__temp_result = getValue('2A19');") + val result = jsContext.globalObject.getProperty("__temp_result") + + if (result is JSNumber) { + return result.int + } + + Log.e(TAG, "Failed to get Battery value via 'getValue(\'2A19\')'. Result was ${result?.javaClass?.simpleName}") + return -1 + } + + private fun executePendingJobs() { + try { + var hasPendingJob: Boolean + do { + hasPendingJob = jsContext.executePendingJob() + } while (hasPendingJob) + } catch (e: IllegalStateException) { + Log.w(TAG, "[DEBUG] Could not execute pending jobs, context might be closed.", e) + } + } + + /** + * 根據藍牙 GATT 規範,Battery Level (0x2A19) 是一個 uint8 的值,代表 0-100 的百分比。 + */ + override val gattValue: ByteArray + get() = byteArrayOf(level.toByte()) +} diff --git a/app/src/main/java/com/shiqi/testquickjs/HrmGattValue.kt b/app/src/main/java/com/shiqi/testquickjs/HrmGattValue.kt new file mode 100644 index 0000000..7079c3d --- /dev/null +++ b/app/src/main/java/com/shiqi/testquickjs/HrmGattValue.kt @@ -0,0 +1,71 @@ +package com.shiqi.testquickjs + +import android.util.Log +import com.shiqi.quickjs.JSContext +import com.shiqi.quickjs.JSNumber +import kotlin.math.roundToInt + +/** + * 一個純粹的業務邏輯類,專注於從一個準備好的 JSContext 中獲取心率數據。 + * 它不再持有任何 Android Context。 + */ +class HrmGattValue( + private val jsContext: JSContext +) : IGattValue { + + private val TAG = "HrmGattValue" + + // init 區塊已被移除,因為腳本的加載和執行責任已上移。 + + private val bpm: Int get() { + Log.d(TAG, "[DEBUG] get() called. Using 2-step evaluation.") + +// // 步驟 1: 執行 next(),僅為了觸發其副作用 (更新 bpm 全域變數和 console.log) +// // 我們忽略它的回傳值,因為我們知道它會被 console.log 污染成 null。 +// jsContext.evaluate("next()") +// executePendingJobs() // 確保 console.log 被執行 +// +// // 步驟 2: 透過 globalObject 明確地獲取 'bpm' 變數。 +// // 這是在 Java 層訪問 JS 全域變數的唯一正確方法。 +// val result = jsContext.globalObject.getProperty("bpm") +// +// if (result is JSNumber) { +// val doubleValue = result.getDouble() +// val bpmValue = doubleValue.roundToInt() +// Log.d(TAG, "[DEBUG] Successfully get 'bpm' from globalObject. DoubleValue: $doubleValue, RoundedInt: $bpmValue") +// return bpmValue +// } +// +// val resultType = result?.javaClass?.simpleName ?: "null" +// Log.e(TAG, "[DEBUG] FAILED! Could not get 'bpm' from globalObject. Result was '$resultType'.") +// return -1 + jsContext.evaluate("__temp_result = getValue('2A37');") + + // 步驟 2: 安全地從 globalObject 讀取臨時變數 + val result = jsContext.globalObject.getProperty("__temp_result") + + if (result is JSNumber) { + return result.int + } + + Log.e(TAG, "Failed to get HRM value via 'getValue(\'2A37\')'. Result was ${result?.javaClass?.simpleName}") + return -1 // 如果獲取失敗,回傳 -1 + } + + private fun executePendingJobs() { + try { + var hasPendingJob: Boolean + do { + hasPendingJob = jsContext.executePendingJob() + } while (hasPendingJob) + } catch (e: IllegalStateException) { + Log.w(TAG, "[DEBUG] Could not execute pending jobs, context might be closed.", e) + } + } + + override val gattValue: ByteArray + get() = byteArrayOf( + 0x00, // Flags: uint8 HR + bpm.toByte() + ) +} diff --git a/app/src/main/java/com/shiqi/testquickjs/IGattValue.kt b/app/src/main/java/com/shiqi/testquickjs/IGattValue.kt new file mode 100644 index 0000000..1abf15a --- /dev/null +++ b/app/src/main/java/com/shiqi/testquickjs/IGattValue.kt @@ -0,0 +1,6 @@ +package com.shiqi.testquickjs + +interface IGattValue { + val gattValue: ByteArray +} + diff --git a/app/src/main/java/com/shiqi/testquickjs/MainActivity.kt b/app/src/main/java/com/shiqi/testquickjs/MainActivity.kt index af02af5..23962be 100644 --- a/app/src/main/java/com/shiqi/testquickjs/MainActivity.kt +++ b/app/src/main/java/com/shiqi/testquickjs/MainActivity.kt @@ -1,75 +1,215 @@ package com.shiqi.testquickjs import android.os.Bundle -import android.os.Handler -import android.os.HandlerThread import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.shiqi.quickjs.JSNumber import com.shiqi.testquickjs.ui.theme.QuickJSTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets class MainActivity : ComponentActivity() { - companion object { - const val TAG = "QuickJs" - } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - // Create a new HandlerThread - val handlerThread = HandlerThread("JsThread").apply { start() } + private lateinit var jsEngine: QuickJsEngine + private lateinit var hrmGattValue: HrmGattValue + private lateinit var batteryGattValue: BatteryGattValue - // Create a Handler associated with the HandlerThread - val handler = Handler(handlerThread.looper) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + jsEngine = QuickJsEngine(this).apply { + init() + } + loadCoreScripts() + hrmGattValue = HrmGattValue(jsEngine.getJsContext()) + batteryGattValue = BatteryGattValue(jsEngine.getJsContext()) setContent { QuickJSTheme { - // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - Greeting("Android") - Button(onClick = { - // Post a task to the Handler to run on the HandlerThread - handler.post { - val quickJsEngine = QuickJsEngine(baseContext) - quickJsEngine.init() - val jsContext = quickJsEngine.getJsContext() - jsContext.evaluate(""" - //const indexInString = Array.prototype.indexOf.call("abc", "b") - const indexInString = "abc".indexOf("b") - console.log(JSON.stringify({ indexInString })) - """.trimIndent()) - } - }) { - Text("Click Me") - } + MainScreen( + jsEngine = jsEngine, + onLoadScript = { fileName -> loadAndExecuteScript(fileName) }, + hrmGattValue = hrmGattValue, + batteryGattValue = batteryGattValue + ) } } } } -} + private fun loadAndExecuteScript(fileName: String) { + try { + val scriptContent = assets.open(fileName) + .bufferedReader(StandardCharsets.UTF_8) + .use { it.readText() } + val context = jsEngine.getJsContext() + context.evaluate(scriptContent) -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) + while (context.executePendingJob()) { /* no-op */ } + Log.i("MainActivity", "Successfully loaded and executed: $fileName") + } catch (e: Exception) { + Log.e("MainActivity", "Failed to load and execute script: $fileName", e) + } + } + private fun loadCoreScripts() { + loadAndExecuteScript("clock.js") + loadAndExecuteScript("gatt-core.js") + } + + override fun onDestroy() { + super.onDestroy() + jsEngine.destroy() + } } -@Preview(showBackground = true) @Composable -fun GreetingPreview() { - QuickJSTheme { - Greeting("Android") +fun MainScreen( + jsEngine: QuickJsEngine, + onLoadScript: (String) -> Unit, + hrmGattValue: HrmGattValue, + batteryGattValue: BatteryGattValue +) { + // --- 直接從 JS 讀取的 UI 狀態 --- + var hrmValueDirect by remember { mutableStateOf("Direct HRM: --") } + var batteryValueDirect by remember { mutableStateOf("Direct Battery: --") } + + // --- 透過 GattValue 類別讀取的 UI 狀態 --- + var hrmValueFromClass by remember { mutableStateOf("Class HRM: --") } + var batteryValueFromClass by remember { mutableStateOf("Class Battery: --") } + + var isHrmLoaded by remember { mutableStateOf(false) } + var isBatteryLoaded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // JS 狀態更新排程器 (保持不變) + LaunchedEffect(Unit) { + val jsContext = jsEngine.getJsContext() + while (isActive) { + withContext(Dispatchers.Default) { + val now = System.currentTimeMillis() + jsContext.evaluate("Clock.tick($now);") + jsContext.evaluate("Object.keys(__gattRegistry).forEach(uuid => next(uuid));") + } + delay(100) + } } -} \ No newline at end of file + + // UI 更新迴圈 + LaunchedEffect(Unit) { + val jsContext = jsEngine.getJsContext() + // 在 JS 全域定義一個臨時變數,用 var 確保是全域 + jsContext.evaluate("var __temp_result;") + + while (isActive) { + withContext(Dispatchers.IO) { + if (isHrmLoaded) { + // --- 方式一:直接讀取 --- + jsContext.evaluate("__temp_result = getValue('2A37');") + val hrmResult = jsContext.globalObject.getProperty("__temp_result") + if (hrmResult is JSNumber) { + val bpm = hrmResult.int + withContext(Dispatchers.Main) { hrmValueDirect = "Direct HRM: $bpm BPM" } + } + + // --- 方式二:透過 HrmGattValue 類別 --- + // 它的 .gattValue 會觸發內部的 get() 來獲取數據 + val bpmFromClass = hrmGattValue.gattValue[1].toInt() and 0xFF // byteArray to Unsigned Int + withContext(Dispatchers.Main) { hrmValueFromClass = "Class HRM: $bpmFromClass BPM" } + } + + if (isBatteryLoaded) { + // --- 方式一:直接讀取 --- + jsContext.evaluate("__temp_result = getValue('2A19');") + val batteryResult = jsContext.globalObject.getProperty("__temp_result") + if (batteryResult is JSNumber) { + val level = batteryResult.int + withContext(Dispatchers.Main) { batteryValueDirect = "Direct Battery: $level%" } + } + + // --- 方式二:透過 BatteryGattValue 類別 --- + val levelFromClass = batteryGattValue.gattValue[0].toInt() and 0xFF + withContext(Dispatchers.Main) { batteryValueFromClass = "Class Battery: $levelFromClass%" } + } + } + delay(1000) + } + } + + // Column 和 Button 部分保持不變 + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = "QuickJS Dynamic GATT", fontSize = 24.sp) + Spacer(modifier = Modifier.height(24.dp)) + + // 顯示區域 + Text(text = "Direct from JS:", color = Color.Gray) + Text(text = hrmValueDirect, fontSize = 20.sp) + Text(text = batteryValueDirect, fontSize = 20.sp) + + Spacer(modifier = Modifier.height(16.dp)) + + Text(text = "Via GattValue Class:", color = Color.Gray, fontWeight = FontWeight.Bold) + Text(text = hrmValueFromClass, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text(text = batteryValueFromClass, fontSize = 20.sp, fontWeight = FontWeight.Bold) + + + Spacer(modifier = Modifier.height(32.dp)) + Text("Manual Load", fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Row { + Button( + onClick = { + if (!isHrmLoaded) { + scope.launch(Dispatchers.IO) { + onLoadScript("hrm.js") + withContext(Dispatchers.Main) { isHrmLoaded = true } + } + } + }, + enabled = !isHrmLoaded + ) { + Text("Load 2A37 (HRM)") + } + Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + if (!isBatteryLoaded) { + scope.launch(Dispatchers.IO) { + onLoadScript("battery.js") + withContext(Dispatchers.Main) { isBatteryLoaded = true } + } + } + }, + enabled = !isBatteryLoaded + ) { + Text("Load 2A19 (Battery)") + } + } + } +} diff --git a/build.gradle b/build.gradle index 69556f2..a531930 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '8.1.0-alpha07' apply false - id 'com.android.library' version '8.1.0-alpha07' apply false - id 'org.jetbrains.kotlin.android' version '1.8.20' apply false + id 'com.android.application' version '8.11.1' apply false + id 'com.android.library' version '8.11.1' apply false + id 'org.jetbrains.kotlin.android' version '1.9.25' apply false } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d3174a0..3d46a87 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jun 07 16:35:15 CST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists