diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..836dd9d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "com.kiwii.controlpanel" + compileSdk { + version = release(35) + } + + defaultConfig { + applicationId = "com.kiwii.controlpanel" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + buildFeatures { + viewBinding = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain(17) + } +} + +dependencies { + implementation(files("libs/unity-bridge.aar")) + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") + implementation("androidx.navigation:navigation-ui-ktx:2.7.6") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + implementation("com.google.android.material:material:1.11.0") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") +} diff --git a/app/libs/unity-bridge.aar b/app/libs/unity-bridge.aar new file mode 100644 index 0000000..df71e2f Binary files /dev/null and b/app/libs/unity-bridge.aar differ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..fc8ddbf --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/kiwii/controlpanel/App.kt b/app/src/main/java/com/kiwii/controlpanel/App.kt new file mode 100644 index 0000000..3042428 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/App.kt @@ -0,0 +1,11 @@ +package com.kiwii.controlpanel + +import android.app.Application +import com.kiwii.controlpanel.logging.SessionLogger + +class App : Application() { + override fun onCreate() { + super.onCreate() + SessionLogger.init(this) + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/MainActivity.kt b/app/src/main/java/com/kiwii/controlpanel/MainActivity.kt new file mode 100644 index 0000000..4c2fddd --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/MainActivity.kt @@ -0,0 +1,61 @@ +package com.kiwii.controlpanel + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.ui.setupActionBarWithNavController +import com.kiwii.bridge.KiwiiRuntimeClientBridge +import com.kiwii.controlpanel.databinding.ActivityMainBinding +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.logging.SessionLogger + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + val navController = navHostFragment.navController + setupActionBarWithNavController(navController) + + autoBind() + } + + private fun autoBind() { + val result = KiwiiRuntimeClientBridge.bind(this) + SessionLogger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = null, + action = "auto_bind", + params = null, + result = "bind=$result", + isStateChange = true + )) + } + + override fun onDestroy() { + super.onDestroy() + if (KiwiiRuntimeClientBridge.isBound()) { + KiwiiRuntimeClientBridge.unbind() + SessionLogger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = null, + action = "auto_unbind", + params = null, + result = "Activity destroyed", + isStateChange = true + )) + } + } + + override fun onSupportNavigateUp(): Boolean { + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment + return navHostFragment.navController.navigateUp() || super.onSupportNavigateUp() + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt new file mode 100644 index 0000000..72e955b --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/data/BridgeRepository.kt @@ -0,0 +1,67 @@ +package com.kiwii.controlpanel.data + +import android.app.Activity +import com.kiwii.bridge.KiwiiRuntimeClientBridge +import com.kiwii.controlpanel.model.OperationResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BridgeRepository { + + fun bind(activity: Activity): Boolean = KiwiiRuntimeClientBridge.bind(activity) + fun unbind() = KiwiiRuntimeClientBridge.unbind() + fun isBound(): Boolean = KiwiiRuntimeClientBridge.isBound() + fun ping(): String = KiwiiRuntimeClientBridge.ping() + + fun getRuntimeStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeStateJson() + fun getHealthStateJson(): String = KiwiiRuntimeClientBridge.getHealthStateJson() + fun getDebugSnapshotJson(): String = KiwiiRuntimeClientBridge.getDebugSnapshotJson() + fun getRuntimeLifecycleStateJson(): String = KiwiiRuntimeClientBridge.getRuntimeLifecycleStateJson() + fun requestRuntimeWarmStart(reason: String = ""): String = KiwiiRuntimeClientBridge.requestRuntimeWarmStart(reason) + + fun getCameraStateJson(): String = KiwiiRuntimeClientBridge.getCameraStateJson() + fun getPerceptionStateJson(): String = KiwiiRuntimeClientBridge.getPerceptionStateJson() + fun getLatestPose2D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose2D() + fun getLatestPose3D(): FloatArray = KiwiiRuntimeClientBridge.getLatestPose3D() + fun getLatestRuntimeStats(): FloatArray = KiwiiRuntimeClientBridge.getLatestRuntimeStats() + fun getLatestPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestPoseFrameJson() + fun getLatestBodyPoseFrameJson(): String = KiwiiRuntimeClientBridge.getLatestBodyPoseFrameJson() + + fun getLeftHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getLeftHandleIMULatest() + fun getLeftHandleIMUQueue(): Array = KiwiiRuntimeClientBridge.getLeftHandleIMUQueue() + fun submitLeftStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitLeftStaticEffect(effectId) + fun submitLeftHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitLeftHeStream(streamId, payload) + + fun getRightHandleIMULatest(): FloatArray = KiwiiRuntimeClientBridge.getRightHandleIMULatest() + fun getRightHandleIMUQueue(): Array = KiwiiRuntimeClientBridge.getRightHandleIMUQueue() + fun submitRightStaticEffect(effectId: Int): String = KiwiiRuntimeClientBridge.submitRightStaticEffect(effectId) + fun submitRightHeStream(streamId: Int, payload: String): String = KiwiiRuntimeClientBridge.submitRightHeStream(streamId, payload) + + fun getHandleStateJson(): String = KiwiiRuntimeClientBridge.getHandleStateJson() + + fun setMotorWeightKg(weight: Float): com.kiwii.bridge.MotorCommandResult = KiwiiRuntimeClientBridge.setMotorWeightKg(weight) + fun setMotorTrainingMode(mode: Int, param: Float): String = KiwiiRuntimeClientBridge.setMotorTrainingMode(mode, param) + fun queryMotorTrainingMode(axis: Int): String = KiwiiRuntimeClientBridge.queryMotorTrainingMode(axis) + fun getLatestMotorStateJson(): String = KiwiiRuntimeClientBridge.getLatestMotorStateJson() + fun getMotorControlStateJson(): String = KiwiiRuntimeClientBridge.getMotorControlStateJson() + + fun getTelemetryStateJson(): String = KiwiiRuntimeClientBridge.getTelemetryStateJson() + fun getSafetyStateJson(): String = KiwiiRuntimeClientBridge.getSafetyStateJson() + fun dryRunSafetyGate(command: String): String = KiwiiRuntimeClientBridge.dryRunSafetyGate(command) + fun submitDeviceCommandDryRun(command: String): String = KiwiiRuntimeClientBridge.submitDeviceCommandDryRun(command) + fun getDeviceCommandStateJson(): String = KiwiiRuntimeClientBridge.getDeviceCommandStateJson() + + suspend fun callAsync(block: () -> T): OperationResult = withContext(Dispatchers.IO) { + try { + val result = block() + if (result == null || (result is String && result.isBlank())) { + @Suppress("UNCHECKED_CAST") + OperationResult.NoData as OperationResult + } else { + OperationResult.Success(result) + } + } catch (e: Exception) { + OperationResult.Error(e) + } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt new file mode 100644 index 0000000..2150080 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/data/ComponentRegistry.kt @@ -0,0 +1,192 @@ +package com.kiwii.controlpanel.data + +import android.util.Log +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.logging.SessionLogger +import com.kiwii.controlpanel.model.ComponentCategory +import com.kiwii.controlpanel.model.ComponentState +import com.kiwii.controlpanel.model.ComponentType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.json.JSONObject + +class ComponentRegistry( + private val repo: BridgeRepository, + private val scope: CoroutineScope, + private val logger: SessionLogger +) { + private val _availability = MutableStateFlow>(emptyMap()) + val availability: StateFlow> = _availability + + private var previousStates = emptyMap() + + fun startDetection() { + scope.launch(Dispatchers.IO) { + while (isActive) { + val result = ComponentType.entries.associateWith { checkAvailability(it) } + // 检测状态变化,记录日志 + logStateChanges(previousStates, result) + previousStates = result + _availability.value = result + delay(1000L) + } + } + } + + private fun logStateChanges( + old: Map, + new: Map + ) { + if (old.isEmpty()) return + for ((type, newState) in new) { + val oldState = old[type] ?: continue + if (oldState != newState) { + logger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = type, + action = "availability_change", + params = null, + result = "${oldState.name} -> ${newState.name}", + isStateChange = true + )) + } + } + } + + private fun checkAvailability(type: ComponentType): ComponentState { + dumpDebugOnce() + return when (type.category) { + ComponentCategory.ALWAYS_READY -> ComponentState.ACTIVE + ComponentCategory.RUNTIME_HOST -> checkRuntimeHostComponent(type) + ComponentCategory.PERIPHERAL -> checkPeripheral(type) + } + } + + private fun checkRuntimeHostComponent(type: ComponentType): ComponentState { + // RuntimeHostStatus 始终可交互(Bind 按钮需在未绑定时可用) + if (type == ComponentType.RUNTIME_HOST_STATUS) return ComponentState.ACTIVE + if (!repo.isBound()) return ComponentState.GREY + return when (type) { + ComponentType.CAMERA -> checkCamera() + ComponentType.TELEMETRY_SAFETY -> ComponentState.ACTIVE + else -> ComponentState.ACTIVE + } + } + + private fun checkPeripheral(type: ComponentType): ComponentState { + if (!repo.isBound()) return ComponentState.GREY + return when (type) { + ComponentType.LEFT_HANDLE -> checkHandle("left") + ComponentType.RIGHT_HANDLE -> checkHandle("right") + ComponentType.BALANCE_BOARD -> checkBalanceBoard() + ComponentType.DONGLE -> checkDongle() + ComponentType.MOTOR -> checkMotor() + else -> ComponentState.GREY + } + } + + private var debugDumped = false + + private fun dumpDebugOnce() { + if (debugDumped || !repo.isBound()) return + debugDumped = true + val apis = mapOf( + "getCameraStateJson" to runCatching { repo.getCameraStateJson() }.getOrDefault("EXCEPTION"), + "getLatestMotorStateJson" to runCatching { repo.getLatestMotorStateJson() }.getOrDefault("EXCEPTION"), + "getDebugSnapshotJson(first200)" to runCatching { repo.getDebugSnapshotJson().take(200) }.getOrDefault("EXCEPTION"), + "getHandleStateJson" to runCatching { repo.getHandleStateJson() }.getOrDefault("EXCEPTION"), + ) + for ((name, value) in apis) { + logger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = null, + action = "DEBUG_DUMP", + params = name, + result = value, + isStateChange = false + )) + } + } + + private fun checkCamera(): ComponentState { + return try { + val json = repo.getCameraStateJson() + if (isUnavailable(json)) ComponentState.GREY else ComponentState.ACTIVE + } catch (_: Exception) { + ComponentState.GREY + } + } + + private fun checkHandle(side: String): ComponentState { + return try { + val json = repo.getHandleStateJson() + val obj = JSONObject(json) + val sideObj = obj.optJSONObject(side) ?: return ComponentState.GREY + // handle-state.v1: check available AND connected + val available = sideObj.optBoolean("available", false) + val connected = sideObj.optBoolean("connected", false) + if (available || connected) ComponentState.ACTIVE else ComponentState.GREY + } catch (_: Exception) { + ComponentState.GREY + } + } + + private fun checkBalanceBoard(): ComponentState { + // BalanceBoard 没有专用 API,暂时用 debugSnapshot 判断 + // TODO: 确认 RuntimeHost debug snapshot 中 balance board 状态字段 + return try { + val json = repo.getDebugSnapshotJson() + if (isUnavailable(json)) return ComponentState.GREY + val obj = JSONObject(json) + val bb = obj.optJSONObject("balanceBoard") ?: obj.optJSONObject("balance_board") + if (bb == null) ComponentState.GREY + else if (bb.optBoolean("available", false) || bb.optBoolean("connected", false)) ComponentState.ACTIVE + else ComponentState.GREY + } catch (_: Exception) { + ComponentState.GREY + } + } + + private fun checkDongle(): ComponentState { + return try { + // Dongle 是 USB 外设,状态在 getDebugSnapshotJson 的全量数据中 + val json = repo.getDebugSnapshotJson() + if (isUnavailable(json)) return ComponentState.GREY + // dongleState 字段存在说明 Dongle USB 已连接并被 RuntimeHost 识别 + if (json.contains("\"dongleState\"")) { + ComponentState.ACTIVE + } else { + ComponentState.GREY + } + } catch (_: Exception) { + ComponentState.GREY + } + } + + private fun checkMotor(): ComponentState { + return try { + val json = repo.getLatestMotorStateJson() + if (isUnavailable(json)) return ComponentState.GREY + // motor-runtime-state.v1 has top-level "available" field + val obj = JSONObject(json) + if (obj.optBoolean("available", false)) ComponentState.ACTIVE else ComponentState.GREY + } catch (_: Exception) { + ComponentState.GREY + } + } + + /** + * 通用不可用判断:含 error 字段、空 JSON、或 NOT_BOUND + */ + private fun isUnavailable(json: String): Boolean { + if (json.isBlank() || json == "{}") return true + if (json.contains("\"error\"")) return true + if (json.contains("NOT_BOUND")) return true + return false + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateRepository.kt b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateRepository.kt new file mode 100644 index 0000000..12197dd --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateRepository.kt @@ -0,0 +1,14 @@ +package com.kiwii.controlpanel.data + +import com.kiwii.controlpanel.model.ComponentType + +class RuntimeStateRepository(private var source: RuntimeStateSource) { + + fun getComponentState(type: ComponentType): String = source.getState(type) + + fun isUsingDebugChannel(): Boolean = source.isDebugChannelAvailable() + + fun switchSource(newSource: RuntimeStateSource) { + source = newSource + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt new file mode 100644 index 0000000..135e9cb --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/data/RuntimeStateSource.kt @@ -0,0 +1,24 @@ +package com.kiwii.controlpanel.data + +import com.kiwii.controlpanel.model.ComponentType + +interface RuntimeStateSource { + fun getState(component: ComponentType): String + fun isDebugChannelAvailable(): Boolean +} + +class AarProxyStateSource(private val bridge: BridgeRepository) : RuntimeStateSource { + + override fun getState(component: ComponentType): String = when (component) { + ComponentType.RUNTIME_HOST_STATUS -> bridge.getRuntimeStateJson() + ComponentType.CAMERA -> bridge.getCameraStateJson() + ComponentType.LEFT_HANDLE, ComponentType.RIGHT_HANDLE -> bridge.getHandleStateJson() + ComponentType.MOTOR -> bridge.getLatestMotorStateJson() + ComponentType.BALANCE_BOARD -> bridge.getDebugSnapshotJson() + ComponentType.DONGLE -> bridge.getDebugSnapshotJson() + ComponentType.TELEMETRY_SAFETY -> bridge.getTelemetryStateJson() + ComponentType.SESSION_LOG -> "{}" + } + + override fun isDebugChannelAvailable(): Boolean = false +} diff --git a/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt b/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt new file mode 100644 index 0000000..2aee635 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/data/StatePoller.kt @@ -0,0 +1,107 @@ +package com.kiwii.controlpanel.data + +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.logging.SessionLogger +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.model.StateSnapshot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class StatePoller( + private val runtimeStateRepo: RuntimeStateRepository, + private val bridgeRepo: BridgeRepository, + private val logger: SessionLogger +) { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val _states = MutableStateFlow>(emptyMap()) + val states: StateFlow> = _states + + private val pollIntervalMs = 1000L + private var wasBound = false + + fun startPolling() { + scope.launch { + while (isActive) { + val isBound = bridgeRepo.isBound() + if (isBound) { + if (!wasBound) { + logger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = null, + action = "connection", + params = null, + result = "BOUND", + isStateChange = true + )) + } + val snapshot = pollAllStates() + _states.value = snapshot + } else { + if (wasBound) { + _states.value = emptyMap() + logger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = null, + action = "connection", + params = null, + result = "DISCONNECTED - states cleared", + isStateChange = true + )) + } + } + wasBound = isBound + delay(pollIntervalMs) + } + } + } + + private fun pollAllStates(): Map { + val now = System.currentTimeMillis() + val polledTypes = listOf( + ComponentType.RUNTIME_HOST_STATUS, + ComponentType.CAMERA, + ComponentType.LEFT_HANDLE, + ComponentType.RIGHT_HANDLE, + ComponentType.BALANCE_BOARD, + ComponentType.DONGLE, + ComponentType.MOTOR, + ComponentType.TELEMETRY_SAFETY + ) + return polledTypes.mapNotNull { type -> + try { + type to StateSnapshot( + data = runtimeStateRepo.getComponentState(type), + updatedAt = now, + isDebugChannel = runtimeStateRepo.isUsingDebugChannel() + ) + } catch (e: Exception) { + // 异常必记 + logger.log(LogEntry( + timestamp = now, + component = type, + action = "state_poll_error", + params = null, + result = "${e.javaClass.simpleName}: ${e.message}", + isStateChange = false + )) + type to StateSnapshot( + data = "{\"error\":\"${e.message}\"}", + updatedAt = now, + isDebugChannel = runtimeStateRepo.isUsingDebugChannel(), + hasError = true + ) + } + }.toMap() + } + + fun stopPolling() { + scope.cancel() + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/logging/LogEntry.kt b/app/src/main/java/com/kiwii/controlpanel/logging/LogEntry.kt new file mode 100644 index 0000000..42160f7 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/logging/LogEntry.kt @@ -0,0 +1,19 @@ +package com.kiwii.controlpanel.logging + +import com.kiwii.controlpanel.model.ComponentType + +data class LogEntry( + val timestamp: Long, + val component: ComponentType?, + val action: String, + val params: String?, + val result: String?, + val isStateChange: Boolean = false +) { + fun toLogLine(): String { + val comp = component?.displayName ?: "GLOBAL" + val p = params ?: "" + val r = result ?: "" + return "$timestamp|$comp|$action|$p|$r|$isStateChange" + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/logging/SessionLogger.kt b/app/src/main/java/com/kiwii/controlpanel/logging/SessionLogger.kt new file mode 100644 index 0000000..01d6e99 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/logging/SessionLogger.kt @@ -0,0 +1,57 @@ +package com.kiwii.controlpanel.logging + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import com.kiwii.controlpanel.model.ComponentType +import java.io.BufferedWriter +import java.io.File + +object SessionLogger { + + private val entries = mutableListOf() + private var fileWriter: BufferedWriter? = null + private var logFile: File? = null + + fun init(context: Context) { + val timestamp = System.currentTimeMillis() + val file = File(context.cacheDir, "session_$timestamp.log") + logFile = file + fileWriter = file.bufferedWriter() + } + + fun log(entry: LogEntry) { + synchronized(this) { + entries.add(entry) + fileWriter?.appendLine(entry.toLogLine()) + fileWriter?.flush() + } + } + + fun getEntries(): List { + synchronized(this) { + return entries.toList() + } + } + + fun getEntriesForComponent(type: ComponentType): List { + synchronized(this) { + return entries.filter { it.component == type } + } + } + + fun clear() { + synchronized(this) { + entries.clear() + } + } + + fun export(context: Context): Uri? { + val file = logFile ?: return null + val exportDir = File(context.getExternalFilesDir(null), "logs") + exportDir.mkdirs() + val exportFile = File(exportDir, file.name) + file.copyTo(exportFile, overwrite = true) + return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", exportFile) + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/model/ComponentState.kt b/app/src/main/java/com/kiwii/controlpanel/model/ComponentState.kt new file mode 100644 index 0000000..d760a79 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/model/ComponentState.kt @@ -0,0 +1,6 @@ +package com.kiwii.controlpanel.model + +enum class ComponentState { + ACTIVE, + GREY +} diff --git a/app/src/main/java/com/kiwii/controlpanel/model/ComponentType.kt b/app/src/main/java/com/kiwii/controlpanel/model/ComponentType.kt new file mode 100644 index 0000000..d11fd0f --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/model/ComponentType.kt @@ -0,0 +1,22 @@ +package com.kiwii.controlpanel.model + +enum class ComponentCategory { + ALWAYS_READY, + RUNTIME_HOST, + PERIPHERAL +} + +enum class ComponentType( + val displayName: String, + val category: ComponentCategory +) { + RUNTIME_HOST_STATUS("RuntimeHost Status", ComponentCategory.RUNTIME_HOST), + CAMERA("Camera", ComponentCategory.RUNTIME_HOST), + LEFT_HANDLE("Left Handle", ComponentCategory.PERIPHERAL), + RIGHT_HANDLE("Right Handle", ComponentCategory.PERIPHERAL), + BALANCE_BOARD("Balance Board", ComponentCategory.PERIPHERAL), + DONGLE("Dongle", ComponentCategory.PERIPHERAL), + MOTOR("Motor", ComponentCategory.PERIPHERAL), + TELEMETRY_SAFETY("Telemetry & Safety", ComponentCategory.RUNTIME_HOST), + SESSION_LOG("Session Log", ComponentCategory.ALWAYS_READY) +} diff --git a/app/src/main/java/com/kiwii/controlpanel/model/OperationResult.kt b/app/src/main/java/com/kiwii/controlpanel/model/OperationResult.kt new file mode 100644 index 0000000..e591576 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/model/OperationResult.kt @@ -0,0 +1,8 @@ +package com.kiwii.controlpanel.model + +sealed class OperationResult { + data class Success(val data: T) : OperationResult() + data class Error(val exception: Exception) : OperationResult() + data class NotBound(val defaultValue: String = "{\"error\":\"NOT_BOUND\"}") : OperationResult() + data object NoData : OperationResult() +} diff --git a/app/src/main/java/com/kiwii/controlpanel/model/StateSnapshot.kt b/app/src/main/java/com/kiwii/controlpanel/model/StateSnapshot.kt new file mode 100644 index 0000000..4f5f3e5 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/model/StateSnapshot.kt @@ -0,0 +1,8 @@ +package com.kiwii.controlpanel.model + +data class StateSnapshot( + val data: String, + val updatedAt: Long, + val isDebugChannel: Boolean, + val hasError: Boolean = false +) diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/FlowLayout.kt b/app/src/main/java/com/kiwii/controlpanel/ui/FlowLayout.kt new file mode 100644 index 0000000..dafe42d --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/FlowLayout.kt @@ -0,0 +1,78 @@ +package com.kiwii.controlpanel.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup + +class FlowLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : ViewGroup(context, attrs) { + + class LayoutParams : MarginLayoutParams { + constructor(width: Int, height: Int) : super(width, height) + constructor(source: MarginLayoutParams) : super(source) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + } + + override fun generateDefaultLayoutParams() = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + override fun generateLayoutParams(attrs: AttributeSet?) = LayoutParams(context, attrs) + override fun generateLayoutParams(p: ViewGroup.LayoutParams?) = LayoutParams(MarginLayoutParams(p)) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + var x = paddingLeft + var y = paddingTop + var rowHeight = 0 + + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child.visibility == View.GONE) continue + measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0) + val lp = child.layoutParams as LayoutParams + val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin + val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin + + if (x + childWidth > maxWidth - paddingRight) { + x = paddingLeft + y += rowHeight + rowHeight = 0 + } + x += childWidth + rowHeight = maxOf(rowHeight, childHeight) + } + + setMeasuredDimension(maxWidth, y + rowHeight + paddingBottom) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + val maxWidth = r - l + var x = paddingLeft + var y = paddingTop + var rowHeight = 0 + + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child.visibility == View.GONE) continue + val lp = child.layoutParams as LayoutParams + val childWidth = child.measuredWidth + lp.leftMargin + lp.rightMargin + val childHeight = child.measuredHeight + lp.topMargin + lp.bottomMargin + + if (x + childWidth > maxWidth - paddingRight) { + x = paddingLeft + y += rowHeight + rowHeight = 0 + } + + child.layout( + x + lp.leftMargin, + y + lp.topMargin, + x + lp.leftMargin + child.measuredWidth, + y + lp.topMargin + child.measuredHeight + ) + x += child.measuredWidth + lp.leftMargin + lp.rightMargin + rowHeight = maxOf(rowHeight, childHeight) + } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt b/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt new file mode 100644 index 0000000..a370830 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/adapter/ComponentCardAdapter.kt @@ -0,0 +1,71 @@ +package com.kiwii.controlpanel.ui.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.databinding.ItemComponentCardBinding +import com.kiwii.controlpanel.model.ComponentCategory +import com.kiwii.controlpanel.model.ComponentState +import com.kiwii.controlpanel.model.ComponentType + +class ComponentCardAdapter( + private val onClick: (ComponentType) -> Unit +) : ListAdapter, ComponentCardAdapter.ViewHolder>(DIFF) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemComponentCardBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val (type, state) = getItem(position) + holder.bind(type, state) + } + + inner class ViewHolder(private val binding: ItemComponentCardBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(type: ComponentType, state: ComponentState) { + binding.tvComponentName.text = type.displayName + binding.tvStatusSummary.text = when (state) { + ComponentState.ACTIVE -> "Available" + ComponentState.GREY -> when (type.category) { + ComponentCategory.ALWAYS_READY -> "Ready" + ComponentCategory.RUNTIME_HOST -> "Not Bound" + ComponentCategory.PERIPHERAL -> "Not Connected" + } + } + + val color = when (state) { + ComponentState.ACTIVE -> R.color.rhs_success + ComponentState.GREY -> R.color.rhs_timestamp + } + binding.viewStateIndicator.setBackgroundColor( + ContextCompat.getColor(binding.root.context, color) + ) + + binding.root.alpha = if (state == ComponentState.GREY) 0.6f else 1.0f + binding.root.setOnClickListener { onClick(type) } + } + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback>() { + override fun areItemsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem.first == newItem.first + + override fun areContentsTheSame( + oldItem: Pair, + newItem: Pair + ) = oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/BalanceBoardOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/BalanceBoardOps.kt new file mode 100644 index 0000000..d6f2aaa --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/BalanceBoardOps.kt @@ -0,0 +1,21 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.kiwii.controlpanel.R + +class BalanceBoardOps(context: Context) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + layout.addView(TextView(context).apply { + text = "暂无 SDK API" + textSize = 12f + setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle)) + setPadding(0, 32, 0, 32) + }) + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/BaseOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/BaseOps.kt new file mode 100644 index 0000000..541b60c --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/BaseOps.kt @@ -0,0 +1,226 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.graphics.Typeface +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.kiwii.controlpanel.R + +abstract class BaseOps(protected val context: Context) { + + abstract fun createView(): View + + protected fun createButton(text: String, onClick: () -> Unit): View { + val title = text.toReadableTitle() + return LinearLayout(context).apply { + tag = TAG_CONTROL_CARD + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + isClickable = true + isFocusable = true + foreground = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground)).use { + it.getDrawable(0) + } + setBackgroundResource(R.drawable.bg_rhs_btn_query) + setPadding(8.dp(), 8.dp(), 8.dp(), 8.dp()) + + addView(TextView(context).apply { + this.text = title + textSize = 13f + gravity = Gravity.CENTER + typeface = Typeface.DEFAULT_BOLD + setTextColor(ContextCompat.getColor(context, R.color.rhs_btn_title)) + }) + addView(TextView(context).apply { + this.text = text + textSize = 9f + gravity = Gravity.CENTER + setTextColor(ContextCompat.getColor(context, R.color.rhs_btn_subtitle)) + }) + + setOnClickListener { onClick() } + } + } + + protected fun createSection(title: String): TextView { + return TextView(context).apply { + this.text = title + textSize = 12f + setTextColor(ContextCompat.getColor(context, R.color.rhs_title)) + setPadding(0, 8.dp(), 0, 4.dp()) + setTypeface(null, Typeface.BOLD) + } + } + + protected fun createParameterInput(hint: String, inputType: Int): EditText { + return EditText(context).apply { + tag = TAG_PARAM_INPUT + this.hint = hint + this.inputType = inputType + textSize = 11f + setSingleLine(true) + setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value)) + setHintTextColor(ContextCompat.getColor(context, R.color.rhs_timestamp)) + setPadding(6.dp(), 0, 6.dp(), 0) + minHeight = 36.dp() + } + } + + protected fun createParameterRow(vararg children: View): LinearLayout { + return LinearLayout(context).apply { + tag = TAG_PARAM_ROW + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setBackgroundResource(R.drawable.bg_rhs_log_area) + setPadding(4.dp(), 4.dp(), 4.dp(), 4.dp()) + children.forEach { child -> + addView(child, LinearLayout.LayoutParams(0, 40.dp(), 1f).apply { + setMargins(3.dp(), 0, 3.dp(), 0) + }) + } + } + } + + protected fun createParameterActionRow(action: View, vararg parameters: View): LinearLayout { + return LinearLayout(context).apply { + tag = TAG_PARAM_ACTION_ROW + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + + // 输入密集操作需要和右侧控制卡保持同一视觉节奏,避免输入区变成大块空白面板。 + addView(createParameterRow(*parameters), LinearLayout.LayoutParams(0, PARAM_ACTION_ROW_HEIGHT_DP.dp(), 2f).apply { + setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp()) + }) + addView(action, LinearLayout.LayoutParams(0, PARAM_ACTION_ROW_HEIGHT_DP.dp(), 1f).apply { + setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp()) + }) + } + } + + protected fun verticalLayout(): LinearLayout { + return OpsGridLayout(context).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, 0, 0, 0) + } + } + + private fun fullSpanParams(): LinearLayout.LayoutParams { + return LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ).apply { + setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp()) + } + } + + private fun controlCardParams(): LinearLayout.LayoutParams { + return LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f).apply { + setMargins(3.dp(), 3.dp(), 3.dp(), 3.dp()) + } + } + + private inner class OpsGridLayout(context: Context) : LinearLayout(context) { + private var currentRow: LinearLayout? = null + private var currentColumn = 0 + + override fun addView(child: View?, index: Int, params: ViewGroup.LayoutParams?) { + if (child == null) { + super.addView(null, index, params) + return + } + + if (child.tag == TAG_CONTROL_CARD) { + val row = currentRow ?: createControlRow().also { currentRow = it } + row.addView(child, controlCardParams()) + currentColumn += 1 + if (currentColumn == CONTROL_COLUMNS) { + currentRow = null + currentColumn = 0 + } + } else { + finishPartialRow() + styleFullSpanChild(child) + super.addView(child, index, params ?: fullSpanParams()) + } + } + + private fun createControlRow(): LinearLayout { + return LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + minimumHeight = 96.dp() + super.addView(this, LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + 0, + 1f + )) + } + } + + private fun finishPartialRow() { + val row = currentRow ?: return + while (currentColumn < CONTROL_COLUMNS) { + row.addView(View(context), controlCardParams()) + currentColumn += 1 + } + currentRow = null + currentColumn = 0 + } + + private fun styleFullSpanChild(child: View) { + when (child) { + is EditText -> { + child.textSize = 12f + child.setSingleLine(false) + child.minLines = 1 + child.maxLines = 2 + child.setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value)) + child.setHintTextColor(ContextCompat.getColor(context, R.color.rhs_timestamp)) + child.setPadding(6.dp(), 4.dp(), 6.dp(), 4.dp()) + } + is Spinner -> { + child.minimumHeight = 40.dp() + } + is LinearLayout -> { + if (child.tag == TAG_PARAM_ROW || child.tag == TAG_PARAM_ACTION_ROW) { + child.minimumHeight = 48.dp() + } + } + } + } + } + + private fun String.toReadableTitle(): String { + return replace(Regex("^(get|set|submit|request|query|dryRun)"), "") + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .replace(Regex("Json$"), "") + .trim() + .ifEmpty { this } + } + + private fun Int.dp(): Int { + return (this * context.resources.displayMetrics.density).toInt() + } + + private inline fun android.content.res.TypedArray.use(block: (android.content.res.TypedArray) -> T): T { + return try { + block(this) + } finally { + recycle() + } + } + + companion object { + private const val CONTROL_COLUMNS = 3 + private const val PARAM_ACTION_ROW_HEIGHT_DP = 88 + private const val TAG_CONTROL_CARD = "control_card" + private const val TAG_PARAM_INPUT = "param_input" + private const val TAG_PARAM_ROW = "param_row" + private const val TAG_PARAM_ACTION_ROW = "param_action_row" + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt new file mode 100644 index 0000000..58ed159 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/CameraOps.kt @@ -0,0 +1,66 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel + +class CameraOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + // 预览占位 + layout.addView(TextView(context).apply { + text = "Preview: Pending RuntimeHost debug channel" + textSize = 12f + setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle)) + setPadding(0, 0, 0, 16) + }) + + layout.addView(createSection("SDK Operations")) + layout.addView(createButton("getCameraStateJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getCameraStateJson") { + viewModel.bridgeRepo.getCameraStateJson() + } + }) + layout.addView(createButton("getPerceptionStateJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getPerceptionStateJson") { + viewModel.bridgeRepo.getPerceptionStateJson() + } + }) + layout.addView(createButton("getLatestPose2D") { + viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose2D") { + viewModel.bridgeRepo.getLatestPose2D() + } + }) + layout.addView(createButton("getLatestPose3D") { + viewModel.executeOperation(ComponentType.CAMERA, "getLatestPose3D") { + viewModel.bridgeRepo.getLatestPose3D() + } + }) + layout.addView(createButton("getLatestRuntimeStats") { + viewModel.executeOperation(ComponentType.CAMERA, "getLatestRuntimeStats") { + viewModel.bridgeRepo.getLatestRuntimeStats() + } + }) + layout.addView(createButton("getLatestPoseFrameJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getLatestPoseFrameJson") { + viewModel.bridgeRepo.getLatestPoseFrameJson() + } + }) + layout.addView(createButton("getLatestBodyPoseFrameJson") { + viewModel.executeOperation(ComponentType.CAMERA, "getLatestBodyPoseFrameJson") { + viewModel.bridgeRepo.getLatestBodyPoseFrameJson() + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt new file mode 100644 index 0000000..91ea41d --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/DongleOps.kt @@ -0,0 +1,21 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.kiwii.controlpanel.R + +class DongleOps(context: Context) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + layout.addView(TextView(context).apply { + text = "暂无 SDK API" + textSize = 12f + setTextColor(ContextCompat.getColor(context, R.color.rhs_subtitle)) + setPadding(0, 32, 0, 32) + }) + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/LeftHandleOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/LeftHandleOps.kt new file mode 100644 index 0000000..6c0358f --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/LeftHandleOps.kt @@ -0,0 +1,63 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel +import com.kiwii.controlpanel.util.ImuParser + +class LeftHandleOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + layout.addView(createSection("IMU")) + layout.addView(createButton("getLeftHandleIMULatest") { + viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getLeftHandleIMULatest") { + val data = viewModel.bridgeRepo.getLeftHandleIMULatest() + val frame = ImuParser.parse(data) + frame?.let { ImuParser.formatFrame(it) } ?: "No Data" + } + }) + layout.addView(createButton("getLeftHandleIMUQueue") { + viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getLeftHandleIMUQueue") { + val queue = viewModel.bridgeRepo.getLeftHandleIMUQueue() + val frames = ImuParser.parseQueue(queue) + frames.joinToString("\n") { ImuParser.formatFrame(it) }.ifEmpty { "No Data" } + } + }) + + layout.addView(createSection("Haptics")) + val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER) + val staticEffectAction = createButton("submitLeftStaticEffect") { + val id = effectInput.text.toString().toIntOrNull() ?: 0 + viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftStaticEffect", "effectId=$id") { + viewModel.bridgeRepo.submitLeftStaticEffect(id) + } + } + layout.addView(createParameterActionRow(staticEffectAction, effectInput)) + + val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER) + val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT) + val heStreamAction = createButton("submitLeftHeStream") { + val sid = streamIdInput.text.toString().toIntOrNull() ?: 0 + val payload = payloadInput.text.toString() + viewModel.executeOperation(ComponentType.LEFT_HANDLE, "submitLeftHeStream", "streamId=$sid,payload=$payload") { + viewModel.bridgeRepo.submitLeftHeStream(sid, payload) + } + } + layout.addView(createParameterActionRow(heStreamAction, streamIdInput, payloadInput)) + + layout.addView(createSection("Handle State")) + layout.addView(createButton("getHandleStateJson") { + viewModel.executeOperation(ComponentType.LEFT_HANDLE, "getHandleStateJson") { + viewModel.bridgeRepo.getHandleStateJson() + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt new file mode 100644 index 0000000..6cf8865 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/MotorOps.kt @@ -0,0 +1,76 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import android.widget.ArrayAdapter +import android.widget.Spinner +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel + +class MotorOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + private val trainingModes = arrayOf( + "NONE (0)", "FREE_WEIGHT (1)", "ECCENTRIC_OVERLOAD (2)", + "VISCOUS (3)", "ELASTIC (4)", "ISOKINETIC_SPOTTING (5)", "SPOTTER (6)" + ) + + override fun createView(): View { + val layout = verticalLayout() + + layout.addView(createSection("Weight")) + val weightInput = createParameterInput( + "Weight (kg)", + android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL + ) + val weightAction = createButton("setMotorWeightKg") { + val weight = weightInput.text.toString().toFloatOrNull() ?: 0f + viewModel.executeOperation(ComponentType.MOTOR, "setMotorWeightKg", "weight=$weight") { + viewModel.bridgeRepo.setMotorWeightKg(weight) + } + } + layout.addView(createParameterActionRow(weightAction, weightInput)) + + layout.addView(createSection("Training Mode")) + val modeSpinner = Spinner(context).apply { + adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, trainingModes) + } + val paramInput = createParameterInput( + "Param (float)", + android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL + ) + val trainingModeAction = createButton("setMotorTrainingMode") { + val mode = modeSpinner.selectedItemPosition + val param = paramInput.text.toString().toFloatOrNull() ?: 0f + viewModel.executeOperation(ComponentType.MOTOR, "setMotorTrainingMode", "mode=$mode,param=$param") { + viewModel.bridgeRepo.setMotorTrainingMode(mode, param) + } + } + layout.addView(createParameterActionRow(trainingModeAction, modeSpinner, paramInput)) + + val axisInput = createParameterInput("Axis (int)", android.text.InputType.TYPE_CLASS_NUMBER) + val queryModeAction = createButton("queryMotorTrainingMode") { + val axis = axisInput.text.toString().toIntOrNull() ?: 0 + viewModel.executeOperation(ComponentType.MOTOR, "queryMotorTrainingMode", "axis=$axis") { + viewModel.bridgeRepo.queryMotorTrainingMode(axis) + } + } + layout.addView(createParameterActionRow(queryModeAction, axisInput)) + + layout.addView(createSection("State Queries")) + layout.addView(createButton("getLatestMotorStateJson") { + viewModel.executeOperation(ComponentType.MOTOR, "getLatestMotorStateJson") { + viewModel.bridgeRepo.getLatestMotorStateJson() + } + }) + layout.addView(createButton("getMotorControlStateJson") { + viewModel.executeOperation(ComponentType.MOTOR, "getMotorControlStateJson") { + viewModel.bridgeRepo.getMotorControlStateJson() + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/RightHandleOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/RightHandleOps.kt new file mode 100644 index 0000000..ffa2b05 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/RightHandleOps.kt @@ -0,0 +1,63 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel +import com.kiwii.controlpanel.util.ImuParser + +class RightHandleOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + layout.addView(createSection("IMU")) + layout.addView(createButton("getRightHandleIMULatest") { + viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getRightHandleIMULatest") { + val data = viewModel.bridgeRepo.getRightHandleIMULatest() + val frame = ImuParser.parse(data) + frame?.let { ImuParser.formatFrame(it) } ?: "No Data" + } + }) + layout.addView(createButton("getRightHandleIMUQueue") { + viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getRightHandleIMUQueue") { + val queue = viewModel.bridgeRepo.getRightHandleIMUQueue() + val frames = ImuParser.parseQueue(queue) + frames.joinToString("\n") { ImuParser.formatFrame(it) }.ifEmpty { "No Data" } + } + }) + + layout.addView(createSection("Haptics")) + val effectInput = createParameterInput("Effect ID (int)", android.text.InputType.TYPE_CLASS_NUMBER) + val staticEffectAction = createButton("submitRightStaticEffect") { + val id = effectInput.text.toString().toIntOrNull() ?: 0 + viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightStaticEffect", "effectId=$id") { + viewModel.bridgeRepo.submitRightStaticEffect(id) + } + } + layout.addView(createParameterActionRow(staticEffectAction, effectInput)) + + val streamIdInput = createParameterInput("Stream ID (int)", android.text.InputType.TYPE_CLASS_NUMBER) + val payloadInput = createParameterInput("Payload (String)", android.text.InputType.TYPE_CLASS_TEXT) + val heStreamAction = createButton("submitRightHeStream") { + val sid = streamIdInput.text.toString().toIntOrNull() ?: 0 + val payload = payloadInput.text.toString() + viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "submitRightHeStream", "streamId=$sid,payload=$payload") { + viewModel.bridgeRepo.submitRightHeStream(sid, payload) + } + } + layout.addView(createParameterActionRow(heStreamAction, streamIdInput, payloadInput)) + + layout.addView(createSection("Handle State")) + layout.addView(createButton("getHandleStateJson") { + viewModel.executeOperation(ComponentType.RIGHT_HANDLE, "getHandleStateJson") { + viewModel.bridgeRepo.getHandleStateJson() + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/RuntimeHostStatusOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/RuntimeHostStatusOps.kt new file mode 100644 index 0000000..e0f57ba --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/RuntimeHostStatusOps.kt @@ -0,0 +1,64 @@ +package com.kiwii.controlpanel.ui.components + +import android.app.Activity +import android.content.Context +import android.view.View +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel + +class RuntimeHostStatusOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + layout.addView(createSection("Lifecycle")) + layout.addView(createButton("Bind") { + val activity = context as? Activity ?: return@createButton + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "bind") { + viewModel.bridgeRepo.bind(activity) + } + }) + layout.addView(createButton("Unbind") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "unbind") { + viewModel.bridgeRepo.unbind() + } + }) + layout.addView(createButton("Ping") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "ping") { + viewModel.bridgeRepo.ping() + } + }) + + layout.addView(createSection("State Queries")) + layout.addView(createButton("getRuntimeStateJson") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeStateJson") { + viewModel.bridgeRepo.getRuntimeStateJson() + } + }) + layout.addView(createButton("getHealthStateJson") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getHealthStateJson") { + viewModel.bridgeRepo.getHealthStateJson() + } + }) + layout.addView(createButton("getDebugSnapshotJson") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getDebugSnapshotJson") { + viewModel.bridgeRepo.getDebugSnapshotJson() + } + }) + layout.addView(createButton("getRuntimeLifecycleStateJson") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeLifecycleStateJson") { + viewModel.bridgeRepo.getRuntimeLifecycleStateJson() + } + }) + layout.addView(createButton("requestRuntimeWarmStart") { + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "requestRuntimeWarmStart") { + viewModel.bridgeRepo.requestRuntimeWarmStart() + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt new file mode 100644 index 0000000..b628458 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/SessionLogOps.kt @@ -0,0 +1,61 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.logging.SessionLogger +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class SessionLogOps(context: Context) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + val logView = TextView(context).apply { + textSize = 11f + typeface = android.graphics.Typeface.MONOSPACE + setTextColor(ContextCompat.getColor(context, R.color.rhs_stat_value)) + setPadding(0, 8, 0, 0) + } + + fun refreshLog() { + val entries = SessionLogger.getEntries().reversed() + val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + logView.text = entries.joinToString("\n") { entry -> + val time = sdf.format(Date(entry.timestamp)) + val comp = entry.component?.displayName ?: "GLOBAL" + "$time [$comp] ${entry.action}: ${entry.result?.take(60) ?: ""}" + }.ifEmpty { "No logs" } + } + + layout.addView(createButton("Refresh") { refreshLog() }) + layout.addView(createButton("Export") { + val uri = SessionLogger.export(context) + if (uri != null) { + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Export Log")) + } else { + Toast.makeText(context, "Export failed", Toast.LENGTH_SHORT).show() + } + }) + layout.addView(createButton("Clear") { + SessionLogger.clear() + refreshLog() + }) + + layout.addView(logView) + refreshLog() + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/components/TelemetrySafetyOps.kt b/app/src/main/java/com/kiwii/controlpanel/ui/components/TelemetrySafetyOps.kt new file mode 100644 index 0000000..502e15c --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/components/TelemetrySafetyOps.kt @@ -0,0 +1,51 @@ +package com.kiwii.controlpanel.ui.components + +import android.content.Context +import android.view.View +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.detail.ComponentDetailViewModel + +class TelemetrySafetyOps( + context: Context, + private val viewModel: ComponentDetailViewModel +) : BaseOps(context) { + + override fun createView(): View { + val layout = verticalLayout() + + layout.addView(createSection("State Queries")) + layout.addView(createButton("getTelemetryStateJson") { + viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getTelemetryStateJson") { + viewModel.bridgeRepo.getTelemetryStateJson() + } + }) + layout.addView(createButton("getSafetyStateJson") { + viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getSafetyStateJson") { + viewModel.bridgeRepo.getSafetyStateJson() + } + }) + layout.addView(createButton("getDeviceCommandStateJson") { + viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "getDeviceCommandStateJson") { + viewModel.bridgeRepo.getDeviceCommandStateJson() + } + }) + + layout.addView(createSection("Dry Run")) + val commandInput = createParameterInput("Command (JSON string)", android.text.InputType.TYPE_CLASS_TEXT) + val dryRunAction = createButton("dryRunSafetyGate") { + val cmd = commandInput.text.toString() + viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "dryRunSafetyGate", "command=$cmd") { + viewModel.bridgeRepo.dryRunSafetyGate(cmd) + } + } + layout.addView(createParameterActionRow(dryRunAction, commandInput)) + layout.addView(createButton("submitDeviceCommandDryRun") { + val cmd = commandInput.text.toString() + viewModel.executeOperation(ComponentType.TELEMETRY_SAFETY, "submitDeviceCommandDryRun", "command=$cmd") { + viewModel.bridgeRepo.submitDeviceCommandDryRun(cmd) + } + }) + + return layout + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt new file mode 100644 index 0000000..0479907 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailFragment.kt @@ -0,0 +1,225 @@ +package com.kiwii.controlpanel.ui.detail + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.MenuProvider +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.databinding.FragmentComponentDetailBinding +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.logging.SessionLogger +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.model.OperationResult +import com.kiwii.controlpanel.ui.components.* +import com.kiwii.controlpanel.util.JsonFormatter +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class ComponentDetailFragment : Fragment() { + + private var _binding: FragmentComponentDetailBinding? = null + private val binding get() = _binding!! + private val viewModel: ComponentDetailViewModel by viewModels() + + private lateinit var componentType: ComponentType + private lateinit var stateAdapter: StatAdapter + private lateinit var logAdapter: LogAdapter + private var latestStateText: String = "No state data" + private var latestResultText: String = "—" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + componentType = ComponentType.valueOf( + arguments?.getString("componentType") ?: ComponentType.RUNTIME_HOST_STATUS.name + ) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentComponentDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.setComponentType(componentType) + updateTitle() + + setupMenu() + setupStateAndLog() + loadOpsView() + observeViewModel() + } + + override fun onResume() { + super.onResume() + updateTitle() + } + + private fun updateTitle() { + (requireActivity() as? AppCompatActivity)?.supportActionBar?.title = componentType.displayName + requireActivity().title = componentType.displayName + } + + private fun setupMenu() { + requireActivity().addMenuProvider(object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_detail, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_record -> { + recordSnapshot() + true + } + else -> false + } + } + }, viewLifecycleOwner, Lifecycle.State.RESUMED) + } + + private fun setupStateAndLog() { + stateAdapter = StatAdapter() + // 通用设备状态字段比 RuntimeHost 更长,3 列能保留卡片摘要感,同时避免关键信息被过早截断。 + binding.rvStateStats.layoutManager = GridLayoutManager(requireContext(), 3) + binding.rvStateStats.adapter = stateAdapter + + logAdapter = LogAdapter() + binding.rvLogEntries.layoutManager = LinearLayoutManager(requireContext()) + binding.rvLogEntries.adapter = logAdapter + } + + private fun loadOpsView() { + val opsView = when (componentType) { + ComponentType.RUNTIME_HOST_STATUS -> RuntimeHostStatusOps(requireContext(), viewModel) + ComponentType.CAMERA -> CameraOps(requireContext(), viewModel) + ComponentType.LEFT_HANDLE -> LeftHandleOps(requireContext(), viewModel) + ComponentType.RIGHT_HANDLE -> RightHandleOps(requireContext(), viewModel) + ComponentType.BALANCE_BOARD -> BalanceBoardOps(requireContext()) + ComponentType.DONGLE -> DongleOps(requireContext()) + ComponentType.MOTOR -> MotorOps(requireContext(), viewModel) + ComponentType.TELEMETRY_SAFETY -> TelemetrySafetyOps(requireContext(), viewModel) + ComponentType.SESSION_LOG -> SessionLogOps(requireContext()) + }.createView() + + binding.opsContainer.removeAllViews() + binding.opsContainer.addView( + opsView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + + private fun observeViewModel() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { observeState() } + launch { observeResult() } + launch { observeLogs() } + } + } + } + + private suspend fun observeState() { + viewModel.stateSnapshot.collect { snapshot -> + if (snapshot != null) { + latestStateText = JsonFormatter.format(snapshot.data) + val items = RuntimeHostStatusParser.parse(snapshot.data, viewModel.bridgeRepo.isBound()) + .ifEmpty { listOf(StatItem("State", "Updated")) } + stateAdapter.submitList(items) + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + val source = if (snapshot.isDebugChannel) "[Debug]" else "[SDK]" + binding.tvStateUpdatedAt.text = "Updated ${sdf.format(Date(snapshot.updatedAt))} $source" + } else { + latestStateText = "No state data" + stateAdapter.submitList(emptyList()) + binding.tvStateUpdatedAt.text = "" + } + } + } + + private suspend fun observeResult() { + viewModel.operationResult.collect { result -> + when (result) { + is OperationResult.Success<*> -> { + latestResultText = formatResultData(result.data) + } + is OperationResult.Error -> { + latestResultText = "${result.exception.javaClass.simpleName}: ${result.exception.message}" + } + is OperationResult.NotBound -> { + latestResultText = "NOT_BOUND" + } + is OperationResult.NoData -> { + latestResultText = "No Data" + } + null -> { + latestResultText = "—" + } + } + } + } + + private suspend fun observeLogs() { + viewModel.logEntries.collect { entries -> + val visibleEntries = entries.takeLast(80).ifEmpty { + listOf(LogEntry( + timestamp = System.currentTimeMillis(), + component = componentType, + action = "No logs yet", + params = null, + result = null + )) + }.toList() + logAdapter.submitList(visibleEntries) { + if (visibleEntries.isNotEmpty()) { + binding.rvLogEntries.scrollToPosition(visibleEntries.size - 1) + } + } + } + } + + private fun formatResultData(data: Any?): String { + return when (data) { + is String -> JsonFormatter.format(data) + is FloatArray -> data.contentToString() + is Array<*> -> (data as? Array)?.joinToString("\n") { it.contentToString() } ?: data.contentToString() + else -> data.toString() + } + } + + private fun recordSnapshot() { + SessionLogger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = componentType, + action = "SNAPSHOT", + params = null, + result = "state=$latestStateText|result=$latestResultText" + )) + Toast.makeText(requireContext(), "Recorded", Toast.LENGTH_SHORT).show() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt new file mode 100644 index 0000000..537505e --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ComponentDetailViewModel.kt @@ -0,0 +1,88 @@ +package com.kiwii.controlpanel.ui.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kiwii.controlpanel.data.AarProxyStateSource +import com.kiwii.controlpanel.data.BridgeRepository +import com.kiwii.controlpanel.data.RuntimeStateRepository +import com.kiwii.controlpanel.data.StatePoller +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.logging.SessionLogger +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.model.OperationResult +import com.kiwii.controlpanel.model.StateSnapshot +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +class ComponentDetailViewModel : ViewModel() { + + val bridgeRepo = BridgeRepository() + private val runtimeStateRepo = RuntimeStateRepository(AarProxyStateSource(bridgeRepo)) + private val statePoller = StatePoller(runtimeStateRepo, bridgeRepo, SessionLogger) + + private val _operationResult = MutableStateFlow?>(null) + val operationResult: StateFlow?> = _operationResult + + private val _componentType = MutableStateFlow(ComponentType.RUNTIME_HOST_STATUS) + + val stateSnapshot: StateFlow = _componentType + .combine(statePoller.states) { type, map -> map[type] } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _logEntries = MutableStateFlow>(emptyList()) + val logEntries: StateFlow> = _logEntries + + init { + statePoller.startPolling() + viewModelScope.launch { + while (isActive) { + _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value) + delay(1000) + } + } + } + + fun setComponentType(type: ComponentType) { + _componentType.value = type + } + + fun executeOperation( + componentType: ComponentType, + actionName: String, + params: String? = null, + block: () -> T + ) { + viewModelScope.launch { + val result = bridgeRepo.callAsync(block) + _operationResult.value = result + + val resultStr = when (result) { + is OperationResult.Success<*> -> result.data.toString() + is OperationResult.Error -> "ERROR: ${result.exception.message}" + is OperationResult.NotBound -> result.defaultValue + is OperationResult.NoData -> "NO_DATA" + } + + SessionLogger.log(LogEntry( + timestamp = System.currentTimeMillis(), + component = componentType, + action = actionName, + params = params, + result = resultStr + )) + + _logEntries.value = SessionLogger.getEntriesForComponent(_componentType.value) + } + } + + override fun onCleared() { + super.onCleared() + statePoller.stopPolling() + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/ControlAdapter.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ControlAdapter.kt new file mode 100644 index 0000000..b9cf60b --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/ControlAdapter.kt @@ -0,0 +1,75 @@ +package com.kiwii.controlpanel.ui.detail + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.kiwii.controlpanel.R + +enum class ControlStyle { LIFECYCLE, QUERY, WARMSTART } + +data class ControlItem( + val id: String, + val title: String, + val subtitle: String, + val style: ControlStyle, + val spanSize: Int = 1 +) + +class ControlAdapter( + private val items: List, + private val onClick: (ControlItem) -> Unit +) : RecyclerView.Adapter() { + + var itemHeight: Int = 0 + + class VH(view: View) : RecyclerView.ViewHolder(view) { + val root: View = view.findViewById(R.id.control_root) + val title: TextView = view.findViewById(R.id.tv_title) + val subtitle: TextView = view.findViewById(R.id.tv_subtitle) + } + + override fun getItemCount() = items.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_rhs_control, parent, false) + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = items[position] + val lp = holder.itemView.layoutParams as GridLayoutManager.LayoutParams + if (itemHeight > 0) { + lp.height = itemHeight + } + val margin = (3 * holder.itemView.resources.displayMetrics.density).toInt() + lp.setMargins(margin, margin, margin, margin) + holder.itemView.layoutParams = lp + + holder.title.text = item.title + holder.subtitle.text = item.subtitle + + val bgRes = when (item.style) { + ControlStyle.LIFECYCLE -> R.drawable.bg_rhs_btn_lifecycle + ControlStyle.QUERY -> R.drawable.bg_rhs_btn_query + ControlStyle.WARMSTART -> R.drawable.bg_rhs_btn_warmstart + } + holder.root.setBackgroundResource(bgRes) + + when (item.style) { + ControlStyle.WARMSTART -> { + holder.title.setTextColor(holder.itemView.context.getColor(R.color.rhs_warmstart_title)) + holder.subtitle.setTextColor(holder.itemView.context.getColor(R.color.rhs_warmstart_subtitle)) + } + else -> { + holder.title.setTextColor(holder.itemView.context.getColor(R.color.rhs_btn_title)) + holder.subtitle.setTextColor(holder.itemView.context.getColor(R.color.rhs_btn_subtitle)) + } + } + + holder.root.setOnClickListener { onClick(item) } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogAdapter.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogAdapter.kt new file mode 100644 index 0000000..a7adf9b --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogAdapter.kt @@ -0,0 +1,55 @@ +package com.kiwii.controlpanel.ui.detail + +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.logging.LogEntry +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class LogAdapter : ListAdapter(DIFF) { + + private val sdf = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + + class VH(val textView: TextView) : RecyclerView.ViewHolder(textView) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_rhs_log, parent, false) as TextView + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val entry = getItem(position) + val type = LogColorizer.classify(entry) + val tag = when (type) { + LogColorizer.EntryType.TX -> "[TX]" + LogColorizer.EntryType.RX -> "[RX]" + LogColorizer.EntryType.SYS -> "[SYS]" + } + val color = when (type) { + LogColorizer.EntryType.TX -> R.color.rhs_tx_blue + LogColorizer.EntryType.RX -> R.color.rhs_rx_green + LogColorizer.EntryType.SYS -> R.color.rhs_sys_purple + } + + val time = sdf.format(Date(entry.timestamp)) + val result = entry.result ?: "" + val suffix = if (result.isNotEmpty()) " => $result" else "" + holder.textView.text = "$time $tag ${entry.action}$suffix" + holder.textView.setTextColor(holder.itemView.context.getColor(color)) + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: LogEntry, b: LogEntry) = + a.timestamp == b.timestamp && a.action == b.action + override fun areContentsTheSame(a: LogEntry, b: LogEntry) = a == b + } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt new file mode 100644 index 0000000..b8699f9 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/LogColorizer.kt @@ -0,0 +1,22 @@ +package com.kiwii.controlpanel.ui.detail + +import com.kiwii.controlpanel.logging.LogEntry + +/** 根据 LogEntry 内容分类为 TX/RX/SYS,供 LogAdapter 设置颜色 */ +object LogColorizer { + + enum class EntryType { TX, RX, SYS } + + fun classify(entry: LogEntry): EntryType { + if (entry.isStateChange) return EntryType.SYS + val action = entry.action.lowercase() + if (action.contains("poll") || action.contains("stream") || action == "snapshot" || action == "connection") { + return EntryType.SYS + } + val result = entry.result + if (result != null && !result.startsWith("ERROR")) { + return EntryType.RX + } + return EntryType.TX + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusFragment.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusFragment.kt new file mode 100644 index 0000000..8359fbe --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusFragment.kt @@ -0,0 +1,213 @@ +package com.kiwii.controlpanel.ui.detail + +import android.app.Activity +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.databinding.FragmentRuntimeHostStatusBinding +import com.kiwii.controlpanel.model.ComponentType +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class RuntimeHostStatusFragment : Fragment() { + + private var _binding: FragmentRuntimeHostStatusBinding? = null + private val binding get() = _binding!! + private val viewModel: ComponentDetailViewModel by viewModels() + + private lateinit var statAdapter: StatAdapter + private lateinit var logAdapter: LogAdapter + private lateinit var controlAdapter: ControlAdapter + + private var logStreamActive = true + + private val controlItems = listOf( + ControlItem("bind", "Bind", "bind(activity)", ControlStyle.LIFECYCLE), + ControlItem("unbind", "Unbind", "unbind()", ControlStyle.LIFECYCLE), + ControlItem("ping", "Ping", "ping()", ControlStyle.LIFECYCLE), + ControlItem("getRuntimeStateJson", "Runtime State", "getRuntimeStateJson", ControlStyle.QUERY), + ControlItem("getHealthStateJson", "Health", "getHealthStateJson", ControlStyle.QUERY), + ControlItem("getDebugSnapshotJson", "Debug", "getDebugSnapshotJson", ControlStyle.QUERY), + ControlItem("getRuntimeLifecycleStateJson", "Lifecycle", "getRuntimeLifecycleStateJson", ControlStyle.QUERY), + ControlItem("requestRuntimeWarmStart", "Warm Start", "requestRuntimeWarmStart", ControlStyle.WARMSTART, spanSize = 2), + ) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentRuntimeHostStatusBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.setComponentType(ComponentType.RUNTIME_HOST_STATUS) + requireActivity().title = ComponentType.RUNTIME_HOST_STATUS.displayName + + setupStats() + setupLog() + setupControls() + setupLogToggle() + observeState() + observeLogs() + } + + private fun setupStats() { + statAdapter = StatAdapter() + binding.rvStats.layoutManager = GridLayoutManager(requireContext(), 4) + binding.rvStats.adapter = statAdapter + } + + private fun setupLog() { + logAdapter = LogAdapter() + binding.rvLog.layoutManager = LinearLayoutManager(requireContext()) + binding.rvLog.adapter = logAdapter + } + + private fun setupControls() { + val spanCount = 3 + val layoutManager = GridLayoutManager(requireContext(), spanCount) + layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { + return if (position < controlItems.size) controlItems[position].spanSize else 1 + } + } + + controlAdapter = ControlAdapter(controlItems) { item -> + handleControlClick(item) + } + + binding.rvControls.layoutManager = layoutManager + binding.rvControls.adapter = controlAdapter + + binding.rvControls.post { + val rowCount = calculateRowCount() + if (rowCount > 0) { + val totalHeight = binding.rvControls.height + val spacing = (3 * resources.displayMetrics.density).toInt() + // 每行高度 = (总高 - 行间距总和) / 行数 + val itemHeight = (totalHeight - spacing * (rowCount + 1)) / rowCount + controlAdapter.itemHeight = itemHeight + controlAdapter.notifyDataSetChanged() + } + } + } + + private fun calculateRowCount(): Int { + var row = 0 + var spanUsed = 0 + for (item in controlItems) { + if (spanUsed + item.spanSize > 3) { + row++ + spanUsed = 0 + } + spanUsed += item.spanSize + } + if (spanUsed > 0) row++ + return row + } + + private fun handleControlClick(item: ControlItem) { + when (item.id) { + "bind" -> { + val activity = context as? Activity ?: return + viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "bind") { + viewModel.bridgeRepo.bind(activity) + } + } + "unbind" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "unbind") { + viewModel.bridgeRepo.unbind() + } + "ping" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "ping") { + viewModel.bridgeRepo.ping() + } + "getRuntimeStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeStateJson") { + viewModel.bridgeRepo.getRuntimeStateJson() + } + "getHealthStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getHealthStateJson") { + viewModel.bridgeRepo.getHealthStateJson() + } + "getDebugSnapshotJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getDebugSnapshotJson") { + viewModel.bridgeRepo.getDebugSnapshotJson() + } + "getRuntimeLifecycleStateJson" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "getRuntimeLifecycleStateJson") { + viewModel.bridgeRepo.getRuntimeLifecycleStateJson() + } + "requestRuntimeWarmStart" -> viewModel.executeOperation(ComponentType.RUNTIME_HOST_STATUS, "requestRuntimeWarmStart") { + viewModel.bridgeRepo.requestRuntimeWarmStart() + } + } + } + + private fun setupLogToggle() { + binding.btnLogToggle.setOnClickListener { + logStreamActive = !logStreamActive + binding.btnLogToggle.text = if (logStreamActive) "Stop" else "Start" + } + } + + private fun observeState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.stateSnapshot.collect { snapshot -> + if (snapshot != null) { + val isBound = viewModel.bridgeRepo.isBound() + val items = RuntimeHostStatusParser.parse(snapshot.data, isBound) + statAdapter.submitList(items) + updateBadge(isBound) + + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + val source = if (snapshot.isDebugChannel) "[Debug]" else "[SDK]" + binding.tvUpdatedAt.text = "Updated ${sdf.format(Date(snapshot.updatedAt))} $source" + } else { + // poller 返回空 map → 未绑定或无数据 + statAdapter.submitList(emptyList()) + updateBadge(false) + binding.tvUpdatedAt.text = "" + } + } + } + } + } + + private fun updateBadge(isBound: Boolean) { + if (isBound) { + binding.tvBadge.text = "BOUND" + binding.tvBadge.setBackgroundResource(R.drawable.bg_rhs_badge_bound) + binding.tvBadge.setTextColor(requireContext().getColor(R.color.rhs_badge_text)) + } else { + binding.tvBadge.text = "UNBOUND" + binding.tvBadge.setBackgroundResource(R.drawable.bg_rhs_badge_unbound) + binding.tvBadge.setTextColor(requireContext().getColor(R.color.result_not_bound)) + } + } + + private fun observeLogs() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.logEntries.collect { entries -> + if (!logStreamActive) return@collect + logAdapter.submitList(entries.toList()) { + if (entries.isNotEmpty()) { + binding.rvLog.scrollToPosition(entries.size - 1) + } + } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt new file mode 100644 index 0000000..f7ce495 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/RuntimeHostStatusParser.kt @@ -0,0 +1,62 @@ +package com.kiwii.controlpanel.ui.detail + +import org.json.JSONObject + +/** + * 状态卡片中的一个指标项 + * @param label 指标名称(JSON key 的可读形式) + * @param value 主要显示值 + * @param detail 辅助说明(可为空) + */ +data class StatItem( + val label: String, + val value: String, + val detail: String = "" +) + +/** 从 RuntimeState JSON 中动态提取所有顶层字段为 StatItem 列表 */ +object RuntimeHostStatusParser { + + fun parse(json: String, isBound: Boolean): List { + val items = mutableListOf() + + val obj = try { + JSONObject(json) + } catch (_: Exception) { + return items + } + + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + val raw = obj.opt(key) ?: continue + val item = when { + raw is JSONObject -> StatItem( + label = humanize(key), + value = raw.optString("state", raw.optString("value", "…")).compactValue(), + detail = raw.optString("detail", "") + ) + else -> StatItem( + label = humanize(key), + value = raw.toString().compactValue() + ) + } + items.add(item) + } + + return items + } + + /** camelCase / snake_case → 可读标签 */ + private fun humanize(key: String): String { + return key + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .replace("_", " ") + .replaceFirstChar { it.uppercase() } + } + + private fun String.compactValue(): String { + return replace("\"", "") + .let { if (it.length > 16) "${it.take(14)}..." else it } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/detail/StatAdapter.kt b/app/src/main/java/com/kiwii/controlpanel/ui/detail/StatAdapter.kt new file mode 100644 index 0000000..da4b248 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/detail/StatAdapter.kt @@ -0,0 +1,50 @@ +package com.kiwii.controlpanel.ui.detail + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.kiwii.controlpanel.R + +class StatAdapter : ListAdapter(DIFF) { + + class VH(view: View) : RecyclerView.ViewHolder(view) { + val label: TextView = view.findViewById(R.id.tv_label) + val value: TextView = view.findViewById(R.id.tv_value) + val detail: TextView = view.findViewById(R.id.tv_detail) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_rhs_stat, parent, false) + return VH(view) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + val item = getItem(position) + val margin = (3 * holder.itemView.resources.displayMetrics.density).toInt() + val lp = holder.itemView.layoutParams as GridLayoutManager.LayoutParams + lp.setMargins(margin, margin, margin, margin) + holder.itemView.layoutParams = lp + + holder.label.text = item.label + holder.value.text = item.value + if (item.detail.isNotEmpty()) { + holder.detail.text = item.detail + holder.detail.visibility = View.VISIBLE + } else { + holder.detail.visibility = View.GONE + } + } + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(a: StatItem, b: StatItem) = a.label == b.label + override fun areContentsTheSame(a: StatItem, b: StatItem) = a == b + } + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeFragment.kt b/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeFragment.kt new file mode 100644 index 0000000..adbe9ac --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeFragment.kt @@ -0,0 +1,63 @@ +package com.kiwii.controlpanel.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.GridLayoutManager +import com.kiwii.controlpanel.R +import com.kiwii.controlpanel.databinding.FragmentHomeBinding +import com.kiwii.controlpanel.model.ComponentType +import com.kiwii.controlpanel.ui.adapter.ComponentCardAdapter +import kotlinx.coroutines.launch + +class HomeFragment : Fragment() { + + private var _binding: FragmentHomeBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by viewModels() + + private lateinit var adapter: ComponentCardAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentHomeBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + adapter = ComponentCardAdapter { componentType -> + navigateToDetail(componentType) + } + + binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2) + binding.recyclerView.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.componentStates.collect { states -> + adapter.submitList(states) + } + } + } + } + + private fun navigateToDetail(type: ComponentType) { + val bundle = Bundle().apply { + putString("componentType", type.name) + } + findNavController().navigate(R.id.action_home_to_detail, bundle) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeViewModel.kt b/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..1b6fe47 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/ui/home/HomeViewModel.kt @@ -0,0 +1,30 @@ +package com.kiwii.controlpanel.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.kiwii.controlpanel.data.BridgeRepository +import com.kiwii.controlpanel.data.ComponentRegistry +import com.kiwii.controlpanel.logging.SessionLogger +import com.kiwii.controlpanel.model.ComponentState +import com.kiwii.controlpanel.model.ComponentType +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +class HomeViewModel : ViewModel() { + + private val bridgeRepo = BridgeRepository() + private val registry = ComponentRegistry(bridgeRepo, viewModelScope, SessionLogger) + + init { + registry.startDetection() + } + + val componentStates: StateFlow>> = + registry.availability.map { map -> + ComponentType.entries.map { type -> + type to (map[type] ?: ComponentState.GREY) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) +} diff --git a/app/src/main/java/com/kiwii/controlpanel/util/ImuParser.kt b/app/src/main/java/com/kiwii/controlpanel/util/ImuParser.kt new file mode 100644 index 0000000..fa57dda --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/util/ImuParser.kt @@ -0,0 +1,120 @@ +package com.kiwii.controlpanel.util + +data class ImuFrame( + val seqId: Int, + val dtUs: Int, + val qw: Float, + val qx: Float, + val qy: Float, + val qz: Float, + val lax: Float, + val lay: Float, + val laz: Float +) + +object ImuParser { + + /** + * 解析 float[9] IMU 帧: [seq_id, dt_us, qw, qx, qy, qz, lax, lay, laz] + */ + fun parse(data: FloatArray): ImuFrame? { + if (data.size < 9) return null + return ImuFrame( + seqId = data[0].toInt(), + dtUs = data[1].toInt(), + qw = data[2], + qx = data[3], + qy = data[4], + qz = data[5], + lax = data[6], + lay = data[7], + laz = data[8] + ) + } + + fun parseQueue(queue: Array): List { + return queue.mapNotNull { parse(it) } + } + + fun formatFrame(frame: ImuFrame): String { + return "seq=${frame.seqId} dt=${frame.dtUs}us " + + "q=(${f(frame.qw)},${f(frame.qx)},${f(frame.qy)},${f(frame.qz)}) " + + "acc=(${f(frame.lax)},${f(frame.lay)},${f(frame.laz)})" + } + + private fun f(v: Float): String = "%.3f".format(v) +} + +/** + * IMU 流统计器 — 检测 seq 跳变和帧间隔异常 + */ +class ImuStats { + private var lastSeqId = -1 + private var frameCount = 0 + private var dropCount = 0 + private var dtSum = 0L + private var dtMin = Int.MAX_VALUE + private var dtMax = 0 + private var lastReportTime = System.currentTimeMillis() + + data class Anomaly(val type: String, val detail: String) + + /** + * 输入一帧,返回异常列表(为空则正常) + */ + fun feed(frame: ImuFrame): List { + val anomalies = mutableListOf() + frameCount++ + + // seq 跳变检测 + if (lastSeqId >= 0) { + val expected = lastSeqId + 1 + if (frame.seqId != expected) { + val dropped = frame.seqId - expected + dropCount += if (dropped > 0) dropped else 1 + anomalies.add(Anomaly( + "seq_jump", + "expected=$expected got=${frame.seqId} dropped=$dropped" + )) + } + } + lastSeqId = frame.seqId + + // 帧间隔统计 + if (frame.dtUs > 0) { + dtSum += frame.dtUs + if (frame.dtUs < dtMin) dtMin = frame.dtUs + if (frame.dtUs > dtMax) dtMax = frame.dtUs + + // 帧间隔异常(>50ms) + if (frame.dtUs > 50000) { + anomalies.add(Anomaly( + "dt_high", + "dt=${frame.dtUs}us (>${50000}us threshold)" + )) + } + } + + return anomalies + } + + /** + * 获取周期统计摘要(调用后重置计数器) + */ + fun getSummaryAndReset(): String { + val avgDt = if (frameCount > 0) dtSum / frameCount else 0 + val summary = "frames=$frameCount drops=$dropCount dt_avg=${avgDt}us dt_min=${dtMin}us dt_max=${dtMax}us" + frameCount = 0 + dropCount = 0 + dtSum = 0 + dtMin = Int.MAX_VALUE + dtMax = 0 + lastReportTime = System.currentTimeMillis() + return summary + } + + fun shouldReport(): Boolean { + return System.currentTimeMillis() - lastReportTime >= 10_000 + } +} + diff --git a/app/src/main/java/com/kiwii/controlpanel/util/JsonFormatter.kt b/app/src/main/java/com/kiwii/controlpanel/util/JsonFormatter.kt new file mode 100644 index 0000000..46bce15 --- /dev/null +++ b/app/src/main/java/com/kiwii/controlpanel/util/JsonFormatter.kt @@ -0,0 +1,21 @@ +package com.kiwii.controlpanel.util + +import org.json.JSONArray +import org.json.JSONObject +import org.json.JSONTokener + +object JsonFormatter { + + fun format(raw: String): String { + if (raw.isBlank()) return "" + return try { + when (val token = JSONTokener(raw).nextValue()) { + is JSONObject -> token.toString(2) + is JSONArray -> token.toString(2) + else -> raw + } + } catch (_: Exception) { + raw + } + } +} diff --git a/app/src/main/res/drawable/bg_rhs_badge_bound.xml b/app/src/main/res/drawable/bg_rhs_badge_bound.xml new file mode 100644 index 0000000..3cb4cb5 --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_badge_bound.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_badge_unbound.xml b/app/src/main/res/drawable/bg_rhs_badge_unbound.xml new file mode 100644 index 0000000..90c420f --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_badge_unbound.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_btn_lifecycle.xml b/app/src/main/res/drawable/bg_rhs_btn_lifecycle.xml new file mode 100644 index 0000000..6a3b8bd --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_btn_lifecycle.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_btn_placeholder.xml b/app/src/main/res/drawable/bg_rhs_btn_placeholder.xml new file mode 100644 index 0000000..38bbdaa --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_btn_placeholder.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_btn_query.xml b/app/src/main/res/drawable/bg_rhs_btn_query.xml new file mode 100644 index 0000000..6a3b8bd --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_btn_query.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_btn_warmstart.xml b/app/src/main/res/drawable/bg_rhs_btn_warmstart.xml new file mode 100644 index 0000000..90c420f --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_btn_warmstart.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_card.xml b/app/src/main/res/drawable/bg_rhs_card.xml new file mode 100644 index 0000000..8380a33 --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_card.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_control_inner.xml b/app/src/main/res/drawable/bg_rhs_control_inner.xml new file mode 100644 index 0000000..d5c6008 --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_control_inner.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_log_area.xml b/app/src/main/res/drawable/bg_rhs_log_area.xml new file mode 100644 index 0000000..380d25e --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_log_area.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_log_toggle.xml b/app/src/main/res/drawable/bg_rhs_log_toggle.xml new file mode 100644 index 0000000..1de6c83 --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_log_toggle.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_stat_box.xml b/app/src/main/res/drawable/bg_rhs_stat_box.xml new file mode 100644 index 0000000..1e8adfb --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_stat_box.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_rhs_status_inner.xml b/app/src/main/res/drawable/bg_rhs_status_inner.xml new file mode 100644 index 0000000..d5c6008 --- /dev/null +++ b/app/src/main/res/drawable/bg_rhs_status_inner.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3905d4d --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_component_detail.xml b/app/src/main/res/layout/fragment_component_detail.xml new file mode 100644 index 0000000..3e03e15 --- /dev/null +++ b/app/src/main/res/layout/fragment_component_detail.xml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml new file mode 100644 index 0000000..da975ab --- /dev/null +++ b/app/src/main/res/layout/fragment_home.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/layout/fragment_runtime_host_status.xml b/app/src/main/res/layout/fragment_runtime_host_status.xml new file mode 100644 index 0000000..57c2c75 --- /dev/null +++ b/app/src/main/res/layout/fragment_runtime_host_status.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_component_card.xml b/app/src/main/res/layout/item_component_card.xml new file mode 100644 index 0000000..c98cdb8 --- /dev/null +++ b/app/src/main/res/layout/item_component_card.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_rhs_control.xml b/app/src/main/res/layout/item_rhs_control.xml new file mode 100644 index 0000000..42cf28e --- /dev/null +++ b/app/src/main/res/layout/item_rhs_control.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/layout/item_rhs_log.xml b/app/src/main/res/layout/item_rhs_log.xml new file mode 100644 index 0000000..f2c7f15 --- /dev/null +++ b/app/src/main/res/layout/item_rhs_log.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/layout/item_rhs_stat.xml b/app/src/main/res/layout/item_rhs_stat.xml new file mode 100644 index 0000000..6662825 --- /dev/null +++ b/app/src/main/res/layout/item_rhs_stat.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_detail.xml b/app/src/main/res/menu/menu_detail.xml new file mode 100644 index 0000000..7f4fc0e --- /dev/null +++ b/app/src/main/res/menu/menu_detail.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..d739bae --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..41f4369 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,28 @@ + + + #4CAF50 + #9E9E9E + #2E7D32 + #C62828 + #F9A825 + + + #2F80ED + #31A66A + #7B8794 + #D9F5E5 + #31A66A + #27313B + #17202A + #52616F + #52616F + #17202A + #17202A + #52616F + #D99000 + #D99000 + #7B8794 + #31A66A + #D99000 + #2F80ED + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3d123ce --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Kiwii Control Panel + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..5f13d50 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..80bd895 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/test/java/com/kiwii/controlpanel/BridgeRepositoryTest.kt b/app/src/test/java/com/kiwii/controlpanel/BridgeRepositoryTest.kt new file mode 100644 index 0000000..cda9140 --- /dev/null +++ b/app/src/test/java/com/kiwii/controlpanel/BridgeRepositoryTest.kt @@ -0,0 +1,55 @@ +package com.kiwii.controlpanel + +import com.kiwii.controlpanel.data.BridgeRepository +import com.kiwii.controlpanel.model.OperationResult +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +class BridgeRepositoryTest { + + private val repo = BridgeRepository() + + @Test + fun `initially not bound`() { + assertFalse(repo.isBound()) + } + + @Test + fun `ping when not bound returns error json`() { + val result = repo.ping() + assertTrue(result.contains("NOT_BOUND")) + } + + @Test + fun `getRuntimeStateJson when not bound returns error`() { + val result = repo.getRuntimeStateJson() + assertTrue(result.contains("NOT_BOUND")) + } + + @Test + fun `callAsync wraps successful result`() = runTest { + val result = repo.callAsync { "hello" } + assertTrue(result is OperationResult.Success) + assertEquals("hello", (result as OperationResult.Success).data) + } + + @Test + fun `callAsync wraps exception`() = runTest { + val result = repo.callAsync { throw RuntimeException("test error") } + assertTrue(result is OperationResult.Error) + assertEquals("test error", (result as OperationResult.Error).exception.message) + } + + @Test + fun `callAsync returns NoData for null`() = runTest { + val result = repo.callAsync { null } + assertTrue(result is OperationResult.NoData) + } + + @Test + fun `callAsync returns NoData for blank string`() = runTest { + val result = repo.callAsync { " " } + assertTrue(result is OperationResult.NoData) + } +} diff --git a/app/src/test/java/com/kiwii/controlpanel/ComponentTypeTest.kt b/app/src/test/java/com/kiwii/controlpanel/ComponentTypeTest.kt new file mode 100644 index 0000000..6d94919 --- /dev/null +++ b/app/src/test/java/com/kiwii/controlpanel/ComponentTypeTest.kt @@ -0,0 +1,46 @@ +package com.kiwii.controlpanel + +import com.kiwii.controlpanel.model.ComponentType +import org.junit.Assert.* +import org.junit.Test + +class ComponentTypeTest { + + @Test + fun `enum has 8 components`() { + assertEquals(8, ComponentType.entries.size) + } + + @Test + fun `runtime host status does not require bound`() { + assertFalse(ComponentType.RUNTIME_HOST_STATUS.requiresBound) + } + + @Test + fun `session log does not require bound`() { + assertFalse(ComponentType.SESSION_LOG.requiresBound) + } + + @Test + fun `camera requires bound`() { + assertTrue(ComponentType.CAMERA.requiresBound) + } + + @Test + fun `motor requires bound`() { + assertTrue(ComponentType.MOTOR.requiresBound) + } + + @Test + fun `all bound-required components`() { + val boundRequired = ComponentType.entries.filter { it.requiresBound } + assertEquals(6, boundRequired.size) + } + + @Test + fun `display names are non-empty`() { + ComponentType.entries.forEach { + assertTrue(it.displayName.isNotBlank()) + } + } +} diff --git a/app/src/test/java/com/kiwii/controlpanel/ImuParserTest.kt b/app/src/test/java/com/kiwii/controlpanel/ImuParserTest.kt new file mode 100644 index 0000000..e8801dd --- /dev/null +++ b/app/src/test/java/com/kiwii/controlpanel/ImuParserTest.kt @@ -0,0 +1,68 @@ +package com.kiwii.controlpanel + +import com.kiwii.controlpanel.util.ImuParser +import org.junit.Assert.* +import org.junit.Test + +class ImuParserTest { + + @Test + fun `parse valid float9 returns correct ImuFrame`() { + val data = floatArrayOf(1f, 100f, 0.707f, 0f, 0f, 0.707f, 0.1f, -9.8f, 0.2f) + val frame = ImuParser.parse(data) + + assertNotNull(frame) + assertEquals(1, frame!!.seqId) + assertEquals(100, frame.dtUs) + assertEquals(0.707f, frame.qw, 0.001f) + assertEquals(0f, frame.qx, 0.001f) + assertEquals(0f, frame.qy, 0.001f) + assertEquals(0.707f, frame.qz, 0.001f) + assertEquals(0.1f, frame.lax, 0.001f) + assertEquals(-9.8f, frame.lay, 0.001f) + assertEquals(0.2f, frame.laz, 0.001f) + } + + @Test + fun `parse short array returns null`() { + val data = floatArrayOf(1f, 2f, 3f) + assertNull(ImuParser.parse(data)) + } + + @Test + fun `parse empty array returns null`() { + assertNull(ImuParser.parse(floatArrayOf())) + } + + @Test + fun `parseQueue with multiple frames`() { + val queue = arrayOf( + floatArrayOf(1f, 100f, 0.5f, 0.5f, 0.5f, 0.5f, 0f, 0f, 0f), + floatArrayOf(2f, 200f, 1f, 0f, 0f, 0f, 1f, 1f, 1f) + ) + val frames = ImuParser.parseQueue(queue) + assertEquals(2, frames.size) + assertEquals(1, frames[0].seqId) + assertEquals(2, frames[1].seqId) + } + + @Test + fun `parseQueue filters invalid frames`() { + val queue = arrayOf( + floatArrayOf(1f, 100f, 0.5f, 0.5f, 0.5f, 0.5f, 0f, 0f, 0f), + floatArrayOf(1f, 2f) // too short + ) + val frames = ImuParser.parseQueue(queue) + assertEquals(1, frames.size) + } + + @Test + fun `formatFrame produces readable output`() { + val data = floatArrayOf(42f, 1000f, 1f, 0f, 0f, 0f, 0f, -9.81f, 0f) + val frame = ImuParser.parse(data)!! + val formatted = ImuParser.formatFrame(frame) + assertTrue(formatted.contains("seq=42")) + assertTrue(formatted.contains("dt=1000us")) + assertTrue(formatted.contains("q=(1.000,0.000,0.000,0.000)")) + } +} diff --git a/app/src/test/java/com/kiwii/controlpanel/JsonFormatterTest.kt b/app/src/test/java/com/kiwii/controlpanel/JsonFormatterTest.kt new file mode 100644 index 0000000..aab9152 --- /dev/null +++ b/app/src/test/java/com/kiwii/controlpanel/JsonFormatterTest.kt @@ -0,0 +1,26 @@ +package com.kiwii.controlpanel + +import com.kiwii.controlpanel.util.JsonFormatter +import org.junit.Assert.* +import org.junit.Test + +class JsonFormatterTest { + + @Test + fun `format invalid json returns raw`() { + val raw = "not json at all" + assertEquals(raw, JsonFormatter.format(raw)) + } + + @Test + fun `format blank string returns empty`() { + assertEquals("", JsonFormatter.format("")) + assertEquals("", JsonFormatter.format(" ")) + } + + @Test + fun `format non-blank non-json returns raw`() { + val raw = "hello world 123" + assertEquals(raw, JsonFormatter.format(raw)) + } +} diff --git a/app/src/test/java/com/kiwii/controlpanel/LogEntryTest.kt b/app/src/test/java/com/kiwii/controlpanel/LogEntryTest.kt new file mode 100644 index 0000000..ff93a01 --- /dev/null +++ b/app/src/test/java/com/kiwii/controlpanel/LogEntryTest.kt @@ -0,0 +1,57 @@ +package com.kiwii.controlpanel + +import com.kiwii.controlpanel.logging.LogEntry +import com.kiwii.controlpanel.model.ComponentType +import org.junit.Assert.* +import org.junit.Test + +class LogEntryTest { + + @Test + fun `toLogLine includes all fields`() { + val entry = LogEntry( + timestamp = 1000L, + component = ComponentType.CAMERA, + action = "getCameraStateJson", + params = "none", + result = "{\"ok\":true}", + isStateChange = false + ) + val line = entry.toLogLine() + assertTrue(line.contains("1000")) + assertTrue(line.contains("Camera")) + assertTrue(line.contains("getCameraStateJson")) + assertTrue(line.contains("none")) + assertTrue(line.contains("{\"ok\":true}")) + assertTrue(line.contains("false")) + } + + @Test + fun `toLogLine handles null component as GLOBAL`() { + val entry = LogEntry( + timestamp = 2000L, + component = null, + action = "connection", + params = null, + result = "BOUND", + isStateChange = true + ) + val line = entry.toLogLine() + assertTrue(line.contains("GLOBAL")) + } + + @Test + fun `toLogLine handles null params and result`() { + val entry = LogEntry( + timestamp = 3000L, + component = ComponentType.MOTOR, + action = "test", + params = null, + result = null, + isStateChange = false + ) + val line = entry.toLogLine() + assertNotNull(line) + assertTrue(line.contains("Motor")) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..cb472af --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.0.1" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4992b2b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..d84443a --- /dev/null +++ b/gradlew @@ -0,0 +1,90 @@ +#!/bin/sh + +# +# Gradle start up script for POSIX generated by Gradle. +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld -- "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NonStop* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME" + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + ;; + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + ;; + esac +fi + +# Collect all arguments for the java command, stracks://gnu.org/s/libc/manual/html_node/Argument-Syntax.html +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@" + +exec "$JAVACMD" "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ca1e266 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "kiwii-sdk-control-panel" +include(":app")