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