diff --git a/N7Cockpit/.gitignore b/N7Cockpit/.gitignore new file mode 100644 index 0000000..68c0eb2 --- /dev/null +++ b/N7Cockpit/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +local.properties +**/build/ +*.iml +.idea/ +.DS_Store +config/migration.psd1 +secrets/ +dist/ +artifacts/ +apk/ diff --git a/N7Cockpit/MIGRATION_GUIDE.md b/N7Cockpit/MIGRATION_GUIDE.md new file mode 100644 index 0000000..e3a9d04 --- /dev/null +++ b/N7Cockpit/MIGRATION_GUIDE.md @@ -0,0 +1,212 @@ +# N7Cockpit 跨服务器迁移与联调手册 + +## 1. 部署拓扑 + +本工程采用统一服务端事件链路,不做网页自动点击: + +```text +Android / N7Cockpit + | HTTPS/HTTP + Bearer token + | mode_id / execution_id / event sequence + v +服务器 A:Cognitive_Agent :8000 + | 展开服务器预设模式、安全校验、执行与状态回读 + +--> Web 控制台(同一事件流) + +--> CAN 盒与车端设备(仅 hardware + 本地批准时) + +服务器 B:保存 N7Cockpit 源码、构建 APK、通过 ADB 安装到 Android +``` + +`127.0.0.1:8000` 只表示“当前机器”。Android 设备访问服务器 A 时必须使用 +服务器 A 的可路由 IP 或 DNS 名。只有 Android Emulator 与服务器 A 同机时,APK +中的服务器 A 地址才使用 `http://10.0.2.2:8000`。 + +## 2. 服务器 A:启动 Cognitive_Agent + +服务器 A 必须已经包含本项目配套修改后的 Cognitive_Agent。进入其工程目录,生成 +至少 32 字符的随机设备令牌;令牌只在服务器 A 和服务器 B 的私密文件中保存: + +```powershell +cd D:\path\to\Cognitive_Agent +$tokenBytes = New-Object byte[] 48 +$rng = [Security.Cryptography.RandomNumberGenerator]::Create() +try { $rng.GetBytes($tokenBytes) } finally { $rng.Dispose() } +$deviceToken = [Convert]::ToBase64String($tokenBytes) +$deviceToken | Set-Content -NoNewline .\n7-device-token.txt +``` + +启动服务。当前 PowerShell 会话关闭后进程也会终止,生产环境应再配置 Windows +Service、NSSM 或反向代理进程守护: + +```powershell +$env:PYTHONPATH = 'src' +$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = ( + Get-Content .\n7-device-token.txt -Raw +).Trim() +python -m cognitive_agent.server --host 0.0.0.0 --port 8000 +``` + +服务器 A 防火墙只允许服务器 B、Android 车机和 Web 管理端访问 TCP 8000。生产 +环境必须在服务器前配置 HTTPS;HTTP 仅适用于受信任的隔离局域网。 + +从服务器 B 检查网络。这里不能使用服务器 B 的 `127.0.0.1`: + +```powershell +Test-NetConnection 192.168.1.10 -Port 8000 +``` + +Web 控制台地址也应改为服务器 A 地址:`http://192.168.1.10:8000/`。服务器 A +本机访问时才使用 `http://127.0.0.1:8000/`。 + +## 3. 服务器 B:解压和初始化 + +安装以下依赖: + +- Windows PowerShell 5.1 或 PowerShell 7; +- JDK 17 或更高版本; +- Android SDK Platform 35 和 Platform Tools; +- 首次构建可访问 Gradle/Google/Aliyun Maven 仓库。 + +解压 ZIP 后: + +```powershell +cd D:\Deploy\N7Cockpit +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\initialize-migration.ps1 +``` + +编辑 `config\migration.psd1`: + +```powershell +@{ + ServerAUrl = 'http://192.168.1.10:8000' + AndroidServerAUrl = 'http://192.168.1.10:8000' + VehicleId = 'denza_n7' + HardwareClientId = 'hwc-n7cockpit-server-b-001' + ExecutionTarget = 'dry_run' + TokenFile = 'secrets/device-token.txt' + JavaHome = 'C:\Program Files\Android\Android Studio\jbr' + AndroidSdk = 'C:\Users\Administrator\AppData\Local\Android\Sdk' + GradleExecutable = '' +} +``` + +把服务器 A 的 `n7-device-token.txt` 安全复制到服务器 B 的 +`secrets\device-token.txt`。不要通过聊天、代码仓库或普通邮件传输。配置和 secrets +目录均被打包规则排除。 + +先验证环境及服务器 A: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\verify-migration-environment.ps1 +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\check-server-a.ps1 +``` + +两条命令都应返回 `Passed=True`,且 `PresetModes` 大于 0。 + +## 4. 构建和安装 Android APK + +安全联调构建: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\build-migrated-apk.ps1 -Variant debug +``` + +脚本会先验证服务器 A、运行 Android 单元测试,再生成: + +```text +dist\N7Cockpit-remote-debug.apk +dist\build-info.json +``` + +`GradleExecutable` 留空时使用包内 Gradle Wrapper;首次构建会下载 Gradle 8.9。 +服务器若已安装 Gradle 8.9,可填写 `gradle.bat` 的绝对路径以避免重复下载。 + +设备开启开发者选项和 USB 调试,执行: + +```powershell +adb devices +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\install-migrated-apk.ps1 -Variant debug +``` + +如有多个设备,增加 `-Serial `。远程调试包名为 +`com.seeker.n7cockpit.remote`,可与正式包共存。 + +## 5. dry_run 联调验收 + +先进行不会访问 CAN 的服务器 A 冒烟测试: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\test-server-a-dry-run.ps1 +``` + +必须看到: + +- `Passed=True`; +- `CanExecuted=False`; +- `ExecutionTarget=dry_run`; +- `Status=succeeded`。 + +然后打开 Android 的“模式”页: + +1. 顶部应显示服务器 A URL 和“编排服务已连接”; +2. 应加载宝宝午睡、零重力、小憩、轻松进出等服务器预设模式; +3. 点击“宝宝午睡模式”; +4. Android 显示 `queued/executing/succeeded`、execution_id 和车端模拟状态; +5. 打开服务器 A 的 Web 控制台,显示同一模式、`source:N7Cockpit`、同一 + execution_id 和同步后的数字孪生状态。 + +此阶段不要点击“申请真实硬件控制”。 + +## 6. 实体 Android 与模拟器的地址选择 + +| 场景 | AndroidServerAUrl | +|---|---| +| 模拟器与服务器 A 同机 | `http://10.0.2.2:8000` | +| 模拟器在服务器 B,服务器 A 独立 | 服务器 A 的 LAN IP/DNS | +| 实体 Android/车机 | 服务器 A 的 LAN IP/DNS | +| 生产环境 | 服务器 A 的 HTTPS 域名 | + +服务器 B 的 `ServerAUrl` 始终填写服务器 B 能访问的服务器 A 地址。 + +## 7. 切换真实 CAN(完成 dry_run 后单独进行) + +真实硬件不属于默认迁移验收范围。确认急停、机械限位、CAN 通道和车端供电安全后: + +1. 将配置改为 `ExecutionTarget='hardware'`; +2. 显式构建: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\build-migrated-apk.ps1 -Variant release -AllowHardware +``` + +3. Android 点击“申请真实硬件控制”; +4. 只能在服务器 A 本机打开 `/hardware-control` 批准对应 HardwareClientId; +5. 批准后才允许 hardware 任务进入 CAN 执行。 + +`-AllowHardware` 和服务器 A 本地批准是两层独立安全门,远程设备令牌不能自批准。 + +## 8. 常见故障 + +- `401`:服务器 A 与 APK 使用的设备令牌不一致,或服务重启时没有加载令牌。 +- `Connection refused/timed out`:服务未监听 `0.0.0.0`、防火墙未放行,或 URL + 错填为 Android/服务器 B 自己的 `127.0.0.1`。 +- 模式为空:车型 ID 不匹配,或服务器 A 不是包含预设模式 API 的配套版本。 +- Android 可访问但 Web 不更新:Web 未登录,或浏览器标签长期后台导致轮询暂停; + 激活页面后会继续读取新事件。 +- Gradle 下载失败:检查代理/DNS;工程已同时配置 Aliyun 与官方仓库。 +- SDK XML 警告:Android Studio 与 command-line tools 版本差异警告,若构建最终 + `BUILD SUCCESSFUL` 通常不影响 APK。 + +## 9. 安全说明 + +设备令牌会进入 APK BuildConfig。只应把 APK 安装到受控车机;如果 APK 可能公开 +分发,应改为设备注册和短期凭据。令牌泄露后必须在服务器 A 生成新令牌、重启服务 +并重新构建 APK。ZIP 源码包不会包含设备令牌、构建目录、历史 APK、测试截图或 +本机 Gradle 缓存。 diff --git a/N7Cockpit/README.md b/N7Cockpit/README.md new file mode 100644 index 0000000..e07d083 --- /dev/null +++ b/N7Cockpit/README.md @@ -0,0 +1,54 @@ +# N7Cockpit Android + +原生 Android 车舱应用。功能模式页从服务器 A 的 Cognitive_Agent 获取预设模式, +提交模式任务,并每秒同步 Web/Android 共用的执行事件与最终车端状态。 + +跨服务器迁移请优先阅读 [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)。可迁移 ZIP +通过 `scripts\create-portable-package.ps1` 生成,包内不包含设备令牌、构建缓存、 +历史 APK 或本机测试产物。 + +## 服务器 B 构建 + +工程使用 Android Gradle Plugin 8.7.3、Gradle 8.9、JDK 17+ 和 Android SDK 35。 +工程已包含 Gradle Wrapper,服务器安装 JDK 17 或更高版本和 Android SDK 35 后可直接构建。 + +服务器 A 地址和设备令牌通过 Gradle 属性或环境变量注入。真实设备不能填写 +`127.0.0.1`;该地址会指向 Android 设备自身。`10.0.2.2` 只适用于 Android +模拟器访问宿主机。 + +```powershell +$env:COGNITIVE_AGENT_BASE_URL = 'http://192.168.1.10:8000' +$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = '<与服务器A相同的长随机令牌>' +./gradlew.bat assembleRelease +``` + +也可使用 `-PorchestratorBaseUrl=...`、`-PorchestratorApiToken=...`、 +`-PorchestratorVehicleId=denza_n7`、`-PorchestratorHardwareClientId=hwc-n7cockpit-001` +和 `-PorchestratorExecutionTarget=hardware`。APK 输出位于 +`app/build/outputs/apk/release/`。 + +未传 `orchestratorExecutionTarget` 时默认使用 `dry_run`。真实 CAN 构建必须显式 +传入 `-PorchestratorExecutionTarget=hardware`,避免普通调试构建误入硬件执行。 + +开发环境允许局域网 HTTP;生产跨服务器部署应在服务器 A 前配置 HTTPS,并关闭 +Manifest 的明文流量许可。令牌会进入 APK 的 BuildConfig,因此它只用于受控车机, +如 APK 会公开分发,应改为设备注册后下发短期凭据。 + +## 模拟器远程联调 + +启动已安装的远程联调应用时,不要解析 `resolve-activity` 输出后直接拼接命令; +部分 Android 版本会先输出匹配优先级元数据。使用固定、完整组件名启动,并验证 +Activity 确实处于前台: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\start-remote-app.ps1 +``` + +自动联调脚本会校验模式目录、启动 Android、点击“宝宝午睡模式”,并检查 +`queued -> executing -> succeeded` 事件、相同的 `execution_id` 和车端状态回显。 +脚本只允许 `10.0.2.2:8000` 且强制要求 APK 的执行目标为 `dry_run`;不申请硬件 +控制权限,也不会执行真实 CAN: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-remote-link.ps1 +``` diff --git a/N7Cockpit/app/build.gradle b/N7Cockpit/app/build.gradle new file mode 100644 index 0000000..38625f8 --- /dev/null +++ b/N7Cockpit/app/build.gradle @@ -0,0 +1,78 @@ +plugins { + id 'com.android.application' +} + +def quotedBuildConfig = { String value -> + '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' +} +def orchestratorBaseUrl = providers.gradleProperty('orchestratorBaseUrl') + .orElse(providers.environmentVariable('COGNITIVE_AGENT_BASE_URL')) + .getOrElse('http://10.0.2.2:8000') +def orchestratorApiToken = providers.gradleProperty('orchestratorApiToken') + .orElse(providers.environmentVariable('COGNITIVE_AGENT_DEVICE_API_TOKEN')) + .getOrElse('') +def orchestratorVehicleId = providers.gradleProperty('orchestratorVehicleId') + .getOrElse('denza_n7') +def orchestratorHardwareClientId = providers.gradleProperty('orchestratorHardwareClientId') + .getOrElse('hwc-n7cockpit-001') +def orchestratorExecutionTarget = providers.gradleProperty('orchestratorExecutionTarget') + // Safe by default: real CAN execution must always be selected explicitly. + .getOrElse('dry_run') + +android { + namespace 'com.seeker.n7cockpit' + compileSdk 35 + + buildFeatures { + buildConfig true + } + + defaultConfig { + applicationId 'com.seeker.n7cockpit' + minSdk 26 + targetSdk 29 + versionCode 2 + versionName '1.1.0' + + testInstrumentationRunner 'android.test.InstrumentationTestRunner' + buildConfigField 'boolean', 'FORCE_SIMULATION', 'false' + buildConfigField 'String', 'ORCHESTRATOR_BASE_URL', quotedBuildConfig(orchestratorBaseUrl) + buildConfigField 'String', 'ORCHESTRATOR_API_TOKEN', quotedBuildConfig(orchestratorApiToken) + buildConfigField 'String', 'ORCHESTRATOR_VEHICLE_ID', quotedBuildConfig(orchestratorVehicleId) + buildConfigField 'String', 'ORCHESTRATOR_HARDWARE_CLIENT_ID', quotedBuildConfig(orchestratorHardwareClientId) + buildConfigField 'String', 'ORCHESTRATOR_EXECUTION_TARGET', quotedBuildConfig(orchestratorExecutionTarget) + } + + buildTypes { + debug { + applicationIdSuffix '.remote' + minifyEnabled false + } + release { + minifyEnabled false + // Signed with the local debug keystore so the APK can be sideloaded on the + // vehicle head unit. Gateway selection is runtime auto-detection: real + // hardware always uses RealVehicleGateway / RealUsbCanGateway. + signingConfig signingConfigs.debug + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + lint { + disable 'ExpiredTargetSdkVersion' + } + + testOptions { + unitTests.returnDefaultValues = true + } +} + +dependencies { + implementation 'dev.mobile:dadb:2.0.0' + testImplementation 'junit:junit:4.13.2' +} diff --git a/N7Cockpit/app/proguard-rules.pro b/N7Cockpit/app/proguard-rules.pro new file mode 100644 index 0000000..e99068b --- /dev/null +++ b/N7Cockpit/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# The helper is launched by its fully-qualified class name through app_process. +-keep class com.seeker.n7cockpit.AcShellBridge { public static void main(java.lang.String[]); } diff --git a/N7Cockpit/app/src/main/AndroidManifest.xml b/N7Cockpit/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..27d5679 --- /dev/null +++ b/N7Cockpit/app/src/main/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/N7Cockpit/app/src/main/assets/models/n7_crossover.glb b/N7Cockpit/app/src/main/assets/models/n7_crossover.glb new file mode 100644 index 0000000..daa7e1c Binary files /dev/null and b/N7Cockpit/app/src/main/assets/models/n7_crossover.glb differ diff --git a/N7Cockpit/app/src/main/assets/models/paimon.glb b/N7Cockpit/app/src/main/assets/models/paimon.glb new file mode 100644 index 0000000..eafb972 Binary files /dev/null and b/N7Cockpit/app/src/main/assets/models/paimon.glb differ diff --git a/N7Cockpit/app/src/main/assets/models/paimon_clips.pmc b/N7Cockpit/app/src/main/assets/models/paimon_clips.pmc new file mode 100644 index 0000000..f9622aa Binary files /dev/null and b/N7Cockpit/app/src/main/assets/models/paimon_clips.pmc differ diff --git a/N7Cockpit/app/src/main/assets/models/paimon_full.glb b/N7Cockpit/app/src/main/assets/models/paimon_full.glb new file mode 100644 index 0000000..113a4f8 Binary files /dev/null and b/N7Cockpit/app/src/main/assets/models/paimon_full.glb differ diff --git a/N7Cockpit/app/src/main/assets/models/paimon_full_clips.pmc b/N7Cockpit/app/src/main/assets/models/paimon_full_clips.pmc new file mode 100644 index 0000000..e3a4a5f Binary files /dev/null and b/N7Cockpit/app/src/main/assets/models/paimon_full_clips.pmc differ diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcBridgeClient.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcBridgeClient.java new file mode 100644 index 0000000..6589c31 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcBridgeClient.java @@ -0,0 +1,284 @@ +package com.seeker.n7cockpit; + +import android.content.Context; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Talks only to the fixed-function loopback vehicle helper. */ +final class AcBridgeClient { + static final int PORT = 37_652; + static final String PROTOCOL = "N7COCKPIT1"; + + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final SelfAdbActivator activator; + private final Context context; + + AcBridgeClient(Context context) { + this.context = context.getApplicationContext(); + activator = new SelfAdbActivator(this.context); + } + + interface Callback { + void onResult(boolean success, String detail); + } + + void setAcPower(boolean enabled, Callback callback) { + sendVehicleCommand("AC " + (enabled ? "1" : "0"), callback); + } + + void sendVehicleCommand(String command, Callback callback) { + executor.execute(() -> { + String recovery = ""; + if (!isExpectedHelperReady(context)) { + SelfAdbActivator.Result result = activator.ensureHelper(); + if (!result.success) { + callback.onResult(false, result.detail); + return; + } + recovery = "控制服务已恢复;"; + } + send(command, recovery, true, callback); + }); + } + + /** Returns the unformatted wire response for structured parsing by RealVehicleGateway. */ + void sendVehicleCommandRaw(String command, Callback callback) { + executor.execute(() -> { + if (!isExpectedHelperReady(context)) { + SelfAdbActivator.Result result = activator.ensureHelper(); + if (!result.success) { + callback.onResult(false, result.detail); + return; + } + } + send(command, "", false, callback); + }); + } + + void prepare(Callback callback) { + executor.execute(() -> { + if (isExpectedHelperReady(context)) { + callback.onResult(true, "车辆控制服务已就绪"); + return; + } + SelfAdbActivator.Result result = activator.ensureHelper(); + callback.onResult(result.success, result.detail); + }); + } + + static boolean isExpectedHelperReady(Context context) { + try { + String response = exchange(PROTOCOL + " " + BridgeAuth.getOrCreate(context) + + " PING", 500, 900); + return ("OK " + PROTOCOL).equals(response); + } catch (Exception ignored) { + return false; + } + } + + private void send(String command, String prefix, boolean translateResponse, Callback callback) { + final String token; + try { + token = BridgeAuth.getOrCreate(context); + } catch (Exception error) { + callback.onResult(false, "无法读取本机控制凭据"); + return; + } + String response = exchange(PROTOCOL + " " + token + " " + command, + 1_200, 4_000); + if (response == null) { + callback.onResult(false, "helper 无响应"); + return; + } + callback.onResult(response.startsWith("OK "), + prefix + (translateResponse ? translate(response) : response)); + } + + private static String translate(String response) { + if (response.startsWith("ERR safety-speed-unknown")) { + return "安全保护:无法读取车速,本次动作已取消"; + } + if (response.startsWith("ERR safety-speed-too-high")) { + return "安全保护:当前车速超过该动作限制,本次动作已取消"; + } + if (response.startsWith("OK CLIMATE ")) { + return formatClimateStatus(response); + } + if (response.startsWith("OK VEHICLE ")) { + return formatVehicleStatus(response); + } + return response; + } + + private static String formatVehicleStatus(String response) { + String speed = field(response, "speed"); + String soc = field(response, "soc"); + String mileage = field(response, "mileage"); + String power = field(response, "power"); + String v12 = field(response, "v12"); + String gear = gearName(field(response, "gear")); + String gun = chargeGunName(field(response, "gun")); + String bms = field(response, "bms"); + + String line1 = "行驶 " + unit(speed, " km/h") + " · " + gear + + " 能源 SOC " + unit(soc, "%") + " · " + unit(power, " kW") + " · 里程 " + + unit(mileage, " km") + " · 12V " + unit(v12, " V"); + String line2 = "胎压 左前 " + unit(field(response, "tfl"), " kPa") + + " · 右前 " + unit(field(response, "tfr"), " kPa") + + " · 左后 " + unit(field(response, "trl"), " kPa") + + " · 右后 " + unit(field(response, "trr"), " kPa"); + String line3 = "车门 " + fourStates(response, "d", false) + + " 车窗 " + fourStates(response, "w", true) + + " 门锁 " + lockName(field(response, "lock")) + + " · 后备箱 " + rawState(field(response, "trunk")); + String line4 = "传感器 近光灯 " + onOff(field(response, "low")) + + " · 日行灯 " + drlName(field(response, "drlight")) + + " · 光照 " + shown(field(response, "lightlevel")) + "/5" + + " · 雨刷 " + wiperName(field(response, "wiper")) + + " · 自动雨刷 " + onOff(field(response, "autowiper")); + String line5 = "安全/电池 主驾安全带 " + beltName(field(response, "beltfl")) + + " · 副驾安全带 " + beltName(field(response, "beltfr")) + + " · 电池温度 " + unit(field(response, "batmin"), "℃") + "~" + + unit(field(response, "batmax"), "℃") + + " · 充电口 " + gun + " · BMS " + shown(bms) + + " · 钥匙电池 " + keyBatteryName(field(response, "keybat")); + return line1 + "\n" + line2 + "\n" + line3 + "\n" + line4 + "\n" + line5; + } + + private static String fourStates(String response, String prefix, boolean percent) { + String fl = field(response, prefix + "fl"); + String fr = field(response, prefix + "fr"); + String rl = field(response, prefix + "rl"); + String rr = field(response, prefix + "rr"); + if (percent) { + return "主驾" + unit(fl, "%") + " 副驾" + unit(fr, "%") + + " 左后" + unit(rl, "%") + " 右后" + unit(rr, "%"); + } + return "主驾" + openClosed(fl) + " 副驾" + openClosed(fr) + + " 左后" + openClosed(rl) + " 右后" + openClosed(rr); + } + + private static String openClosed(String value) { + return "0".equals(value) ? "关" : "1".equals(value) ? "开" : "未知"; + } + + private static String lockName(String value) { + return "1".equals(value) ? "未锁" : "2".equals(value) ? "已锁" : "未知"; + } + + private static String rawState(String value) { + if ("0".equals(value)) return "关闭"; + if ("1".equals(value)) return "打开"; + return "?".equals(value) ? "未知" : "代码" + value; + } + + private static String gearName(String value) { + switch (value) { + case "1": return "P挡"; + case "2": return "R挡"; + case "3": return "N挡"; + case "4": return "D挡"; + default: return "挡位未知"; + } + } + + private static String chargeGunName(String value) { + switch (value) { + case "1": return "未连接"; + case "2": return "交流"; + case "3": return "直流"; + case "4": return "交流/直流"; + case "5": return "外放电"; + default: return "未知"; + } + } + + private static String onOff(String value) { + return "1".equals(value) ? "开" : "0".equals(value) || "2".equals(value) ? "关" : "未知"; + } + + private static String drlName(String value) { + return "1".equals(value) ? "开" : "2".equals(value) ? "关" : "未知"; + } + + private static String beltName(String value) { + return "1".equals(value) ? "已系" : "0".equals(value) ? "未系" : "未知"; + } + + private static String wiperName(String value) { + return "?".equals(value) ? "未知" : "0".equals(value) ? "停止" : "工作"; + } + + private static String keyBatteryName(String value) { + return "?".equals(value) ? "未知" : "0".equals(value) ? "正常" : "代码" + value; + } + + private static String unit(String value, String suffix) { + return "?".equals(value) ? "未知" : value + suffix; + } + + private static String formatClimateStatus(String response) { + String power = field(response, "power"); + String temp = field(response, "temp"); + String fan = field(response, "fan"); + String cycle = field(response, "cycle"); + String inside = field(response, "inside"); + String outside = field(response, "outside"); + String front = field(response, "front"); + String auto = field(response, "auto"); + return "空调" + ("1".equals(power) ? "开" : "0".equals(power) ? "关" : "未知") + + " · 设定" + degree(temp) + + " · 风量" + shown(fan) + + " · " + ("1".equals(cycle) ? "内循环" : "0".equals(cycle) ? "外循环" : "循环未知") + + " · " + ("0".equals(auto) ? "自动" : "1".equals(auto) ? "手动" : "模式未知") + + " · 前挡除雾" + ("1".equals(front) ? "开" : "0".equals(front) ? "关" : "未知") + + " · 车内" + degree(inside) + " / 车外" + degree(outside); + } + + private static String field(String response, String name) { + String marker = name + "="; + int start = response.indexOf(marker); + if (start < 0) return "?"; + start += marker.length(); + int end = response.indexOf(' ', start); + return end < 0 ? response.substring(start) : response.substring(start, end); + } + + private static String shown(String value) { + return "?".equals(value) ? "未知" : value; + } + + private static String degree(String value) { + return "?".equals(value) ? "未知" : value + "℃"; + } + + private static String exchange(String line, int connectTimeout, int readTimeout) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), PORT), + connectTimeout); + socket.setSoTimeout(readTimeout); + OutputStreamWriter writer = new OutputStreamWriter( + socket.getOutputStream(), StandardCharsets.UTF_8); + writer.write(line + "\n"); + writer.flush(); + BufferedReader reader = new BufferedReader(new InputStreamReader( + socket.getInputStream(), StandardCharsets.UTF_8)); + return reader.readLine(); + } catch (Exception ignored) { + return null; + } + } + + void shutdown() { + executor.shutdownNow(); + } +} + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcShellBridge.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcShellBridge.java new file mode 100644 index 0000000..b235bb7 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AcShellBridge.java @@ -0,0 +1,422 @@ +package com.seeker.n7cockpit; + +import android.os.IBinder; +import android.os.Parcel; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +/** + * Shell-UID fixed-function vehicle bridge started with app_process. + * + * The loopback protocol is deliberately an allowlist: callers cannot provide a device id, + * feature id, Binder transaction, CAN frame, or shell command. Addresses and accepted values + * come from BYDMate's live-validated DiLink 5 allowlist. + */ +public final class AcShellBridge { + private static final String PROTOCOL = "N7COCKPIT1"; + private static final int TX_GET_INT = 5; + private static final int TX_SET_INT = 6; + private static final int TX_GET_FLOAT = 7; + + private static final int DEV_AC = 1000; + private static final int FID_AC_POWER_SET = 501_219_364; + private static final int FID_AC_AUTO_SET = 501_219_352; + private static final int FID_AC_CYCLE_SET = 501_219_355; + private static final int FID_AC_TEMP_SET = 501_219_368; + private static final int FID_DEFROST_FRONT_SET = 501_219_362; + private static final int FID_DEFROST_REAR_SET = 501_219_357; + + private static final int FID_AC_POWER_READ = 1_077_936_144; + private static final int FID_AC_TEMP_READ = 1_077_936_168; + private static final int FID_AC_FAN_READ = 1_077_936_156; + private static final int FID_AC_CYCLE_READ = 1_077_936_148; + private static final int FID_INSIDE_TEMP_READ = 1_031_798_832; + private static final int FID_OUTSIDE_TEMP_READ = 1_077_936_184; + private static final int FID_DEFROST_FRONT_READ = 1_077_936_150; + private static final int FID_AC_AUTO_READ = 1_077_936_146; + + private static final int DEV_BODY = 1001; + private static final int[] WINDOW_POSITION_FIDS = { + 1_276_219_408, // driver + 1_276_219_424, // front passenger + 1_276_219_416, // rear left + 1_276_219_432 // rear right + }; + private static final int FID_SUNSHADE_SET = 1_125_122_060; + + private static final int DEV_GLOBAL = 1023; + private static final int FID_INTERIOR_LIGHT_SET = 1_330_643_002; + private static final int FID_AMBIENT_LIGHT_SET = 1_069_547_536; + private static final int DEV_LIGHT = 1004; + private static final int FID_DRL_SET = 1_125_122_118; + + private static final int DEV_SPEED = 1013; + private static final int FID_SPEED = -1_807_745_016; + + private AcShellBridge() {} + + public static void main(String[] args) throws Exception { + if (args.length != 1 || !args[0].matches("[0-9a-f]{64}")) { + System.err.println("ERR missing bridge authentication"); + System.exit(3); + return; + } + String authToken = args[0]; + IBinder autoService = resolveAutoService(); + if (autoService == null) { + System.err.println("ERR autoservice not found"); + System.exit(2); + return; + } + + try (ServerSocket server = new ServerSocket( + AcBridgeClient.PORT, 4, InetAddress.getLoopbackAddress())) { + server.setReuseAddress(true); + System.out.println("READY protocol=" + PROTOCOL + " port=" + AcBridgeClient.PORT + + " pid=" + android.os.Process.myPid()); + System.out.flush(); + + while (true) { + try (Socket client = server.accept()) { + client.setSoTimeout(3_000); + handleClient(autoService, authToken, client); + } catch (Exception error) { + System.err.println("CLIENT_ERR " + error); + } + } + } + } + + private static void handleClient(IBinder service, String authToken, Socket client) throws Exception { + BufferedReader reader = new BufferedReader(new InputStreamReader( + client.getInputStream(), StandardCharsets.UTF_8)); + OutputStreamWriter writer = new OutputStreamWriter( + client.getOutputStream(), StandardCharsets.UTF_8); + + String line = reader.readLine(); + String prefix = PROTOCOL + " " + authToken + " "; + if (line == null || !line.startsWith(prefix)) { + writer.write("ERR authentication-required\n"); + writer.flush(); + return; + } + line = PROTOCOL + " " + line.substring(prefix.length()); + String response; + if ((PROTOCOL + " PING").equals(line)) { + response = "OK " + PROTOCOL; + } else if ((PROTOCOL + " VEHICLE STATUS").equals(line)) { + response = readVehicleStatus(service); + } else if ((PROTOCOL + " AC 0").equals(line) || (PROTOCOL + " AC 1").equals(line)) { + int value = line.endsWith("1") ? 1 : 0; + response = writeOne(service, DEV_AC, FID_AC_POWER_SET, value, "AC"); + } else if (line.startsWith(PROTOCOL + " CLIMATE ")) { + response = handleClimate(service, + line.substring((PROTOCOL + " CLIMATE ").length())); + } else if (line != null && line.startsWith(PROTOCOL + " WINDOWS ")) { + response = handleWindows(service, line.substring((PROTOCOL + " WINDOWS ").length())); + } else if (line.startsWith(PROTOCOL + " WINDOW ")) { + response = handleIndividualWindow(service, + line.substring((PROTOCOL + " WINDOW ").length())); + } else if (line != null && line.startsWith(PROTOCOL + " SUNSHADE ")) { + response = handleSunshade(service, line.substring((PROTOCOL + " SUNSHADE ").length())); + } else if (line != null && line.startsWith(PROTOCOL + " LIGHT ")) { + response = handleLight(service, line.substring((PROTOCOL + " LIGHT ").length())); + } else { + response = "ERR invalid-command"; + } + + writer.write(response + "\n"); + writer.flush(); + } + + private static String readVehicleStatus(IBinder service) throws Exception { + String speed = readFloatText(service, 1013, -1_807_745_016, -1f, 350f); + String soc = readFloatText(service, 1014, 1_246_777_400, 0f, 100f); + int mileageRaw = readInt(service, 1014, 1_246_765_072); + String mileage = mileageRaw == Integer.MIN_VALUE || mileageRaw < 0 + ? "?" : String.format(Locale.US, "%.1f", mileageRaw / 10f); + String power = boundedIntText(readInt(service, 1012, 339_738_656), -500, 500); + String voltage12v = readFloatText(service, 1001, 1_128_267_816, 5f, 20f); + String gear = boundedIntText(readInt(service, 1011, 555_745_336), 0, 10); + String chargeGun = boundedIntText(readInt(service, 1009, 876_609_586), 0, 20); + String bms = boundedIntText(readInt(service, 1009, 876_609_560), 0, 100); + + String tireFl = boundedIntText(readInt(service, 1016, -1_728_052_956), 50, 500); + String tireFr = boundedIntText(readInt(service, 1016, -1_728_052_952), 50, 500); + String tireRl = boundedIntText(readInt(service, 1016, -1_728_052_948), 50, 500); + String tireRr = boundedIntText(readInt(service, 1016, -1_728_052_944), 50, 500); + + String doorFl = boundedIntText(readInt(service, 1001, 692_060_168), 0, 10); + String doorFr = boundedIntText(readInt(service, 1001, 692_060_170), 0, 10); + String doorRl = boundedIntText(readInt(service, 1001, 692_060_172), 0, 10); + String doorRr = boundedIntText(readInt(service, 1001, 692_060_174), 0, 10); + String windowFl = boundedIntText(readInt(service, 1001, 947_912_728), 0, 100); + String windowFr = boundedIntText(readInt(service, 1001, 1_267_728_400), 0, 100); + String windowRl = boundedIntText(readInt(service, 1001, 947_912_736), 0, 100); + String windowRr = boundedIntText(readInt(service, 1001, 947_912_752), 0, 100); + String trunk = boundedIntText(readInt(service, 1001, 1_074_790_416), 0, 10); + String hood = boundedIntText(readInt(service, 1001, 692_060_188), 0, 10); + String lock = boundedIntText(readInt(service, 1032, 1_081_081_864), 0, 10); + + String lowBeam = boundedIntText(readInt(service, 1004, 950_009_866), 0, 10); + String drl = boundedIntText(readInt(service, 1004, 1_231_040_528), 0, 10); + String lightLevel = boundedIntText(readInt(service, 1043, 315_621_396), 0, 10); + String wiper = boundedIntText(readInt(service, 1046, 1_336_934_438), 0, 100); + String autoWiper = boundedIntText(readInt(service, 1046, 321_912_862), 0, 10); + String seatbeltFl = boundedIntText(readInt(service, 1007, 692_060_184), 0, 10); + String seatbeltFr = boundedIntText(readInt(service, 1042, 315_621_439), 0, 10); + int batteryMaxRaw = readInt(service, 1014, 1_148_190_752); + int batteryMinRaw = readInt(service, 1014, 1_148_190_736); + String batteryMax = temperatureOffsetText(batteryMaxRaw); + String batteryMin = temperatureOffsetText(batteryMinRaw); + String keyBattery = boundedIntText(readInt(service, 1014, 402_653_200), 0, 100); + + return "OK VEHICLE speed=" + speed + " soc=" + soc + " mileage=" + mileage + + " power=" + power + " v12=" + voltage12v + " gear=" + gear + + " gun=" + chargeGun + " bms=" + bms + + " tfl=" + tireFl + " tfr=" + tireFr + " trl=" + tireRl + " trr=" + tireRr + + " dfl=" + doorFl + " dfr=" + doorFr + " drl=" + doorRl + " drr=" + doorRr + + " wfl=" + windowFl + " wfr=" + windowFr + " wrl=" + windowRl + " wrr=" + windowRr + + " trunk=" + trunk + " hood=" + hood + " lock=" + lock + + " low=" + lowBeam + " drlight=" + drl + " lightlevel=" + lightLevel + + " wiper=" + wiper + " autowiper=" + autoWiper + + " beltfl=" + seatbeltFl + " beltfr=" + seatbeltFr + + " batmax=" + batteryMax + " batmin=" + batteryMin + " keybat=" + keyBattery; + } + + private static String readFloatText(IBinder service, int device, int fid, + float minimum, float maximum) throws Exception { + TransactResult result = transact(service, TX_GET_FLOAT, device, fid, 0, false); + if (result.status != 0) return "?"; + float value = Float.intBitsToFloat(result.value); + if (!Float.isFinite(value) || value < minimum || value > maximum) return "?"; + return String.format(Locale.US, "%.1f", value); + } + + private static String boundedIntText(int value, int minimum, int maximum) { + if (value == Integer.MIN_VALUE || value < minimum || value > maximum) return "?"; + return Integer.toString(value); + } + + private static String temperatureOffsetText(int raw) { + if (raw == Integer.MIN_VALUE) return "?"; + int celsius = raw - 40; + return celsius >= -50 && celsius <= 100 ? Integer.toString(celsius) : "?"; + } + + private static String handleClimate(IBinder service, String argument) throws Exception { + if ("STATUS".equals(argument)) return readClimateStatus(service); + if ("POWER 0".equals(argument) || "POWER 1".equals(argument)) { + int value = argument.endsWith("1") ? 1 : 0; + return writeOne(service, DEV_AC, FID_AC_POWER_SET, value, "CLIMATE POWER"); + } + if ("AUTO 0".equals(argument) || "AUTO 1".equals(argument)) { + boolean auto = argument.endsWith("1"); + return writeOne(service, DEV_AC, FID_AC_AUTO_SET, auto ? 0 : 1, + "CLIMATE AUTO"); + } + if ("CYCLE INNER".equals(argument) || "CYCLE OUTER".equals(argument)) { + int value = argument.endsWith("INNER") ? 1 : 0; + return writeOne(service, DEV_AC, FID_AC_CYCLE_SET, value, "CLIMATE CYCLE"); + } + if ("DEFROST_FRONT 0".equals(argument) || "DEFROST_FRONT 1".equals(argument)) { + int value = argument.endsWith("1") ? 1 : 0; + return writeOne(service, DEV_AC, FID_DEFROST_FRONT_SET, value, + "CLIMATE DEFROST_FRONT"); + } + if ("DEFROST_REAR 0".equals(argument) || "DEFROST_REAR 1".equals(argument)) { + int value = argument.endsWith("1") ? 1 : 0; + return writeOne(service, DEV_AC, FID_DEFROST_REAR_SET, value, + "CLIMATE DEFROST_REAR"); + } + if (argument.startsWith("TEMP ")) { + try { + int value = Integer.parseInt(argument.substring(5)); + if (value < 16 || value > 30) return "ERR invalid-climate-temperature"; + return writeOne(service, DEV_AC, FID_AC_TEMP_SET, value, "CLIMATE TEMP"); + } catch (NumberFormatException ignored) { + return "ERR invalid-climate-temperature"; + } + } + return "ERR invalid-climate-command"; + } + + private static String readClimateStatus(IBinder service) throws Exception { + int power = readInt(service, DEV_AC, FID_AC_POWER_READ); + int temp = readInt(service, DEV_AC, FID_AC_TEMP_READ); + int fan = readInt(service, DEV_AC, FID_AC_FAN_READ); + int cycle = readInt(service, DEV_AC, FID_AC_CYCLE_READ); + int inside = readInt(service, DEV_AC, FID_INSIDE_TEMP_READ); + int outside = readInt(service, DEV_AC, FID_OUTSIDE_TEMP_READ); + int frontDefrost = readInt(service, DEV_AC, FID_DEFROST_FRONT_READ); + int autoMode = readInt(service, DEV_AC, FID_AC_AUTO_READ); + return "OK CLIMATE power=" + valueText(power) + + " temp=" + valueText(temp) + + " fan=" + valueText(fan) + + " cycle=" + valueText(cycle) + + " inside=" + valueText(inside) + + " outside=" + valueText(outside) + + " front=" + valueText(frontDefrost) + + " auto=" + valueText(autoMode); + } + + private static int readInt(IBinder service, int device, int fid) throws Exception { + TransactResult result = transact(service, TX_GET_INT, device, fid, 0, false); + return result.status == 0 ? result.value : Integer.MIN_VALUE; + } + + private static String valueText(int value) { + return value == Integer.MIN_VALUE ? "?" : Integer.toString(value); + } + + private static String handleIndividualWindow(IBinder service, String argument) throws Exception { + String[] parts = argument.split(" "); + if (parts.length != 2 || !("0".equals(parts[1]) || "100".equals(parts[1]))) { + return "ERR invalid-window-command"; + } + final int index; + switch (parts[0]) { + case "FL": index = 0; break; + case "FR": index = 1; break; + case "RL": index = 2; break; + case "RR": index = 3; break; + default: return "ERR invalid-window-target"; + } + int percent = Integer.parseInt(parts[1]); + if (percent > 0) { + String blocked = speedBlockReason(service, 5f); + if (blocked != null) return blocked; + } + return writeOne(service, DEV_BODY, WINDOW_POSITION_FIDS[index], percent, + "WINDOW " + parts[0]); + } + + private static String handleWindows(IBinder service, String argument) throws Exception { + final int percent; + if ("0".equals(argument)) percent = 0; + else if ("10".equals(argument)) percent = 10; + else if ("50".equals(argument)) percent = 50; + else if ("100".equals(argument)) percent = 100; + else return "ERR invalid-window-position"; + + if (percent > 0) { + String blocked = speedBlockReason(service, 5f); + if (blocked != null) return blocked; + } + + StringBuilder statuses = new StringBuilder(); + boolean accepted = true; + for (int i = 0; i < WINDOW_POSITION_FIDS.length; i++) { + if (i > 0) Thread.sleep(150); // BYDMate: prevent dropped writes on some DiLink ECUs. + TransactResult result = transact(service, TX_SET_INT, DEV_BODY, + WINDOW_POSITION_FIDS[i], percent, true); + if (i > 0) statuses.append(','); + statuses.append(result.status); + if (result.status < 0) accepted = false; + } + return (accepted ? "OK" : "ERR") + " WINDOWS percent=" + percent + + " status=" + statuses; + } + + private static String handleSunshade(IBinder service, String mode) throws Exception { + final int value; + switch (mode) { + case "OPEN": value = 1; break; + case "CLOSE": value = 2; break; + default: return "ERR invalid-sunshade-mode"; + } + String blocked = speedBlockReason(service, 5f); + if (blocked != null) return blocked; + return writeOne(service, DEV_BODY, FID_SUNSHADE_SET, value, "SUNSHADE " + mode); + } + + private static String handleLight(IBinder service, String argument) throws Exception { + String[] parts = argument.split(" "); + if (parts.length != 2 || !("0".equals(parts[1]) || "1".equals(parts[1]))) { + return "ERR invalid-light-command"; + } + boolean enabled = "1".equals(parts[1]); + switch (parts[0]) { + case "INTERIOR": + return writeOne(service, DEV_GLOBAL, FID_INTERIOR_LIGHT_SET, + enabled ? 2 : 1, "LIGHT INTERIOR"); + case "AMBIENT": + return writeOne(service, DEV_GLOBAL, FID_AMBIENT_LIGHT_SET, + enabled ? 5 : 1, "LIGHT AMBIENT"); + case "DRL": + // Turning exterior DRLs off is parked-only and fail-closed on unknown speed. + if (!enabled) { + String blocked = speedBlockReason(service, 0.5f); + if (blocked != null) return blocked; + } + return writeOne(service, DEV_LIGHT, FID_DRL_SET, + enabled ? 1 : 2, "LIGHT DRL"); + default: + return "ERR invalid-light-target"; + } + } + + private static String speedBlockReason(IBinder service, float maximumKph) throws Exception { + TransactResult result = transact(service, TX_GET_FLOAT, DEV_SPEED, FID_SPEED, 0, false); + if (result.status != 0) return "ERR safety-speed-unknown status=" + result.status; + float speed = Float.intBitsToFloat(result.value); + if (!Float.isFinite(speed) || speed < 0f) return "ERR safety-speed-unknown"; + if (speed > maximumKph) return "ERR safety-speed-too-high kph=" + speed + + " limit=" + maximumKph; + return null; + } + + private static String writeOne(IBinder service, int device, int fid, int value, + String label) throws Exception { + TransactResult result = transact(service, TX_SET_INT, device, fid, value, true); + if (result.status >= 0) { + return "OK " + label + " status=" + result.status + " value=" + value; + } + return "ERR " + label + " autoservice-status=" + result.status; + } + + private static IBinder resolveAutoService() throws Exception { + Class serviceManager = Class.forName("android.os.ServiceManager"); + Method getService = serviceManager.getDeclaredMethod("getService", String.class); + return (IBinder) getService.invoke(null, "autoservice"); + } + + private static TransactResult transact(IBinder service, int transaction, int device, + int fid, int value, boolean writeValue) throws Exception { + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(service.getInterfaceDescriptor()); + data.writeInt(device); + data.writeInt(fid); + if (writeValue) data.writeInt(value); + boolean handled = service.transact(transaction, data, reply, 0); + if (!handled || reply.dataAvail() < 4) return new TransactResult(-999, 0); + int status = reply.readInt(); + int returned = reply.dataAvail() >= 4 ? reply.readInt() : 0; + return new TransactResult(status, returned); + } finally { + data.recycle(); + reply.recycle(); + } + } + + private static final class TransactResult { + final int status; + final int value; + + TransactResult(int status, int value) { + this.status = status; + this.value = value; + } + } +} + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AudioReactivePlayer.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AudioReactivePlayer.java new file mode 100644 index 0000000..da7ef5c --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/AudioReactivePlayer.java @@ -0,0 +1,479 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; + +/** + * Generates the original local test melody, loops it, and analyzes the exact PCM around the live + * AudioTrack playback head. No microphone permission or bundled music file is required. + */ +public final class AudioReactivePlayer implements AutoCloseable { + private static final String TAG = "N7AudioReactive"; + /** Reasons for an asynchronous stop that the owning service must react to. */ + public enum StopReason { + AUDIO_FOCUS_LOSS + } + + /** Receives stops initiated by the audio system rather than by the UI/service owner. */ + public interface PlaybackListener { + void onPlaybackStopped(StopReason reason); + } + + public static final int SAMPLE_RATE = 44_100; + private static final double NOTE_SECONDS = 0.38; + /** Stable musical grid used by the mascot for beat-locked body accents. */ + static final long RHYTHM_BEAT_FRAMES = Math.round(SAMPLE_RATE * NOTE_SECONDS); + private static final int ANALYSIS_WINDOW = 2_048; + private static final long ANALYSIS_INTERVAL_MS = 32L; + private static final double[] NOTES = { + 440.0, 554.37, 659.25, 554.37, 493.88, 659.25, 783.99, 659.25 + }; + private static final double[] SPECTRAL_PROBES = { + 110.0, 164.81, 220.0, 277.18, + 329.63, 392.0, 440.0, 493.88, 554.37, 659.25, 783.99, 880.0, + 987.77, 1318.51, 1760.0, 2349.32, 3135.96 + }; + private static final double[] GOERTZEL_COEFFICIENTS = createGoertzelCoefficients(); + private static final double[] HANN_WINDOW = createHannWindow(); + + private final Object lock = new Object(); + private final AudioManager audioManager; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final short[] pcm; + private final AudioAttributes audioAttributes; + private final PlaybackListener playbackListener; + + private AudioTrack audioTrack; + private AudioFocusRequest focusRequest; + private HandlerThread analysisThread; + private Handler analysisHandler; + private boolean closed; + private volatile boolean playing; + private volatile MascotActionBus.AudioFrame lastFrame = MascotActionBus.AudioFrame.SILENCE; + + private final Runnable analyzeRunnable = new Runnable() { + @Override + public void run() { + AudioTrack current; + Handler handler; + synchronized (lock) { + current = audioTrack; + handler = analysisHandler; + } + if (current == null || handler == null) return; + + MascotActionBus.AudioFrame analyzedFrame = null; + try { + if (current.getPlayState() == AudioTrack.PLAYSTATE_PLAYING) { + long head = Integer.toUnsignedLong(current.getPlaybackHeadPosition()); + analyzedFrame = analyzeAt(head); + } + } catch (IllegalStateException ignored) { + // Audio routes can disappear between the state and playback-head reads. + } + + synchronized (lock) { + if (audioTrack != current || analysisHandler != handler || !playing) return; + // Publish while the player lock still proves this frame is current. stop() takes + // the same lock, so its final SILENCE/IDLE state cannot be overwritten by a late + // analyzer iteration. + if (analyzedFrame != null) { + lastFrame = analyzedFrame; + MascotActionBus.publishAudioFrame(analyzedFrame); + if (MascotActionBus.getAction() == MascotActionBus.Action.IDLE) { + MascotActionBus.setAction(MascotActionBus.Action.MUSIC); + } + } + handler.postDelayed(this, ANALYSIS_INTERVAL_MS); + } + } + }; + + public AudioReactivePlayer(Context context) { + this(context, null); + } + + public AudioReactivePlayer(Context context, PlaybackListener playbackListener) { + Context applicationContext = context.getApplicationContext(); + audioManager = (AudioManager) applicationContext.getSystemService(Context.AUDIO_SERVICE); + this.playbackListener = playbackListener; + audioAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + pcm = createMelody(); + } + + /** Starts or keeps playing the looping melody. Returns false when focus/output is unavailable. */ + public boolean start() { + synchronized (lock) { + if (closed) return false; + if (audioTrack != null) { + resumeLocked(); + return playing; + } + if (!requestAudioFocusLocked()) return false; + + AudioTrack candidate = null; + try { + candidate = new AudioTrack.Builder() + .setAudioAttributes(audioAttributes) + .setAudioFormat(new AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(SAMPLE_RATE) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build()) + .setTransferMode(AudioTrack.MODE_STATIC) + .setBufferSizeInBytes(pcm.length * 2) + .build(); + + int initialState = candidate.getState(); + // MODE_STATIC legitimately reports STATE_NO_STATIC_DATA until its first write. + // Reject only a truly uninitialized output route at this stage. + if (initialState != AudioTrack.STATE_INITIALIZED + && initialState != AudioTrack.STATE_NO_STATIC_DATA) { + Log.e(TAG, "AudioTrack did not initialize; state=" + candidate.getState()); + releaseQuietly(candidate); + abandonAudioFocusLocked(); + return false; + } + if (!writeAll(candidate)) { + Log.e(TAG, "AudioTrack rejected generated PCM; samples=" + pcm.length); + releaseQuietly(candidate); + abandonAudioFocusLocked(); + return false; + } + if (candidate.getState() != AudioTrack.STATE_INITIALIZED) { + Log.e(TAG, "AudioTrack stayed without static data after PCM write; state=" + + candidate.getState()); + releaseQuietly(candidate); + abandonAudioFocusLocked(); + return false; + } + int loopResult = candidate.setLoopPoints(0, pcm.length, -1); + if (loopResult != AudioTrack.SUCCESS) { + Log.e(TAG, "AudioTrack rejected static loop points; result=" + loopResult + + ", samples=" + pcm.length); + releaseQuietly(candidate); + abandonAudioFocusLocked(); + return false; + } + + audioTrack = candidate; + playing = true; + startAnalysisLocked(); + candidate.play(); + } catch (RuntimeException failure) { + Log.e(TAG, "AudioTrack startup failed", failure); + audioTrack = null; + playing = false; + stopAnalysisLocked(); + abandonAudioFocusLocked(); + if (candidate != null) releaseQuietly(candidate); + return false; + } + } + + MascotActionBus.setAction(MascotActionBus.Action.MUSIC); + return true; + } + + /** Stops playback, analyzer callbacks, and releases the app's audio focus immediately. */ + public void stop() { + stopInternal(null); + } + + private void stopInternal(StopReason asynchronousReason) { + AudioTrack oldTrack; + AudioFocusRequest oldFocus; + boolean hadPlayback; + synchronized (lock) { + oldTrack = audioTrack; + hadPlayback = oldTrack != null || focusRequest != null || playing; + audioTrack = null; + playing = false; + stopAnalysisLocked(); + oldFocus = focusRequest; + focusRequest = null; + } + + if (oldTrack != null) { + try { + oldTrack.pause(); + oldTrack.flush(); + } catch (IllegalStateException ignored) { + // Route teardown may already have stopped the track. + } + releaseQuietly(oldTrack); + } + if (oldFocus != null && audioManager != null) { + try { + audioManager.abandonAudioFocusRequest(oldFocus); + } catch (RuntimeException ignored) { + // Some vendor audio services throw while shutting down. + } + } + + lastFrame = MascotActionBus.AudioFrame.SILENCE; + MascotActionBus.publishAudioFrame(MascotActionBus.AudioFrame.SILENCE); + if (MascotActionBus.getAction() == MascotActionBus.Action.MUSIC) { + MascotActionBus.setAction(MascotActionBus.Action.IDLE); + } + if (hadPlayback && asynchronousReason != null && playbackListener != null) { + playbackListener.onPlaybackStopped(asynchronousReason); + } + } + + public boolean isPlaying() { + return playing; + } + + public MascotActionBus.AudioFrame getLastFrame() { + return lastFrame; + } + + public int getMelodyDurationMs() { + return Math.round(pcm.length * 1000f / SAMPLE_RATE); + } + + /** Permanently closes this instance. Create a new player if playback is needed afterwards. */ + @Override + public void close() { + synchronized (lock) { + closed = true; + } + stop(); + } + + private boolean requestAudioFocusLocked() { + if (audioManager == null) return false; + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) + .setAudioAttributes(audioAttributes) + .setAcceptsDelayedFocusGain(false) + .setWillPauseWhenDucked(false) + .setOnAudioFocusChangeListener(this::onAudioFocusChange, mainHandler) + .build(); + try { + int result = audioManager.requestAudioFocus(focusRequest); + if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) return true; + Log.e(TAG, "Media audio focus request was denied; result=" + result); + } catch (RuntimeException ignored) { + Log.e(TAG, "Media audio focus request failed", ignored); + // Failure is surfaced through the boolean start contract. + } + focusRequest = null; + return false; + } + + private void abandonAudioFocusLocked() { + AudioFocusRequest oldFocus = focusRequest; + focusRequest = null; + if (oldFocus != null && audioManager != null) { + try { + audioManager.abandonAudioFocusRequest(oldFocus); + } catch (RuntimeException ignored) { + // Ignore vendor service shutdown races. + } + } + } + + private void onAudioFocusChange(int focusChange) { + if (focusChange == AudioManager.AUDIOFOCUS_LOSS + || focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) { + // A foreground test player must not keep a stale MUSIC animation or notification + // after another app takes focus. A new explicit play request can start it again. + stopInternal(StopReason.AUDIO_FOCUS_LOSS); + return; + } + + synchronized (lock) { + AudioTrack current = audioTrack; + if (current == null) return; + try { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + current.setVolume(0.22f); + break; + case AudioManager.AUDIOFOCUS_GAIN: + current.setVolume(1f); + break; + default: + break; + } + } catch (IllegalStateException ignored) { + // The next explicit stop/start can rebuild a lost audio route. + } + } + } + + private void resumeLocked() { + AudioTrack current = audioTrack; + if (current == null) return; + try { + current.setVolume(1f); + if (current.getPlayState() != AudioTrack.PLAYSTATE_PLAYING) current.play(); + playing = true; + MascotActionBus.setAction(MascotActionBus.Action.MUSIC); + } catch (IllegalStateException ignored) { + playing = false; + } + } + + private boolean writeAll(AudioTrack track) { + int offset = 0; + while (offset < pcm.length) { + int written = track.write(pcm, offset, pcm.length - offset, AudioTrack.WRITE_BLOCKING); + if (written <= 0) { + Log.e(TAG, "AudioTrack.write failed; result=" + written + ", offset=" + offset); + return false; + } + offset += written; + } + return true; + } + + private void startAnalysisLocked() { + stopAnalysisLocked(); + analysisThread = new HandlerThread( + "LumiPcmAnalyzer", Process.THREAD_PRIORITY_AUDIO); + analysisThread.start(); + analysisHandler = new Handler(analysisThread.getLooper()); + analysisHandler.post(analyzeRunnable); + } + + private void stopAnalysisLocked() { + Handler handler = analysisHandler; + HandlerThread thread = analysisThread; + analysisHandler = null; + analysisThread = null; + if (handler != null) handler.removeCallbacksAndMessages(null); + if (thread != null) thread.quitSafely(); + } + + private MascotActionBus.AudioFrame analyzeAt(long playbackHead) { + int end = (int) (playbackHead % pcm.length); + double squareSum = 0.0; + int crossings = 0; + int previousSign = 0; + + for (int i = 0; i < ANALYSIS_WINDOW; i++) { + int index = end - ANALYSIS_WINDOW + i; + if (index < 0) index += pcm.length; + double normalized = pcm[index] / 32768.0; + squareSum += normalized * normalized; + + int sign = normalized > 0.0035 ? 1 : (normalized < -0.0035 ? -1 : previousSign); + if (previousSign != 0 && sign != previousSign) crossings++; + previousSign = sign; + } + + float rms = (float) Math.sqrt(squareSum / ANALYSIS_WINDOW); + float zeroCrossHz = crossings * SAMPLE_RATE / (2f * ANALYSIS_WINDOW); + double bassSquares = 0.0; + double midSquares = 0.0; + double trebleSquares = 0.0; + double strongest = 0.0; + float dominant = zeroCrossHz; + + for (int probeIndex = 0; probeIndex < SPECTRAL_PROBES.length; probeIndex++) { + double probe = SPECTRAL_PROBES[probeIndex]; + double amplitude = goertzelAmplitude(end, probeIndex); + double energy = amplitude * amplitude; + if (probe < 300.0) bassSquares += energy; + else if (probe < 1_000.0) midSquares += energy; + else trebleSquares += energy; + if (amplitude > strongest) { + strongest = amplitude; + dominant = (float) probe; + } + } + + if (strongest < 0.006 && zeroCrossHz < 4_000f) dominant = zeroCrossHz; + float bass = unit((float) Math.sqrt(bassSquares) * 4.2f); + float mid = unit((float) Math.sqrt(midSquares) * 3.4f); + float treble = unit((float) Math.sqrt(trebleSquares) * 4.0f); + return new MascotActionBus.AudioFrame( + rms, bass, mid, treble, dominant, playbackHead, SystemClock.uptimeMillis()); + } + + private double goertzelAmplitude(int end, int probeIndex) { + double coefficient = GOERTZEL_COEFFICIENTS[probeIndex]; + double previous = 0.0; + double previous2 = 0.0; + for (int i = 0; i < ANALYSIS_WINDOW; i++) { + int index = end - ANALYSIS_WINDOW + i; + if (index < 0) index += pcm.length; + double sample = pcm[index] / 32768.0; + // Hann window reduces false energy when the playback head straddles two notes. + double state = sample * HANN_WINDOW[i] + coefficient * previous - previous2; + previous2 = previous; + previous = state; + } + double power = previous2 * previous2 + previous * previous + - coefficient * previous * previous2; + return 4.0 * Math.sqrt(Math.max(0.0, power)) / ANALYSIS_WINDOW; + } + + private static double[] createGoertzelCoefficients() { + double[] coefficients = new double[SPECTRAL_PROBES.length]; + for (int i = 0; i < SPECTRAL_PROBES.length; i++) { + double omega = 2.0 * Math.PI * SPECTRAL_PROBES[i] / SAMPLE_RATE; + coefficients[i] = 2.0 * Math.cos(omega); + } + return coefficients; + } + + private static double[] createHannWindow() { + double[] window = new double[ANALYSIS_WINDOW]; + for (int i = 0; i < ANALYSIS_WINDOW; i++) { + window[i] = 0.5 + - 0.5 * Math.cos(2.0 * Math.PI * i / (ANALYSIS_WINDOW - 1)); + } + return window; + } + + private short[] createMelody() { + int samplesPerNote = (int) (SAMPLE_RATE * NOTE_SECONDS); + short[] result = new short[samplesPerNote * NOTES.length]; + int fadeSamples = (int) (SAMPLE_RATE * 0.018); + + for (int noteIndex = 0; noteIndex < NOTES.length; noteIndex++) { + double frequency = NOTES[noteIndex]; + for (int i = 0; i < samplesPerNote; i++) { + double envelope = 1.0; + if (i < fadeSamples) envelope = i / (double) fadeSamples; + int remaining = samplesPerNote - i - 1; + if (remaining < fadeSamples) envelope *= remaining / (double) fadeSamples; + + double time = i / (double) SAMPLE_RATE; + double sample = Math.sin(2.0 * Math.PI * frequency * time) + + 0.18 * Math.sin(4.0 * Math.PI * frequency * time); + result[noteIndex * samplesPerNote + i] = + (short) (sample * envelope * Short.MAX_VALUE * 0.22); + } + } + return result; + } + + private static float unit(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + private static void releaseQuietly(AudioTrack track) { + try { + track.release(); + } catch (RuntimeException ignored) { + // release() is best-effort on vendor audio implementations. + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BootReceiver.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BootReceiver.java new file mode 100644 index 0000000..6bfdeed --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BootReceiver.java @@ -0,0 +1,22 @@ +package com.seeker.n7cockpit; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +/** Restores the helper after a real Android reboot once self-ADB was authorized. */ +public final class BootReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; + Intent service = new Intent(context, SelfActivationService.class); + if (Build.VERSION.SDK_INT >= 26) { + context.startForegroundService(service); + } else { + context.startService(service); + } + } +} + + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BridgeAuth.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BridgeAuth.java new file mode 100644 index 0000000..f238e83 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/BridgeAuth.java @@ -0,0 +1,41 @@ +package com.seeker.n7cockpit; + +import android.content.Context; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; + +/** Per-install secret used to authenticate the app to its loopback shell helper. */ +final class BridgeAuth { + private static final String FILE_NAME = "cockpit-vehicle-bridge-token"; + + private BridgeAuth() {} + + static synchronized String getOrCreate(Context context) throws Exception { + File file = new File(context.getNoBackupFilesDir(), FILE_NAME); + if (file.isFile()) { + byte[] bytes = new byte[(int) file.length()]; + int read; + try (FileInputStream input = new FileInputStream(file)) { + read = input.read(bytes); + } + String existing = new String(bytes, 0, Math.max(0, read), + StandardCharsets.US_ASCII).trim(); + if (existing.matches("[0-9a-f]{64}")) return existing; + } + + byte[] random = new byte[32]; + new SecureRandom().nextBytes(random); + StringBuilder token = new StringBuilder(64); + for (byte value : random) token.append(String.format("%02x", value & 0xff)); + try (FileOutputStream output = new FileOutputStream(file, false)) { + output.write(token.toString().getBytes(StandardCharsets.US_ASCII)); + } + return token.toString(); + } +} + + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ClimateDialView.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ClimateDialView.java new file mode 100644 index 0000000..afa63ee --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ClimateDialView.java @@ -0,0 +1,674 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Shader; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + +import java.util.Locale; + +/** + * Wide climate strip combining temperature, fan, AUTO and recirculation. + * Every interactive region is at least 56dp and all geometry follows the + * measured bounds. + */ +public class ClimateDialView extends View { + public interface OnClimateChangeListener { + void onTemperatureRequested(float celsius); + + void onFanLevelRequested(int level); + + void onPowerRequested(boolean powerOn); + + void onAutoModeRequested(boolean autoMode); + + void onRecirculationRequested(boolean recirculationOn); + } + + private enum HitTarget { + NONE, + TEMPERATURE_DOWN, + TEMPERATURE_UP, + POWER, + AUTO, + RECIRCULATION, + FAN + } + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF cardRect = new RectF(); + private final RectF contentRect = new RectF(); + private final RectF temperatureDownRect = new RectF(); + private final RectF temperatureUpRect = new RectF(); + private final RectF powerRect = new RectF(); + private final RectF autoRect = new RectF(); + private final RectF recirculationRect = new RectF(); + private RectF[] fanHitRects = new RectF[7]; + private Shader surfaceShader; + private String temperatureText = "25"; + + private float temperatureCelsius = 25f; + private float minimumTemperature = 16f; + private float maximumTemperature = 30f; + private float temperatureStep = 0.5f; + private int fanLevel = 3; + private int maximumFanLevel = 7; + private boolean powerOn = true; + private boolean autoMode = true; + private boolean recirculationOn; + private HitTarget pressedTarget = HitTarget.NONE; + private int pressedFanLevel; + private OnClimateChangeListener changeListener; + + public ClimateDialView(Context context) { + super(context); + init(); + } + + public ClimateDialView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public ClimateDialView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setClickable(true); + setFocusable(true); + setMinimumWidth(CockpitTheme.dpInt(getContext(), 480f)); + setMinimumHeight(CockpitTheme.dpInt(getContext(), 120f)); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setStrokeCap(Paint.Cap.ROUND); + strokePaint.setStrokeJoin(Paint.Join.ROUND); + for (int i = 0; i < fanHitRects.length; i++) { + fanHitRects[i] = new RectF(); + } + updateContentDescription(); + } + + public void setOnClimateChangeListener(OnClimateChangeListener listener) { + changeListener = listener; + } + + public void setTemperature(float celsius) { + float safeValue = snapTemperature(celsius); + if (Math.abs(temperatureCelsius - safeValue) > 0.001f) { + temperatureCelsius = safeValue; + temperatureText = createTemperatureText(); + updateContentDescription(); + invalidate(); + } + } + + public float getTemperature() { + return temperatureCelsius; + } + + public void setTemperatureRange(float minimum, float maximum, float step) { + if (maximum <= minimum) { + throw new IllegalArgumentException("maximum must be greater than minimum"); + } + minimumTemperature = minimum; + maximumTemperature = maximum; + temperatureStep = Math.max(0.1f, step); + setTemperature(temperatureCelsius); + } + + public float getMinimumTemperature() { + return minimumTemperature; + } + + public float getMaximumTemperature() { + return maximumTemperature; + } + + public void setFanLevel(int level) { + int safeLevel = CockpitTheme.clamp(level, 0, maximumFanLevel); + if (fanLevel != safeLevel) { + fanLevel = safeLevel; + updateContentDescription(); + invalidate(); + } + } + + public int getFanLevel() { + return fanLevel; + } + + public void setMaximumFanLevel(int maximumFanLevel) { + int safeMaximum = CockpitTheme.clamp(maximumFanLevel, 3, 10); + if (this.maximumFanLevel != safeMaximum) { + this.maximumFanLevel = safeMaximum; + fanHitRects = new RectF[safeMaximum]; + for (int i = 0; i < safeMaximum; i++) { + fanHitRects[i] = new RectF(); + } + fanLevel = Math.min(fanLevel, safeMaximum); + requestLayout(); + invalidate(); + } + } + + public int getMaximumFanLevel() { + return maximumFanLevel; + } + + public void setPowerOn(boolean powerOn) { + if (this.powerOn != powerOn) { + this.powerOn = powerOn; + updateContentDescription(); + invalidate(); + } + } + + public boolean isPowerOn() { + return powerOn; + } + + public void setAutoMode(boolean autoMode) { + if (this.autoMode != autoMode) { + this.autoMode = autoMode; + updateContentDescription(); + invalidate(); + } + } + + public boolean isAutoMode() { + return autoMode; + } + + public void setRecirculationOn(boolean recirculationOn) { + if (this.recirculationOn != recirculationOn) { + this.recirculationOn = recirculationOn; + updateContentDescription(); + invalidate(); + } + } + + public boolean isRecirculationOn() { + return recirculationOn; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desiredWidth = CockpitTheme.dpInt(getContext(), 820f) + + getPaddingLeft() + getPaddingRight(); + int desiredHeight = CockpitTheme.dpInt(getContext(), 142f) + + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension( + resolveSize(desiredWidth, widthMeasureSpec), + resolveSize(desiredHeight, heightMeasureSpec)); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + surfaceShader = new LinearGradient( + getPaddingLeft(), + getPaddingTop(), + Math.max(getPaddingLeft() + 1f, width - getPaddingRight()), + Math.max(getPaddingTop() + 1f, height - getPaddingBottom()), + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.COLOR_SURFACE, + Shader.TileMode.CLAMP); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + float left = getPaddingLeft(); + float top = getPaddingTop(); + float right = getWidth() - getPaddingRight(); + float bottom = getHeight() - getPaddingBottom(); + cardRect.set(left, top, right, bottom); + float corner = Math.min( + CockpitTheme.dp(getContext(), CockpitTheme.CORNER_MEDIUM_DP), + cardRect.height() * 0.22f); + + paint.setStyle(Paint.Style.FILL); + paint.setShader(surfaceShader); + canvas.drawRoundRect(cardRect, corner, corner, paint); + paint.setShader(null); + strokePaint.setColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.95f)); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1f)); + canvas.drawRoundRect(cardRect, corner, corner, strokePaint); + + float inset = CockpitTheme.dp(getContext(), 12f); + contentRect.set( + cardRect.left + inset, + cardRect.top + inset, + cardRect.right - inset, + cardRect.bottom - inset); + layoutControls(contentRect); + drawTemperature(canvas, contentRect); + drawPowerAndFan(canvas, contentRect); + drawModeButton(canvas, autoRect, "自动", autoMode, HitTarget.AUTO); + drawModeButton(canvas, recirculationRect, "内循环", recirculationOn, + HitTarget.RECIRCULATION); + } + + private void layoutControls(RectF content) { + float width = content.width(); + float gap = CockpitTheme.dp(getContext(), 10f); + float touch = Math.min( + CockpitTheme.dp(getContext(), 64f), + Math.max(CockpitTheme.dp(getContext(), CockpitTheme.TOUCH_TARGET_DP), + content.height() - CockpitTheme.dp(getContext(), 8f))); + float centerY = content.centerY(); + boolean compact = width < CockpitTheme.dp(getContext(), 690f); + + float autoWidth = CockpitTheme.dp(getContext(), compact ? 70f : 94f); + float recirculationWidth = CockpitTheme.dp(getContext(), compact ? 82f : 124f); + recirculationRect.set( + content.right - recirculationWidth, + centerY - touch * 0.5f, + content.right, + centerY + touch * 0.5f); + autoRect.set( + recirculationRect.left - gap - autoWidth, + centerY - touch * 0.5f, + recirculationRect.left - gap, + centerY + touch * 0.5f); + + float temperatureWidth = compact + ? Math.min(width * 0.38f, CockpitTheme.dp(getContext(), 230f)) + : CockpitTheme.clamp(width * 0.31f, + CockpitTheme.dp(getContext(), 245f), + CockpitTheme.dp(getContext(), 306f)); + float temperatureRight = content.left + temperatureWidth; + temperatureDownRect.set( + content.left, + centerY - touch * 0.5f, + content.left + touch, + centerY + touch * 0.5f); + temperatureUpRect.set( + temperatureRight - touch, + centerY - touch * 0.5f, + temperatureRight, + centerY + touch * 0.5f); + + float fanStart = temperatureRight + gap * 1.4f; + powerRect.set( + fanStart, + centerY - touch * 0.5f, + fanStart + touch, + centerY + touch * 0.5f); + float fanBarsStart = powerRect.right + gap * 0.5f; + float fanBarsEnd = autoRect.left - gap; + float segmentGap = CockpitTheme.dp(getContext(), compact ? 3f : 5f); + float segmentWidth = Math.max( + CockpitTheme.dp(getContext(), 5f), + (fanBarsEnd - fanBarsStart - segmentGap * (maximumFanLevel - 1)) + / maximumFanLevel); + for (int i = 0; i < maximumFanLevel; i++) { + float segmentLeft = fanBarsStart + i * (segmentWidth + segmentGap); + fanHitRects[i].set( + segmentLeft - segmentGap * 0.5f, + centerY - touch * 0.5f, + segmentLeft + segmentWidth + segmentGap * 0.5f, + centerY + touch * 0.5f); + } + } + + private void drawTemperature(Canvas canvas, RectF content) { + drawChevronButton( + canvas, + temperatureDownRect, + false, + pressedTarget == HitTarget.TEMPERATURE_DOWN); + drawChevronButton( + canvas, + temperatureUpRect, + true, + pressedTarget == HitTarget.TEMPERATURE_UP); + + float valueLeft = temperatureDownRect.right; + float valueRight = temperatureUpRect.left; + float valueCenter = (valueLeft + valueRight) * 0.5f; + String value = temperatureText; + paint.setTypeface(CockpitTheme.TYPEFACE_LIGHT); + paint.setTextSize(CockpitTheme.clamp( + content.height() * 0.43f, + CockpitTheme.sp(getContext(), 31f), + CockpitTheme.sp(getContext(), 52f))); + paint.setColor(powerOn + ? CockpitTheme.COLOR_TEXT_PRIMARY + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.58f)); + paint.setTextAlign(Paint.Align.CENTER); + Paint.FontMetrics metrics = paint.getFontMetrics(); + float baseline = content.centerY() - (metrics.ascent + metrics.descent) * 0.5f; + canvas.drawText(value, valueCenter - CockpitTheme.dp(getContext(), 8f), baseline, paint); + + float valueWidth = paint.measureText(value); + paint.setTypeface(CockpitTheme.TYPEFACE_REGULAR); + paint.setTextSize(CockpitTheme.sp(getContext(), 17f)); + paint.setTextAlign(Paint.Align.LEFT); + canvas.drawText("°C", valueCenter - CockpitTheme.dp(getContext(), 8f) + + valueWidth * 0.5f + CockpitTheme.dp(getContext(), 2f), baseline, paint); + + strokePaint.setColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.86f)); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1f)); + float dividerX = temperatureUpRect.right + CockpitTheme.dp(getContext(), 7f); + canvas.drawLine( + dividerX, + content.top + CockpitTheme.dp(getContext(), 8f), + dividerX, + content.bottom - CockpitTheme.dp(getContext(), 8f), + strokePaint); + } + + private void drawChevronButton( + Canvas canvas, RectF bounds, boolean upward, boolean pressed) { + drawControlSurface(canvas, bounds, false, pressed); + float centerX = bounds.centerX(); + float centerY = bounds.centerY(); + float half = CockpitTheme.dp(getContext(), 9f); + strokePaint.setColor(powerOn + ? CockpitTheme.COLOR_ACTION + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.48f)); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 3.4f)); + strokePaint.setStrokeCap(Paint.Cap.ROUND); + path.reset(); + if (upward) { + path.moveTo(centerX - half, centerY + half * 0.45f); + path.lineTo(centerX, centerY - half * 0.45f); + path.lineTo(centerX + half, centerY + half * 0.45f); + } else { + path.moveTo(centerX - half, centerY - half * 0.45f); + path.lineTo(centerX, centerY + half * 0.45f); + path.lineTo(centerX + half, centerY - half * 0.45f); + } + canvas.drawPath(path, strokePaint); + } + + private void drawPowerAndFan(Canvas canvas, RectF content) { + drawControlSurface( + canvas, + powerRect, + powerOn, + pressedTarget == HitTarget.POWER); + drawFanIcon(canvas, powerRect, powerOn); + + float maxBarHeight = Math.min( + content.height() * 0.48f, + CockpitTheme.dp(getContext(), 34f)); + float minimumBarHeight = CockpitTheme.dp(getContext(), 8f); + for (int i = 0; i < maximumFanLevel; i++) { + RectF hit = fanHitRects[i]; + float barWidth = Math.max(1f, hit.width() - CockpitTheme.dp(getContext(), 4f)); + float barHeight = minimumBarHeight + + (maxBarHeight - minimumBarHeight) * i / Math.max(1f, maximumFanLevel - 1f); + rectForBar(hit, barWidth, barHeight); + boolean selected = powerOn && i < fanLevel; + boolean pressed = pressedTarget == HitTarget.FAN && pressedFanLevel == i + 1; + paint.setStyle(Paint.Style.FILL); + paint.setColor(selected + ? (pressed ? CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.68f) + : CockpitTheme.COLOR_ACTION) + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.22f)); + canvas.drawRoundRect(contentRect, barWidth * 0.25f, barWidth * 0.25f, paint); + } + } + + private void rectForBar(RectF hit, float width, float height) { + contentRect.set( + hit.centerX() - width * 0.5f, + hit.centerY() - height * 0.5f, + hit.centerX() + width * 0.5f, + hit.centerY() + height * 0.5f); + } + + private void drawFanIcon(Canvas canvas, RectF bounds, boolean active) { + float radius = Math.min(bounds.width(), bounds.height()) * 0.28f; + float centerX = bounds.centerX(); + float centerY = bounds.centerY(); + int color = active + ? CockpitTheme.COLOR_ACTION + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.46f); + paint.setStyle(Paint.Style.FILL); + paint.setColor(color); + canvas.drawCircle(centerX, centerY, radius * 0.18f, paint); + float rotation = active + ? (SystemClock.uptimeMillis() % 8000L) * 360f / 8000f + : 0f; + for (int i = 0; i < 4; i++) { + int save = canvas.save(); + canvas.rotate(rotation + i * 90f, centerX, centerY); + path.reset(); + path.moveTo(centerX + radius * 0.12f, centerY - radius * 0.12f); + path.cubicTo( + centerX + radius * 0.56f, + centerY - radius, + centerX + radius * 1.15f, + centerY - radius * 0.70f, + centerX + radius * 0.92f, + centerY - radius * 0.15f); + path.cubicTo( + centerX + radius * 0.73f, + centerY + radius * 0.23f, + centerX + radius * 0.37f, + centerY + radius * 0.28f, + centerX + radius * 0.20f, + centerY + radius * 0.14f); + path.close(); + canvas.drawPath(path, paint); + canvas.restoreToCount(save); + } + if (active && isAttachedToWindow()) { + postInvalidateDelayed(48L); + } + } + + private void drawModeButton( + Canvas canvas, + RectF bounds, + String label, + boolean selected, + HitTarget target) { + drawControlSurface(canvas, bounds, selected, pressedTarget == target); + paint.setStyle(Paint.Style.FILL); + paint.setTypeface(CockpitTheme.TYPEFACE_MEDIUM); + paint.setTextSize(CockpitTheme.sp(getContext(), + bounds.width() < CockpitTheme.dp(getContext(), 90f) ? 13f : 15f)); + paint.setTextAlign(Paint.Align.CENTER); + paint.setColor(selected + ? CockpitTheme.COLOR_PRIMARY + : CockpitTheme.COLOR_TEXT_PRIMARY); + Paint.FontMetrics metrics = paint.getFontMetrics(); + float baseline = bounds.centerY() - (metrics.ascent + metrics.descent) * 0.5f; + canvas.drawText(label, bounds.centerX(), baseline, paint); + } + + private void drawControlSurface( + Canvas canvas, RectF bounds, boolean selected, boolean pressed) { + float radius = CockpitTheme.dp(getContext(), CockpitTheme.CORNER_SMALL_DP); + int fill; + int stroke; + if (pressed) { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.22f); + stroke = CockpitTheme.COLOR_PRIMARY; + } else if (selected) { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.12f); + stroke = CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.82f); + } else { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_SURFACE_RAISED, 0.88f); + stroke = CockpitTheme.COLOR_STROKE; + } + paint.setStyle(Paint.Style.FILL); + paint.setColor(fill); + canvas.drawRoundRect(bounds, radius, radius, paint); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), selected ? 1.25f : 1f)); + strokePaint.setColor(stroke); + canvas.drawRoundRect(bounds, radius, radius, strokePaint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return super.onTouchEvent(event); + } + float x = event.getX(); + float y = event.getY(); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + pressedTarget = findTarget(x, y); + pressedFanLevel = findFanLevel(x, y); + setPressed(pressedTarget != HitTarget.NONE); + invalidate(); + return pressedTarget != HitTarget.NONE; + case MotionEvent.ACTION_MOVE: + HitTarget movedTarget = findTarget(x, y); + if (movedTarget != pressedTarget) { + setPressed(false); + } + return true; + case MotionEvent.ACTION_CANCEL: + clearPressedTarget(); + return true; + case MotionEvent.ACTION_UP: + HitTarget releasedTarget = findTarget(x, y); + int releasedFanLevel = findFanLevel(x, y); + HitTarget originalTarget = pressedTarget; + int originalFanLevel = pressedFanLevel; + clearPressedTarget(); + if (releasedTarget == originalTarget + && (originalTarget != HitTarget.FAN + || releasedFanLevel == originalFanLevel)) { + performClick(); + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + activateTarget(originalTarget, originalFanLevel); + } + return true; + default: + return true; + } + } + + @Override + public boolean performClick() { + super.performClick(); + return true; + } + + private HitTarget findTarget(float x, float y) { + if (temperatureDownRect.contains(x, y)) { + return HitTarget.TEMPERATURE_DOWN; + } + if (temperatureUpRect.contains(x, y)) { + return HitTarget.TEMPERATURE_UP; + } + if (powerRect.contains(x, y)) { + return HitTarget.POWER; + } + if (autoRect.contains(x, y)) { + return HitTarget.AUTO; + } + if (recirculationRect.contains(x, y)) { + return HitTarget.RECIRCULATION; + } + return findFanLevel(x, y) > 0 ? HitTarget.FAN : HitTarget.NONE; + } + + private int findFanLevel(float x, float y) { + for (int i = 0; i < fanHitRects.length; i++) { + if (fanHitRects[i].contains(x, y)) { + return i + 1; + } + } + return 0; + } + + private void activateTarget(HitTarget target, int requestedFanLevel) { + switch (target) { + case TEMPERATURE_DOWN: + setTemperature(temperatureCelsius - temperatureStep); + if (changeListener != null) { + changeListener.onTemperatureRequested(temperatureCelsius); + } + break; + case TEMPERATURE_UP: + setTemperature(temperatureCelsius + temperatureStep); + if (changeListener != null) { + changeListener.onTemperatureRequested(temperatureCelsius); + } + break; + case POWER: + setPowerOn(!powerOn); + if (changeListener != null) { + changeListener.onPowerRequested(powerOn); + } + break; + case AUTO: + setAutoMode(!autoMode); + if (changeListener != null) { + changeListener.onAutoModeRequested(autoMode); + } + break; + case RECIRCULATION: + setRecirculationOn(!recirculationOn); + if (changeListener != null) { + changeListener.onRecirculationRequested(recirculationOn); + } + break; + case FAN: + if (requestedFanLevel > 0) { + setFanLevel(requestedFanLevel); + if (changeListener != null) { + changeListener.onFanLevelRequested(fanLevel); + } + } + break; + case NONE: + break; + } + } + + private void clearPressedTarget() { + pressedTarget = HitTarget.NONE; + pressedFanLevel = 0; + setPressed(false); + invalidate(); + } + + private float snapTemperature(float value) { + float clamped = CockpitTheme.clamp(value, minimumTemperature, maximumTemperature); + float steps = Math.round((clamped - minimumTemperature) / temperatureStep); + return CockpitTheme.clamp( + minimumTemperature + steps * temperatureStep, + minimumTemperature, + maximumTemperature); + } + + private String createTemperatureText() { + boolean wholeNumber = Math.abs(temperatureCelsius - Math.round(temperatureCelsius)) < 0.01f; + return wholeNumber + ? String.format(Locale.getDefault(), "%.0f", temperatureCelsius) + : String.format(Locale.getDefault(), "%.1f", temperatureCelsius); + } + + private void updateContentDescription() { + setContentDescription(String.format( + Locale.getDefault(), + "空调%s,温度 %.1f 摄氏度,风量 %d 档,自动模式%s,内循环%s", + powerOn ? "开启" : "关闭", + temperatureCelsius, + fanLevel, + autoMode ? "开启" : "关闭", + recirculationOn ? "开启" : "关闭")); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitDrawables.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitDrawables.java new file mode 100644 index 0000000..fd6f42f --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitDrawables.java @@ -0,0 +1,154 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.graphics.drawable.RippleDrawable; +import android.graphics.drawable.StateListDrawable; + +/** Factory for cockpit surfaces and touch feedback. */ +public final class CockpitDrawables { + private CockpitDrawables() { + } + + public static GradientDrawable roundedRect( + Context context, int fillColor, float cornerDp) { + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setColor(fillColor); + drawable.setCornerRadius(CockpitTheme.dp(context, cornerDp)); + return drawable; + } + + public static GradientDrawable outlinedRect( + Context context, + int fillColor, + int strokeColor, + float strokeDp, + float cornerDp) { + GradientDrawable drawable = roundedRect(context, fillColor, cornerDp); + drawable.setStroke(CockpitTheme.dpInt(context, strokeDp), strokeColor); + return drawable; + } + + public static GradientDrawable verticalGradient( + Context context, + int topColor, + int bottomColor, + int strokeColor, + float cornerDp) { + GradientDrawable drawable = new GradientDrawable( + GradientDrawable.Orientation.TOP_BOTTOM, + new int[]{topColor, bottomColor}); + drawable.setShape(GradientDrawable.RECTANGLE); + drawable.setCornerRadius(CockpitTheme.dp(context, cornerDp)); + if (Color.alpha(strokeColor) > 0) { + drawable.setStroke(CockpitTheme.dpInt(context, 1f), strokeColor); + } + return drawable; + } + + public static Drawable surface(Context context) { + return verticalGradient( + context, + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.COLOR_SURFACE, + CockpitTheme.COLOR_STROKE, + CockpitTheme.CORNER_MEDIUM_DP); + } + + public static Drawable elevatedSurface(Context context) { + return verticalGradient( + context, + Color.rgb(24, 45, 59), + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.38f), + CockpitTheme.CORNER_MEDIUM_DP); + } + + public static Drawable primaryButton(Context context) { + Drawable normal = verticalGradient( + context, + Color.rgb(71, 207, 199), + Color.rgb(42, 154, 163), + CockpitTheme.withAlpha(Color.WHITE, 0.18f), + CockpitTheme.CORNER_SMALL_DP); + Drawable pressed = roundedRect( + context, Color.rgb(36, 137, 145), CockpitTheme.CORNER_SMALL_DP); + return touchDrawable(context, normal, pressed, CockpitTheme.COLOR_PRIMARY); + } + + public static Drawable actionButton(Context context) { + Drawable normal = verticalGradient( + context, + Color.rgb(62, 139, 255), + Color.rgb(38, 105, 224), + CockpitTheme.withAlpha(Color.WHITE, 0.16f), + CockpitTheme.CORNER_SMALL_DP); + Drawable pressed = roundedRect( + context, Color.rgb(32, 88, 187), CockpitTheme.CORNER_SMALL_DP); + return touchDrawable(context, normal, pressed, CockpitTheme.COLOR_TEXT_PRIMARY); + } + + public static Drawable secondaryButton(Context context) { + Drawable normal = outlinedRect( + context, + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.COLOR_STROKE, + 1f, + CockpitTheme.CORNER_SMALL_DP); + Drawable pressed = outlinedRect( + context, + CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.16f), + CockpitTheme.COLOR_PRIMARY, + 1f, + CockpitTheme.CORNER_SMALL_DP); + return touchDrawable(context, normal, pressed, CockpitTheme.COLOR_PRIMARY); + } + + public static Drawable selectedButton(Context context) { + return outlinedRect( + context, + CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.16f), + CockpitTheme.COLOR_PRIMARY, + 1.25f, + CockpitTheme.CORNER_SMALL_DP); + } + + public static Drawable navigationItem(Context context, boolean selected) { + int fill = selected + ? CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.10f) + : Color.TRANSPARENT; + Drawable content = roundedRect(context, fill, 0f); + Drawable pressed = roundedRect( + context, + CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.16f), + 0f); + return touchDrawable(context, content, pressed, CockpitTheme.COLOR_PRIMARY); + } + + public static Drawable pill(Context context, int fillColor, int strokeColor) { + return outlinedRect(context, fillColor, strokeColor, 1f, 100f); + } + + /** + * Builds a disabled/pressed state drawable wrapped in native ripple feedback. + */ + public static Drawable touchDrawable( + Context context, Drawable normal, Drawable pressed, int rippleColor) { + StateListDrawable states = new StateListDrawable(); + states.addState( + new int[]{-android.R.attr.state_enabled}, + roundedRect( + context, + CockpitTheme.withAlpha(CockpitTheme.COLOR_SURFACE, 0.62f), + CockpitTheme.CORNER_SMALL_DP)); + states.addState(new int[]{android.R.attr.state_pressed}, pressed); + states.addState(new int[0], normal); + ColorStateList ripple = ColorStateList.valueOf( + CockpitTheme.withAlpha(rippleColor, 0.20f)); + return new RippleDrawable(ripple, states, null); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitIconView.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitIconView.java new file mode 100644 index 0000000..6f8ccc7 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitIconView.java @@ -0,0 +1,436 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.util.AttributeSet; +import android.view.View; + +/** + * Dependency-free cockpit icon set drawn with a single visual language. + * + *

Icons are vector geometry rendered by Canvas rather than Unicode glyphs, + * which keeps their appearance consistent across head-unit system fonts.

+ */ +public class CockpitIconView extends View { + public enum Icon { + HOME, + DASHBOARD, + VEHICLE, + CLIMATE, + CONTROLS, + WINDOW, + LIGHTS, + SUNSHADE, + DEVICE, + SETTINGS, + MUSIC, + BLUETOOTH, + WIFI, + CELLULAR, + VOLUME, + BATTERY, + CHEVRON_RIGHT, + RECIRCULATE, + POWER + } + + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private Icon icon = Icon.DASHBOARD; + private int iconColor = CockpitTheme.COLOR_TEXT_PRIMARY; + private float strokeWidthDp = 2.2f; + + public CockpitIconView(Context context) { + super(context); + init(); + } + + public CockpitIconView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CockpitIconView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public CockpitIconView(Context context, Icon icon) { + super(context); + this.icon = icon == null ? Icon.DASHBOARD : icon; + init(); + } + + private void init() { + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeJoin(Paint.Join.ROUND); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + } + + public void setIcon(Icon icon) { + if (icon != null && this.icon != icon) { + this.icon = icon; + invalidate(); + } + } + + public Icon getIcon() { + return icon; + } + + public void setIconColor(int color) { + if (iconColor != color) { + iconColor = color; + invalidate(); + } + } + + public int getIconColor() { + return iconColor; + } + + public void setStrokeWidthDp(float strokeWidthDp) { + this.strokeWidthDp = Math.max(1f, strokeWidthDp); + invalidate(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desired = CockpitTheme.dpInt(getContext(), 32f); + int width = resolveSize(desired + getPaddingLeft() + getPaddingRight(), widthMeasureSpec); + int height = resolveSize(desired + getPaddingTop() + getPaddingBottom(), heightMeasureSpec); + setMeasuredDimension(width, height); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + float availableWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + float availableHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + float side = Math.max(1f, Math.min(availableWidth, availableHeight)); + float left = getPaddingLeft() + (availableWidth - side) * 0.5f; + float top = getPaddingTop() + (availableHeight - side) * 0.5f; + float scale = side / 48f; + + int alpha = isEnabled() ? (isPressed() ? 208 : 255) : 92; + paint.setColor(iconColor); + paint.setAlpha(alpha); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(CockpitTheme.dp(getContext(), strokeWidthDp) / scale); + + int save = canvas.save(); + canvas.translate(left, top); + canvas.scale(scale, scale); + drawIcon(canvas); + canvas.restoreToCount(save); + } + + private void drawIcon(Canvas canvas) { + switch (icon) { + case HOME: + drawHome(canvas); + break; + case DASHBOARD: + drawDashboard(canvas); + break; + case VEHICLE: + drawVehicle(canvas); + break; + case CLIMATE: + drawClimate(canvas); + break; + case CONTROLS: + drawControls(canvas); + break; + case WINDOW: + drawWindow(canvas); + break; + case LIGHTS: + drawLights(canvas); + break; + case SUNSHADE: + drawSunshade(canvas); + break; + case DEVICE: + drawDevice(canvas); + break; + case SETTINGS: + drawSettings(canvas); + break; + case MUSIC: + drawMusic(canvas); + break; + case BLUETOOTH: + drawBluetooth(canvas); + break; + case WIFI: + drawWifi(canvas); + break; + case CELLULAR: + drawCellular(canvas); + break; + case VOLUME: + drawVolume(canvas); + break; + case BATTERY: + drawBattery(canvas); + break; + case CHEVRON_RIGHT: + drawChevron(canvas); + break; + case RECIRCULATE: + drawRecirculate(canvas); + break; + case POWER: + drawPower(canvas); + break; + } + } + + private void drawHome(Canvas canvas) { + path.reset(); + path.moveTo(8f, 23f); + path.lineTo(24f, 9f); + path.lineTo(40f, 23f); + path.lineTo(40f, 40f); + path.lineTo(29f, 40f); + path.lineTo(29f, 29f); + path.lineTo(19f, 29f); + path.lineTo(19f, 40f); + path.lineTo(8f, 40f); + path.close(); + canvas.drawPath(path, paint); + } + + private void drawDashboard(Canvas canvas) { + rect.set(8f, 11f, 40f, 43f); + canvas.drawArc(rect, 198f, 144f, false, paint); + line(canvas, 11f, 34f, 15f, 32f); + line(canvas, 37f, 34f, 33f, 32f); + line(canvas, 24f, 27f, 32f, 19f); + canvas.drawCircle(24f, 27f, 2.6f, paint); + line(canvas, 15f, 39f, 33f, 39f); + } + + private void drawVehicle(Canvas canvas) { + path.reset(); + path.moveTo(11f, 27f); + path.lineTo(14.5f, 17f); + path.quadTo(15.5f, 13f, 20f, 13f); + path.lineTo(28f, 13f); + path.quadTo(32.5f, 13f, 33.5f, 17f); + path.lineTo(37f, 27f); + path.quadTo(40f, 28f, 40f, 32f); + path.lineTo(40f, 37f); + path.quadTo(40f, 40f, 37f, 40f); + path.lineTo(11f, 40f); + path.quadTo(8f, 40f, 8f, 37f); + path.lineTo(8f, 32f); + path.quadTo(8f, 28f, 11f, 27f); + path.close(); + canvas.drawPath(path, paint); + line(canvas, 14f, 25f, 34f, 25f); + canvas.drawCircle(15f, 34f, 2f, paint); + canvas.drawCircle(33f, 34f, 2f, paint); + line(canvas, 5f, 22f, 12f, 22f); + line(canvas, 36f, 22f, 43f, 22f); + } + + private void drawClimate(Canvas canvas) { + canvas.drawCircle(24f, 24f, 3f, paint); + for (int i = 0; i < 4; i++) { + int save = canvas.save(); + canvas.rotate(i * 90f, 24f, 24f); + path.reset(); + path.moveTo(26f, 20f); + path.cubicTo(31f, 12f, 39f, 11f, 40f, 16f); + path.cubicTo(41f, 21f, 34f, 24f, 28f, 25f); + canvas.drawPath(path, paint); + canvas.restoreToCount(save); + } + } + + private void drawControls(Canvas canvas) { + line(canvas, 9f, 14f, 39f, 14f); + line(canvas, 9f, 24f, 39f, 24f); + line(canvas, 9f, 34f, 39f, 34f); + canvas.drawCircle(18f, 14f, 3.5f, paint); + canvas.drawCircle(31f, 24f, 3.5f, paint); + canvas.drawCircle(15f, 34f, 3.5f, paint); + } + + private void drawWindow(Canvas canvas) { + path.reset(); + path.moveTo(9f, 35f); + path.lineTo(12f, 23f); + path.quadTo(14f, 15f, 24f, 11f); + path.lineTo(39f, 11f); + path.lineTo(39f, 35f); + path.close(); + canvas.drawPath(path, paint); + line(canvas, 10f, 39f, 40f, 39f); + line(canvas, 15f, 31f, 35f, 31f); + } + + private void drawLights(Canvas canvas) { + canvas.drawCircle(24f, 22f, 9f, paint); + path.reset(); + path.moveTo(18f, 29f); + path.quadTo(24f, 33f, 30f, 29f); + canvas.drawPath(path, paint); + line(canvas, 20f, 34f, 28f, 34f); + line(canvas, 20f, 38f, 28f, 38f); + line(canvas, 24f, 5f, 24f, 9f); + line(canvas, 8f, 22f, 4f, 22f); + line(canvas, 44f, 22f, 40f, 22f); + line(canvas, 12f, 10f, 15f, 13f); + line(canvas, 36f, 10f, 33f, 13f); + } + + private void drawSunshade(Canvas canvas) { + rect.set(8f, 12f, 40f, 38f); + canvas.drawRoundRect(rect, 2f, 2f, paint); + line(canvas, 6f, 9f, 42f, 9f); + line(canvas, 13f, 16f, 13f, 34f); + line(canvas, 20f, 16f, 20f, 34f); + line(canvas, 27f, 16f, 27f, 34f); + line(canvas, 34f, 16f, 34f, 34f); + } + + private void drawDevice(Canvas canvas) { + rect.set(8f, 14f, 40f, 40f); + canvas.drawRoundRect(rect, 3f, 3f, paint); + path.reset(); + path.moveTo(17f, 14f); + path.lineTo(17f, 10f); + path.quadTo(17f, 8f, 19f, 8f); + path.lineTo(29f, 8f); + path.quadTo(31f, 8f, 31f, 10f); + path.lineTo(31f, 14f); + canvas.drawPath(path, paint); + canvas.drawCircle(24f, 27f, 5f, paint); + line(canvas, 27.5f, 30.5f, 32f, 35f); + } + + private void drawSettings(Canvas canvas) { + canvas.drawCircle(24f, 24f, 11f, paint); + canvas.drawCircle(24f, 24f, 4f, paint); + for (int i = 0; i < 8; i++) { + int save = canvas.save(); + canvas.rotate(i * 45f, 24f, 24f); + line(canvas, 24f, 7f, 24f, 13f); + canvas.restoreToCount(save); + } + } + + private void drawMusic(Canvas canvas) { + line(canvas, 19f, 13f, 35f, 9f); + line(canvas, 19f, 13f, 19f, 34f); + line(canvas, 35f, 9f, 35f, 30f); + line(canvas, 19f, 18f, 35f, 14f); + canvas.drawOval(new RectF(9f, 31f, 19f, 39f), paint); + canvas.drawOval(new RectF(25f, 27f, 35f, 35f), paint); + } + + private void drawBluetooth(Canvas canvas) { + path.reset(); + path.moveTo(21f, 7f); + path.lineTo(33f, 17f); + path.lineTo(14f, 34f); + path.moveTo(21f, 7f); + path.lineTo(21f, 41f); + path.lineTo(33f, 31f); + path.lineTo(14f, 14f); + canvas.drawPath(path, paint); + } + + private void drawWifi(Canvas canvas) { + rect.set(5f, 8f, 43f, 46f); + canvas.drawArc(rect, 220f, 100f, false, paint); + rect.inset(7f, 7f); + canvas.drawArc(rect, 220f, 100f, false, paint); + rect.inset(7f, 7f); + canvas.drawArc(rect, 220f, 100f, false, paint); + paint.setStyle(Paint.Style.FILL); + canvas.drawCircle(24f, 36f, 2.3f, paint); + paint.setStyle(Paint.Style.STROKE); + } + + private void drawCellular(Canvas canvas) { + for (int i = 0; i < 4; i++) { + float left = 8f + i * 9f; + float top = 34f - i * 7f; + rect.set(left, top, left + 5f, 40f); + canvas.drawRoundRect(rect, 1f, 1f, paint); + } + } + + private void drawVolume(Canvas canvas) { + path.reset(); + path.moveTo(8f, 20f); + path.lineTo(16f, 20f); + path.lineTo(27f, 11f); + path.lineTo(27f, 37f); + path.lineTo(16f, 28f); + path.lineTo(8f, 28f); + path.close(); + canvas.drawPath(path, paint); + rect.set(20f, 12f, 40f, 36f); + canvas.drawArc(rect, -50f, 100f, false, paint); + rect.set(18f, 7f, 47f, 41f); + canvas.drawArc(rect, -48f, 96f, false, paint); + } + + private void drawBattery(Canvas canvas) { + rect.set(7f, 15f, 38f, 33f); + canvas.drawRoundRect(rect, 3f, 3f, paint); + line(canvas, 41f, 21f, 41f, 27f); + line(canvas, 12f, 20f, 31f, 20f); + line(canvas, 12f, 24f, 31f, 24f); + line(canvas, 12f, 28f, 27f, 28f); + } + + private void drawChevron(Canvas canvas) { + path.reset(); + path.moveTo(18f, 10f); + path.lineTo(32f, 24f); + path.lineTo(18f, 38f); + canvas.drawPath(path, paint); + } + + private void drawRecirculate(Canvas canvas) { + path.reset(); + path.moveTo(9f, 19f); + path.quadTo(13f, 11f, 23f, 11f); + path.lineTo(34f, 11f); + path.lineTo(29f, 6f); + path.moveTo(39f, 29f); + path.quadTo(35f, 37f, 25f, 37f); + path.lineTo(14f, 37f); + path.lineTo(19f, 42f); + canvas.drawPath(path, paint); + line(canvas, 34f, 11f, 29f, 16f); + line(canvas, 14f, 37f, 19f, 32f); + } + + private void drawPower(Canvas canvas) { + rect.set(9f, 9f, 39f, 39f); + canvas.drawArc(rect, -48f, 276f, false, paint); + line(canvas, 24f, 6f, 24f, 23f); + } + + private void line(Canvas canvas, float x1, float y1, float x2, float y2) { + canvas.drawLine(x1, y1, x2, y2, paint); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitLayoutProfile.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitLayoutProfile.java new file mode 100644 index 0000000..54132ab --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitLayoutProfile.java @@ -0,0 +1,177 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.util.DisplayMetrics; + +/** + * Resolution, density and aspect-ratio aware sizing profile for the cockpit HMI. + * + *

The original screen converted every design unit through Android density. On a 1280x720 + * automotive display configured at 320 dpi that doubled the entire interface and left only a + * narrow strip for content. A cockpit is a bounded full-screen surface, so its geometry is instead + * scaled from the actual render area. System font scale is still respected independently.

+ */ +public final class CockpitLayoutProfile { + public enum Tier { + COMPACT, + STANDARD, + WIDE + } + + private static final float REFERENCE_WIDTH_PX = 1280f; + private static final float REFERENCE_HEIGHT_PX = 720f; + + private static volatile CockpitLayoutProfile cached; + + public final int widthPx; + public final int heightPx; + public final float density; + public final float fontScale; + public final float geometryScale; + public final float textScale; + public final float aspectRatio; + public final Tier tier; + + private CockpitLayoutProfile( + int widthPx, + int heightPx, + float density, + float fontScale, + float geometryScale, + float textScale, + float aspectRatio, + Tier tier) { + this.widthPx = widthPx; + this.heightPx = heightPx; + this.density = density; + this.fontScale = fontScale; + this.geometryScale = geometryScale; + this.textScale = textScale; + this.aspectRatio = aspectRatio; + this.tier = tier; + } + + public static CockpitLayoutProfile from(Context context) { + if (context == null) throw new IllegalArgumentException("context == null"); + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + int width = Math.max(1, metrics.widthPixels); + int height = Math.max(1, metrics.heightPixels); + float density = finitePositive(metrics.density, 1f); + float scaledDensity = finitePositive(metrics.scaledDensity, density); + + CockpitLayoutProfile current = cached; + float requestedFontScale = scaledDensity / density; + if (current != null + && current.widthPx == width + && current.heightPx == height + && Math.abs(current.density - density) < 0.001f + && Math.abs(current.fontScale - requestedFontScale) < 0.001f) { + return current; + } + CockpitLayoutProfile created = forDisplay( + width, height, density, requestedFontScale); + cached = created; + return created; + } + + /** Pure factory retained for deterministic unit tests. */ + static CockpitLayoutProfile forDisplay( + int widthPx, int heightPx, float density, float fontScale) { + int safeWidth = Math.max(1, widthPx); + int safeHeight = Math.max(1, heightPx); + float safeDensity = finitePositive(density, 1f); + float safeFontScale = clamp(finitePositive(fontScale, 1f), 0.85f, 1.35f); + float aspect = safeWidth / (float) safeHeight; + + // Height is normally the limiting axis in a landscape cockpit. Taking the smaller ratio + // also handles ultrawide screens without inflating vertical chrome. + float resolutionScale = Math.min( + safeWidth / REFERENCE_WIDTH_PX, + safeHeight / REFERENCE_HEIGHT_PX); + float geometryScale = clamp(resolutionScale, 0.68f, 1.85f); + float textScale = clamp(geometryScale, 0.90f, 1.72f) * safeFontScale; + + Tier tier; + if (safeWidth < 1180 || safeHeight < 660 || aspect < 1.50f) { + tier = Tier.COMPACT; + } else if (safeWidth >= 1760 && safeHeight >= 900 && aspect >= 1.65f) { + tier = Tier.WIDE; + } else { + tier = Tier.STANDARD; + } + return new CockpitLayoutProfile( + safeWidth, + safeHeight, + safeDensity, + safeFontScale, + geometryScale, + textScale, + aspect, + tier); + } + + public int px(float designUnits) { + if (!Float.isFinite(designUnits)) return 0; + return Math.round(designUnits * geometryScale); + } + + public float textPx(float designSp) { + if (!Float.isFinite(designSp)) return 0f; + return designSp * textScale; + } + + public float designUnits(float pixels) { + if (!Float.isFinite(pixels)) return 0f; + return pixels / Math.max(0.001f, geometryScale); + } + + public float navigationWidthUnits() { + switch (tier) { + case COMPACT: return 86f; + case WIDE: return 102f; + default: return 94f; + } + } + + public float statusBarHeightUnits() { + switch (tier) { + case COMPACT: return 50f; + case WIDE: return 60f; + default: return 56f; + } + } + + public float simulationBannerHeightUnits() { + return tier == Tier.COMPACT ? 30f : tier == Tier.WIDE ? 36f : 34f; + } + + public float feedbackBarHeightUnits() { + return tier == Tier.COMPACT ? 44f : tier == Tier.WIDE ? 54f : 50f; + } + + public float homeCompanionWidthUnits() { + return tier == Tier.COMPACT ? 330f : tier == Tier.WIDE ? 400f : 370f; + } + + /** Maximum in-app mascot footprint; the user setting remains unchanged for the overlay. */ + public int inAppMascotMaximumPx() { + // Large automotive panels provide more pixels, but not permission for an ornament to keep + // growing linearly over driver controls. Plateau the wide-screen footprint while leaving + // the cross-app overlay's user-selected logical size untouched. + float widthFraction = tier == Tier.COMPACT ? 0.20f + : tier == Tier.WIDE ? 0.17f : 0.205f; + float heightFraction = tier == Tier.COMPACT ? 0.35f + : tier == Tier.WIDE ? 0.30f : 0.36f; + return Math.max(1, Math.round(Math.min( + widthPx * widthFraction, + heightPx * heightFraction))); + } + + private static float finitePositive(float value, float fallback) { + return Float.isFinite(value) && value > 0f ? value : fallback; + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationButton.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationButton.java new file mode 100644 index 0000000..ea655ff --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationButton.java @@ -0,0 +1,155 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +/** A production-sized navigation target for the left cockpit rail. */ +public class CockpitNavigationButton extends LinearLayout { + private final Paint indicatorPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private CockpitIconView iconView; + private TextView labelView; + private boolean active; + + public CockpitNavigationButton(Context context) { + super(context); + init(CockpitIconView.Icon.DASHBOARD, ""); + } + + public CockpitNavigationButton(Context context, AttributeSet attrs) { + super(context, attrs); + init(CockpitIconView.Icon.DASHBOARD, ""); + } + + public CockpitNavigationButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(CockpitIconView.Icon.DASHBOARD, ""); + } + + public CockpitNavigationButton( + Context context, CockpitIconView.Icon icon, CharSequence label) { + super(context); + init(icon, label); + } + + private void init(CockpitIconView.Icon icon, CharSequence label) { + setOrientation(VERTICAL); + setGravity(Gravity.CENTER); + setWillNotDraw(false); + setClickable(true); + setFocusable(true); + setMinimumHeight(CockpitTheme.dpInt(getContext(), 72f)); + int horizontal = CockpitTheme.dpInt(getContext(), 8f); + int vertical = CockpitTheme.dpInt(getContext(), 9f); + setPadding(horizontal, vertical, horizontal, vertical); + + iconView = new CockpitIconView(getContext(), icon); + int iconSize = CockpitTheme.dpInt(getContext(), 30f); + addView(iconView, new LayoutParams(iconSize, iconSize)); + + labelView = CockpitViews.text( + getContext(), + label, + 14f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_MEDIUM); + labelView.setGravity(Gravity.CENTER); + LayoutParams labelParams = new LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + labelParams.topMargin = CockpitTheme.dpInt(getContext(), 5f); + addView(labelView, labelParams); + + indicatorPaint.setColor(CockpitTheme.COLOR_PRIMARY); + setLabel(label); + refreshAppearance(); + } + + public void setIcon(CockpitIconView.Icon icon) { + iconView.setIcon(icon); + } + + public CockpitIconView.Icon getIcon() { + return iconView.getIcon(); + } + + public void setLabel(CharSequence label) { + CharSequence safeLabel = label == null ? "" : label; + labelView.setText(safeLabel); + setContentDescription(safeLabel); + } + + public CharSequence getLabel() { + return labelView.getText(); + } + + public void setActive(boolean active) { + if (this.active != active) { + this.active = active; + setSelected(active); + refreshAppearance(); + invalidate(); + } + } + + public boolean isActive() { + return active; + } + + public CockpitIconView getIconView() { + return iconView; + } + + public TextView getLabelView() { + return labelView; + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + if (iconView != null) { + iconView.setEnabled(enabled); + } + if (labelView != null) { + labelView.setEnabled(enabled); + } + refreshAppearance(); + } + + private void refreshAppearance() { + if (iconView == null || labelView == null) { + return; + } + int color; + if (!isEnabled()) { + color = CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.40f); + } else { + color = active ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_SECONDARY; + } + iconView.setIconColor(color); + labelView.setTextColor(color); + setBackground(CockpitDrawables.navigationItem(getContext(), active)); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (active) { + float width = CockpitTheme.dp(getContext(), 4f); + float inset = CockpitTheme.dp(getContext(), 10f); + canvas.drawRoundRect( + 0f, + inset, + width, + getHeight() - inset, + width, + width, + indicatorPaint); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationRail.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationRail.java new file mode 100644 index 0000000..77af6d1 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitNavigationRail.java @@ -0,0 +1,142 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.drawable.GradientDrawable; +import android.util.AttributeSet; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +/** Left navigation rail with exclusive item selection. */ +public class CockpitNavigationRail extends LinearLayout { + public interface OnItemSelectedListener { + void onItemSelected(int itemId, CockpitNavigationButton button); + } + + private final SparseArray items = new SparseArray<>(); + private OnItemSelectedListener listener; + private int selectedItemId = View.NO_ID; + private boolean distributeItems = true; + + public CockpitNavigationRail(Context context) { + super(context); + init(); + } + + public CockpitNavigationRail(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CockpitNavigationRail(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setOrientation(VERTICAL); + setMinimumWidth(CockpitTheme.dpInt(getContext(), CockpitTheme.NAVIGATION_WIDTH_DP)); + setPadding(0, CockpitTheme.dpInt(getContext(), 8f), 0, + CockpitTheme.dpInt(getContext(), 8f)); + GradientDrawable background = new GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + new int[]{CockpitTheme.COLOR_BACKGROUND_DEEP, CockpitTheme.COLOR_SURFACE}); + setBackground(background); + } + + public CockpitNavigationButton addItem( + int itemId, CockpitIconView.Icon icon, CharSequence label) { + int safeId = itemId == View.NO_ID ? View.generateViewId() : itemId; + CockpitNavigationButton existing = items.get(safeId); + if (existing != null) { + existing.setIcon(icon); + existing.setLabel(label); + return existing; + } + + CockpitNavigationButton button = new CockpitNavigationButton(getContext(), icon, label); + button.setId(safeId); + button.setOnClickListener(view -> selectItem(view.getId(), true)); + items.put(safeId, button); + addView(button, createItemLayoutParams()); + if (selectedItemId == View.NO_ID) { + selectItem(safeId, false); + } + return button; + } + + public void removeItem(int itemId) { + CockpitNavigationButton button = items.get(itemId); + if (button == null) { + return; + } + removeView(button); + items.remove(itemId); + if (selectedItemId == itemId) { + selectedItemId = View.NO_ID; + if (items.size() > 0) { + selectItem(items.keyAt(0), false); + } + } + } + + public void clearItems() { + removeAllViews(); + items.clear(); + selectedItemId = View.NO_ID; + } + + public void setSelectedItem(int itemId) { + selectItem(itemId, false); + } + + public int getSelectedItemId() { + return selectedItemId; + } + + public CockpitNavigationButton getItem(int itemId) { + return items.get(itemId); + } + + public void setOnItemSelectedListener(OnItemSelectedListener listener) { + this.listener = listener; + } + + public void setDistributeItems(boolean distributeItems) { + if (this.distributeItems == distributeItems) { + return; + } + this.distributeItems = distributeItems; + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + child.setLayoutParams(createItemLayoutParams()); + } + } + + public boolean isDistributingItems() { + return distributeItems; + } + + private void selectItem(int itemId, boolean notify) { + CockpitNavigationButton target = items.get(itemId); + if (target == null || !target.isEnabled()) { + return; + } + selectedItemId = itemId; + for (int i = 0; i < items.size(); i++) { + items.valueAt(i).setActive(items.keyAt(i) == itemId); + } + if (notify && listener != null) { + listener.onItemSelected(itemId, target); + } + } + + private LayoutParams createItemLayoutParams() { + return distributeItems + ? new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f) + : new LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitStatusBar.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitStatusBar.java new file mode 100644 index 0000000..dd053c6 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitStatusBar.java @@ -0,0 +1,255 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +/** Reusable top status bar for all first- and second-level cockpit pages. */ +public class CockpitStatusBar extends LinearLayout { + private final Handler clockHandler = new Handler(Looper.getMainLooper()); + private final Paint dividerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + + private TextView titleView; + private TextView simulationChip; + private LinearLayout statusArea; + private CockpitIconView bluetoothIcon; + private CockpitIconView wifiIcon; + private CockpitIconView cellularIcon; + private CockpitIconView volumeIcon; + private CockpitIconView batteryIcon; + private TextView batteryText; + private TextView timeView; + private boolean showSystemTime = true; + + private final Runnable clockTick = new Runnable() { + @Override + public void run() { + if (showSystemTime) { + timeView.setText(timeFormat.format(new Date())); + long now = System.currentTimeMillis(); + clockHandler.postDelayed(this, 60_000L - (now % 60_000L) + 50L); + } + } + }; + + public CockpitStatusBar(Context context) { + super(context); + init(); + } + + public CockpitStatusBar(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public CockpitStatusBar(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setOrientation(HORIZONTAL); + setGravity(Gravity.CENTER_VERTICAL); + setWillNotDraw(false); + setMinimumHeight(CockpitTheme.dpInt(getContext(), CockpitTheme.STATUS_BAR_HEIGHT_DP)); + int horizontal = CockpitTheme.dpInt(getContext(), CockpitTheme.CONTENT_MARGIN_DP); + setPadding(horizontal, 0, horizontal, 0); + setBackgroundColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_BACKGROUND, 0.96f)); + + LinearLayout titleArea = CockpitViews.horizontal(getContext()); + titleView = CockpitViews.title(getContext(), ""); + titleArea.addView(titleView, new LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + simulationChip = CockpitViews.text( + getContext(), + "模拟环境", + CockpitTheme.TEXT_CAPTION_SP, + CockpitTheme.COLOR_WARNING, + CockpitTheme.TYPEFACE_MEDIUM); + simulationChip.setGravity(Gravity.CENTER); + simulationChip.setBackground(CockpitDrawables.pill( + getContext(), + CockpitTheme.withAlpha(CockpitTheme.COLOR_WARNING, 0.10f), + CockpitTheme.withAlpha(CockpitTheme.COLOR_WARNING, 0.65f))); + int chipHorizontal = CockpitTheme.dpInt(getContext(), 10f); + simulationChip.setPadding(chipHorizontal, 0, chipHorizontal, 0); + simulationChip.setVisibility(GONE); + LayoutParams chipParams = new LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + CockpitTheme.dpInt(getContext(), 28f)); + chipParams.leftMargin = CockpitTheme.dpInt(getContext(), 14f); + titleArea.addView(simulationChip, chipParams); + addView(titleArea, new LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + + statusArea = CockpitViews.horizontal(getContext()); + bluetoothIcon = addStatusIcon(CockpitIconView.Icon.BLUETOOTH, "蓝牙"); + wifiIcon = addStatusIcon(CockpitIconView.Icon.WIFI, "无线网络"); + cellularIcon = addStatusIcon(CockpitIconView.Icon.CELLULAR, "移动网络"); + volumeIcon = addStatusIcon(CockpitIconView.Icon.VOLUME, "音量"); + batteryIcon = addStatusIcon(CockpitIconView.Icon.BATTERY, "电池"); + + batteryText = CockpitViews.text( + getContext(), + "--%", + CockpitTheme.TEXT_LABEL_SP, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + LayoutParams batteryTextParams = new LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT); + batteryTextParams.leftMargin = CockpitTheme.dpInt(getContext(), 3f); + statusArea.addView(batteryText, batteryTextParams); + + timeView = CockpitViews.text( + getContext(), + "--:--", + CockpitTheme.TEXT_BODY_SP, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_MEDIUM); + timeView.setGravity(Gravity.CENTER); + LayoutParams timeParams = new LayoutParams( + CockpitTheme.dpInt(getContext(), 70f), + ViewGroup.LayoutParams.MATCH_PARENT); + timeParams.leftMargin = CockpitTheme.dpInt(getContext(), 12f); + statusArea.addView(timeView, timeParams); + addView(statusArea, new LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + dividerPaint.setColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.55f)); + dividerPaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1f)); + setBluetoothConnected(true); + setWifiLevel(3); + setCellularLevel(4); + setVolumeEnabled(true); + } + + private CockpitIconView addStatusIcon(CockpitIconView.Icon icon, String description) { + CockpitIconView view = new CockpitIconView(getContext(), icon); + view.setIconColor(CockpitTheme.COLOR_TEXT_PRIMARY); + view.setContentDescription(description); + statusArea.addView(view, new LayoutParams( + CockpitTheme.dpInt(getContext(), 31f), + CockpitTheme.dpInt(getContext(), 27f))); + return view; + } + + public void setTitle(CharSequence title) { + titleView.setText(title == null ? "" : title); + } + + public CharSequence getTitle() { + return titleView.getText(); + } + + public TextView getTitleView() { + return titleView; + } + + public void setSimulationMode(boolean simulationMode) { + simulationChip.setVisibility(simulationMode ? VISIBLE : GONE); + } + + public boolean isSimulationMode() { + return simulationChip.getVisibility() == VISIBLE; + } + + public void setBluetoothConnected(boolean connected) { + setIconState(bluetoothIcon, connected, connected ? "蓝牙已连接" : "蓝牙未连接"); + } + + public void setWifiLevel(int level) { + int safeLevel = CockpitTheme.clamp(level, 0, 4); + setIconState(wifiIcon, safeLevel > 0, "无线网络信号 " + safeLevel + " 格"); + } + + public void setCellularLevel(int level) { + int safeLevel = CockpitTheme.clamp(level, 0, 4); + setIconState(cellularIcon, safeLevel > 0, "移动网络信号 " + safeLevel + " 格"); + } + + public void setVolumeEnabled(boolean enabled) { + setIconState(volumeIcon, enabled, enabled ? "声音已开启" : "声音已静音"); + } + + public void setBatteryPercent(int percent) { + int safePercent = CockpitTheme.clamp(percent, 0, 100); + batteryText.setText(String.format(Locale.getDefault(), "%d%%", safePercent)); + int color = safePercent <= 15 + ? CockpitTheme.COLOR_DANGER + : CockpitTheme.COLOR_TEXT_SECONDARY; + batteryText.setTextColor(color); + batteryIcon.setIconColor(color); + batteryIcon.setContentDescription("电池 " + safePercent + "%"); + } + + public void setShowSystemTime(boolean showSystemTime) { + this.showSystemTime = showSystemTime; + clockHandler.removeCallbacks(clockTick); + if (showSystemTime && isAttachedToWindow()) { + clockTick.run(); + } + } + + public void setTimeText(CharSequence text) { + setShowSystemTime(false); + timeView.setText(text == null ? "" : text); + } + + public TextView getTimeView() { + return timeView; + } + + public void setOnStatusAreaClickListener(OnClickListener listener) { + statusArea.setOnClickListener(listener); + statusArea.setClickable(listener != null); + statusArea.setFocusable(listener != null); + if (listener != null) { + statusArea.setMinimumHeight( + CockpitTheme.dpInt(getContext(), CockpitTheme.TOUCH_TARGET_DP)); + } + } + + private void setIconState(CockpitIconView icon, boolean active, String description) { + icon.setIconColor(active + ? CockpitTheme.COLOR_TEXT_PRIMARY + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.38f)); + icon.setContentDescription(description); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (showSystemTime) { + clockTick.run(); + } + } + + @Override + protected void onDetachedFromWindow() { + clockHandler.removeCallbacks(clockTick); + super.onDetachedFromWindow(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + canvas.drawLine(0f, getHeight() - dividerPaint.getStrokeWidth() * 0.5f, + getWidth(), getHeight() - dividerPaint.getStrokeWidth() * 0.5f, dividerPaint); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitTheme.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitTheme.java new file mode 100644 index 0000000..fc557fc --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitTheme.java @@ -0,0 +1,80 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; + +/** + * Design tokens shared by the native cockpit widgets. + * + *

Dimensions are authored as reference design units and resolved through + * {@link CockpitLayoutProfile}. This prevents an arbitrary Android density setting from doubling + * the whole full-screen HMI while still scaling cleanly from compact to high-resolution panels.

+ */ +public final class CockpitTheme { + private CockpitTheme() { + } + + public static final int COLOR_BACKGROUND = Color.rgb(7, 16, 23); + public static final int COLOR_BACKGROUND_DEEP = Color.rgb(8, 19, 28); + public static final int COLOR_SURFACE = Color.rgb(14, 27, 37); + public static final int COLOR_SURFACE_RAISED = Color.rgb(21, 39, 51); + public static final int COLOR_STROKE = Color.rgb(40, 64, 77); + public static final int COLOR_PRIMARY = Color.rgb(94, 230, 213); + public static final int COLOR_ACTION = Color.rgb(77, 141, 255); + public static final int COLOR_TEXT_PRIMARY = Color.rgb(243, 247, 248); + public static final int COLOR_TEXT_SECONDARY = Color.rgb(155, 176, 184); + public static final int COLOR_WARNING = Color.rgb(255, 185, 104); + public static final int COLOR_DANGER = Color.rgb(255, 113, 113); + public static final int COLOR_SUCCESS = Color.rgb(99, 220, 168); + + public static final float CORNER_SMALL_DP = 16f; + public static final float CORNER_MEDIUM_DP = 22f; + public static final float CORNER_LARGE_DP = 28f; + public static final float TOUCH_TARGET_DP = 56f; + public static final float CONTENT_MARGIN_DP = 24f; + public static final float STATUS_BAR_HEIGHT_DP = 56f; + public static final float NAVIGATION_WIDTH_DP = 96f; + + public static final float TEXT_CAPTION_SP = 12f; + public static final float TEXT_LABEL_SP = 15f; + public static final float TEXT_BODY_SP = 17f; + public static final float TEXT_TITLE_SP = 22f; + public static final float TEXT_DISPLAY_SP = 54f; + + public static final Typeface TYPEFACE_REGULAR = + Typeface.create("sans-serif", Typeface.NORMAL); + public static final Typeface TYPEFACE_MEDIUM = + Typeface.create("sans-serif-medium", Typeface.NORMAL); + public static final Typeface TYPEFACE_LIGHT = + Typeface.create("sans-serif-light", Typeface.NORMAL); + + public static float dp(Context context, float value) { + return CockpitLayoutProfile.from(context).px(value); + } + + public static int dpInt(Context context, float value) { + return Math.round(dp(context, value)); + } + + public static float sp(Context context, float value) { + return CockpitLayoutProfile.from(context).textPx(value); + } + + public static float pxToDp(Context context, float pixels) { + return CockpitLayoutProfile.from(context).designUnits(pixels); + } + + public static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + public static int clamp(int value, int minimum, int maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + public static int withAlpha(int color, float alphaFraction) { + int alpha = Math.round(255f * clamp(alphaFraction, 0f, 1f)); + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitViews.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitViews.java new file mode 100644 index 0000000..4429a25 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/CockpitViews.java @@ -0,0 +1,164 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.Space; +import android.widget.TextView; + +/** Small native builders used when composing screens from Java. */ +public final class CockpitViews { + private CockpitViews() { + } + + public static TextView text( + Context context, + CharSequence value, + float sizeSp, + int color, + Typeface typeface) { + TextView view = new TextView(context); + view.setText(value); + view.setTextSize(TypedValue.COMPLEX_UNIT_PX, CockpitTheme.sp(context, sizeSp)); + view.setTextColor(color); + view.setTypeface(typeface); + view.setGravity(Gravity.CENTER_VERTICAL); + view.setIncludeFontPadding(false); + view.setSingleLine(true); + view.setEllipsize(TextUtils.TruncateAt.END); + return view; + } + + public static TextView title(Context context, CharSequence value) { + return text( + context, + value, + CockpitTheme.TEXT_TITLE_SP, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_MEDIUM); + } + + public static TextView body(Context context, CharSequence value) { + return text( + context, + value, + CockpitTheme.TEXT_BODY_SP, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_REGULAR); + } + + public static TextView label(Context context, CharSequence value) { + return text( + context, + value, + CockpitTheme.TEXT_LABEL_SP, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + } + + public static TextView metric(Context context, CharSequence value) { + return text( + context, + value, + CockpitTheme.TEXT_DISPLAY_SP, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_LIGHT); + } + + public static TextView actionButton( + Context context, CharSequence value, boolean primary) { + TextView view = text( + context, + value, + CockpitTheme.TEXT_BODY_SP, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_MEDIUM); + view.setGravity(Gravity.CENTER); + view.setMinWidth(CockpitTheme.dpInt(context, 88f)); + view.setMinHeight(CockpitTheme.dpInt(context, CockpitTheme.TOUCH_TARGET_DP)); + int horizontal = CockpitTheme.dpInt(context, 20f); + view.setPadding(horizontal, 0, horizontal, 0); + view.setClickable(true); + view.setFocusable(true); + view.setBackground(primary + ? CockpitDrawables.actionButton(context) + : CockpitDrawables.secondaryButton(context)); + return view; + } + + public static LinearLayout horizontal(Context context) { + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.HORIZONTAL); + layout.setGravity(Gravity.CENTER_VERTICAL); + return layout; + } + + public static LinearLayout vertical(Context context) { + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.VERTICAL); + return layout; + } + + public static View divider(Context context, boolean vertical) { + View divider = new View(context); + divider.setBackgroundColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.78f)); + if (vertical) { + divider.setLayoutParams(new LinearLayout.LayoutParams( + CockpitTheme.dpInt(context, 1f), ViewGroup.LayoutParams.MATCH_PARENT)); + } else { + divider.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, CockpitTheme.dpInt(context, 1f))); + } + return divider; + } + + public static Space weightedSpace(Context context) { + Space space = new Space(context); + space.setLayoutParams(new LinearLayout.LayoutParams(0, 0, 1f)); + return space; + } + + public static void setPadding(View view, float horizontalDp, float verticalDp) { + int horizontal = CockpitTheme.dpInt(view.getContext(), horizontalDp); + int vertical = CockpitTheme.dpInt(view.getContext(), verticalDp); + view.setPadding(horizontal, vertical, horizontal, vertical); + } + + public static void ensureTouchTarget(View view) { + int target = CockpitTheme.dpInt(view.getContext(), CockpitTheme.TOUCH_TARGET_DP); + view.setMinimumWidth(target); + view.setMinimumHeight(target); + view.setClickable(true); + view.setFocusable(true); + } + + public static LinearLayout.LayoutParams linearParams( + int width, int height, float weight, float marginDp, Context context) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(width, height, weight); + int margin = CockpitTheme.dpInt(context, marginDp); + params.setMargins(margin, margin, margin, margin); + return params; + } + + public static FrameLayout.LayoutParams frameParams( + int width, int height, int gravity, float marginDp, Context context) { + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(width, height, gravity); + int margin = CockpitTheme.dpInt(context, marginDp); + params.setMargins(margin, margin, margin, margin); + return params; + } + + public static void setEnabledAppearance(TextView view, boolean enabled) { + view.setEnabled(enabled); + view.setTextColor(enabled + ? CockpitTheme.COLOR_TEXT_PRIMARY + : CockpitTheme.withAlpha(Color.WHITE, 0.36f)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/GatewayFactory.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/GatewayFactory.java new file mode 100644 index 0000000..2166ff7 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/GatewayFactory.java @@ -0,0 +1,83 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.os.Build; + +import java.util.Locale; + +/** Chooses a visible emulator-safe mock; uncertain/real hardware defaults to the real gateways. */ +public final class GatewayFactory { + public enum Mode { + AUTO, MOCK, REAL + } + + private GatewayFactory() {} + + public static VehicleGateway create(Context context) { + return create(context, Mode.AUTO); + } + + public static VehicleGateway createVehicle(Context context) { + return create(context); + } + + public static VehicleGateway create(Context context, Mode mode) { + Context app = requireContext(context).getApplicationContext(); + return useMock(mode) ? new MockVehicleGateway() : new RealVehicleGateway(app); + } + + public static UsbCanGateway createUsb(Context context, UsbCanGateway.Listener listener) { + return createUsb(context, Mode.AUTO, listener); + } + + public static UsbCanGateway createUsbCan(Context context, UsbCanGateway.Listener listener) { + return createUsb(context, listener); + } + + public static UsbCanGateway createUsb(Context context, Mode mode, + UsbCanGateway.Listener listener) { + Context app = requireContext(context).getApplicationContext(); + return useMock(mode) ? new MockUsbCanGateway(listener) : new RealUsbCanGateway(app, listener); + } + + public static boolean isEmulator() { + String fingerprint = lower(Build.FINGERPRINT); + String model = lower(Build.MODEL); + String manufacturer = lower(Build.MANUFACTURER); + String brand = lower(Build.BRAND); + String device = lower(Build.DEVICE); + String product = lower(Build.PRODUCT); + String hardware = lower(Build.HARDWARE); + + return fingerprint.startsWith("generic") + || fingerprint.startsWith("unknown") + || fingerprint.contains("emulator") + || model.contains("google_sdk") + || model.contains("emulator") + || model.contains("android sdk built for") + || manufacturer.contains("genymotion") + || (brand.startsWith("generic") && device.startsWith("generic")) + || product.contains("sdk_gphone") + || product.contains("google_sdk") + || product.contains("simulator") + || product.contains("vbox86p") + || hardware.contains("goldfish") + || hardware.contains("ranchu") + || hardware.contains("vbox86"); + } + + private static boolean useMock(Mode mode) { + if (mode == Mode.MOCK) return true; + if (mode == Mode.REAL) return false; + return BuildConfig.FORCE_SIMULATION || isEmulator(); + } + + private static Context requireContext(Context context) { + if (context == null) throw new IllegalArgumentException("context is null"); + return context; + } + + private static String lower(String value) { + return value == null ? "" : value.toLowerCase(Locale.US); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/InAppMascotLayer.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/InAppMascotLayer.java new file mode 100644 index 0000000..953a0bc --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/InAppMascotLayer.java @@ -0,0 +1,863 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.util.AttributeSet; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.ScaleGestureDetector; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +/** + * A compact, transparent in-app host for the interactive mascot. + * + *

The layer is deliberately only as large as the mascot. Add it as the last child of the + * activity's root {@link FrameLayout}; unlike a full-screen overlay it cannot consume touches + * outside its own visible square.

+ */ +public final class InAppMascotLayer extends FrameLayout implements MascotActionBus.Listener { + private static final String PREFS_NAME = "n7_in_app_mascot_layer"; + private static final String PREF_HAS_POSITION = "has_position"; + private static final String PREF_X_FRACTION = "x_fraction"; + private static final String PREF_Y_FRACTION = "y_fraction"; + private static final String PREF_PAGE_HAS_POSITION_PREFIX = "page_has_position_"; + private static final String PREF_PAGE_X_FRACTION_PREFIX = "page_x_fraction_"; + private static final String PREF_PAGE_Y_FRACTION_PREFIX = "page_y_fraction_"; + private static final String PREF_LAYOUT_VERSION = "layout_version"; + /** Version 5 restores one user-owned position shared by every app page. */ + private static final int CURRENT_LAYOUT_VERSION = 5; + private static final int DEFAULT_SIZE_DP = MascotActionBus.DEFAULT_SIZE_DP; + private static final float DEFAULT_X_FRACTION = 1f; + private static final float DEFAULT_Y_FRACTION = 0.29f; + + private static final MascotActionBus.Action[] DEMO_ACTIONS = { + MascotActionBus.Action.IDLE, + MascotActionBus.Action.CLIMATE, + MascotActionBus.Action.WINDOW, + MascotActionBus.Action.LIGHT, + MascotActionBus.Action.SUNSHADE, + MascotActionBus.Action.DEVICE, + MascotActionBus.Action.MUSIC + }; + + private final SharedPreferences preferences; + private final MascotView mascotView; + private final GestureDetector gestureDetector; + private final ScaleGestureDetector scaleDetector; + private final int touchSlop; + private final float density; + + private int currentSizeDp; + private int activePointerId = MotionEvent.INVALID_POINTER_ID; + private float downParentX; + private float downParentY; + private float startLayerX; + private float startLayerY; + private boolean dragging; + private boolean scaling; + private boolean multiTouchSequence; + private boolean allowTap; + private boolean restorePending = true; + private boolean listeningForSize; + private boolean touchSequenceAccepted; + private boolean hasUserPosition; + private boolean positionInitialized; + private boolean legacyPagePositionMigrationPending; + private MascotActionBus.Action dragRestoreAction; + private MascotInteractionMapper.DragDirection dragDirection = + MascotInteractionMapper.DragDirection.NONE; + private float pendingTapX = Float.NaN; + private float pendingTapY = Float.NaN; + private float defaultXFraction = DEFAULT_X_FRACTION; + private float defaultYFraction = DEFAULT_Y_FRACTION; + private int defaultDockPageId = -1; + private float scaleStartSizeDp; + private float accumulatedScale = 1f; + private int rotationPointerIdA = MotionEvent.INVALID_POINTER_ID; + private int rotationPointerIdB = MotionEvent.INVALID_POINTER_ID; + private float previousTwoFingerAngleDegrees; + private boolean twoFingerRotationActive; + + private boolean parentLayoutListenerAttached; + private final OnLayoutChangeListener parentLayoutListener = + (view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + boolean parentSizeChanged = right - left != oldRight - oldLeft + || bottom - top != oldBottom - oldTop; + if (restorePending || parentSizeChanged) { + restoreState(); + } else { + clampCurrentPosition(); + bringToFront(); + } + }; + + public InAppMascotLayer(Context context) { + this(context, null); + } + + public InAppMascotLayer(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public InAppMascotLayer(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + density = getResources().getDisplayMetrics().density; + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + preferences = context.getApplicationContext() + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + currentSizeDp = MascotPreferences.readSizeDp(context, DEFAULT_SIZE_DP); + hasUserPosition = preferences.getBoolean(PREF_HAS_POSITION, false); + int storedLayoutVersion = preferences.getInt(PREF_LAYOUT_VERSION, 0); + legacyPagePositionMigrationPending = storedLayoutVersion < CURRENT_LAYOUT_VERSION + && !hasUserPosition; + if (storedLayoutVersion < CURRENT_LAYOUT_VERSION + && !legacyPagePositionMigrationPending) { + preferences.edit().putInt(PREF_LAYOUT_VERSION, CURRENT_LAYOUT_VERSION).apply(); + } + + setBackgroundColor(Color.TRANSPARENT); + setClipChildren(false); + setClipToPadding(false); + setClickable(true); + setFocusable(true); + setContentDescription("3D 桌宠;单指拖动,双指缩放和旋转,单击互动,双击切换动作"); + setElevation(dp(24)); + + mascotView = new MascotView(context); + mascotView.setClickable(false); + mascotView.setFocusable(false); + addView(mascotView, new FrameLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, Gravity.CENTER)); + + gestureDetector = new GestureDetector(context, + new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onDown(MotionEvent event) { + return true; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent event) { + if (!allowTap || dragging || scaling || multiTouchSequence) return false; + pendingTapX = event.getX(); + pendingTapY = event.getY(); + return performClick(); + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + if (!allowTap || dragging || scaling || multiTouchSequence) return false; + playNextDemoAction(); + return true; + } + }); + + scaleDetector = new ScaleGestureDetector(context, + new ScaleGestureDetector.SimpleOnScaleGestureListener() { + @Override + public boolean onScaleBegin(ScaleGestureDetector detector) { + scaling = true; + allowTap = false; + animate().cancel(); + scaleStartSizeDp = currentSizeDp; + accumulatedScale = 1f; + return true; + } + + @Override + public boolean onScale(ScaleGestureDetector detector) { + accumulatedScale *= detector.getScaleFactor(); + int requested = Math.round(scaleStartSizeDp * accumulatedScale); + applySizeDp(requested, true, true); + return true; + } + + @Override + public void onScaleEnd(ScaleGestureDetector detector) { + scaling = false; + hasUserPosition = true; + clampCurrentPosition(); + persistState(); + } + }); + } + + /** + * Adds (or reuses) the mascot as the top-most, compact child of an activity root. + */ + public static InAppMascotLayer attach(FrameLayout root) { + if (root == null) throw new IllegalArgumentException("root FrameLayout must not be null"); + for (int index = root.getChildCount() - 1; index >= 0; index--) { + View child = root.getChildAt(index); + if (child instanceof InAppMascotLayer) { + InAppMascotLayer existing = (InAppMascotLayer) child; + if (MascotActionBus.isOverlayRunning()) existing.stop(); + else existing.start(); + return existing; + } + } + + InAppMascotLayer layer = new InAppMascotLayer(root.getContext()); + int sizePx = layer.resolvedSizePx(layer.currentSizeDp); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(sizePx, sizePx); + params.gravity = Gravity.TOP | Gravity.START; + // Publish the authoritative shared value before an attached root can synchronously invoke + // onAttachedToWindow(). This prevents a stale per-host value from resetting the overlay. + MascotActionBus.setSizeDp(layer.currentSizeDp); + root.addView(layer, params); + if (MascotActionBus.isOverlayRunning()) layer.stop(); + else layer.start(); + return layer; + } + + /** Makes the in-app mascot visible and restores its persisted size and location. */ + public void start() { + setVisibility(VISIBLE); + setEnabled(true); + restorePending = true; + bringToFront(); + restoreState(); + } + + /** Hides the in-app mascot without removing it, preserving its state for the next start. */ + public void stop() { + persistState(); + animate().cancel(); + restoreActionAfterDrag(); + setEnabled(false); + setVisibility(GONE); + } + + /** Removes the layer from its root after persisting its state. */ + public void detach() { + persistState(); + animate().cancel(); + restoreActionAfterDrag(); + ViewGroup parent = (ViewGroup) getParent(); + if (parent != null) parent.removeView(this); + } + + public MascotView getMascotView() { + return mascotView; + } + + public int getSizeDp() { + return currentSizeDp; + } + + /** + * Records the current page without changing the mascot's established screen position. + * The first page may supply an initial location; after that the user owns one global position. + */ + public boolean setPageDefaultDock(int pageId, float xFraction, float yFraction) { + boolean firstPage = defaultDockPageId < 0; + defaultDockPageId = pageId; + if (firstPage && !positionInitialized && !hasUserPosition) { + defaultXFraction = clamp01(xFraction); + defaultYFraction = clamp01(yFraction); + } + + boolean migrated = migrateLegacyPagePosition(pageId); + if (!positionInitialized || migrated) { + restorePending = true; + post(this::restoreState); + } else { + // Page replacement may request a root layout even when its dimensions are unchanged. + // Only clamp to the display bounds; never re-dock around page-specific controls. + post(() -> { + clampCurrentPosition(); + bringToFront(); + }); + } + return !hasUserPosition; + } + + public int getDefaultDockPageId() { + return defaultDockPageId; + } + + public boolean hasUserPosition() { + return hasUserPosition; + } + + /** Clears a manual position, for example from a settings reset action. */ + public void clearUserPosition() { + hasUserPosition = false; + preferences.edit() + .putBoolean(PREF_HAS_POSITION, false) + .remove(PREF_X_FRACTION) + .remove(PREF_Y_FRACTION) + .apply(); + restorePending = true; + restoreState(); + } + + /** Programmatic counterpart to pinch-to-resize. */ + public void setSizeDp(int sizeDp) { + applySizeDp(sizeDp, true, true); + hasUserPosition = true; + post(() -> { + clampCurrentPosition(); + persistState(); + }); + } + + /** Restores size and normalized location, remaining valid across display-size changes. */ + public void restoreState() { + FrameLayout parent = parentFrameLayout(); + if (parent == null || parent.getWidth() <= 0 || parent.getHeight() <= 0) { + restorePending = true; + return; + } + + hasUserPosition = preferences.getBoolean(PREF_HAS_POSITION, false); + int sharedSizeDp = MascotPreferences.readSizeDp(getContext(), DEFAULT_SIZE_DP); + applySizeDp(sharedSizeDp, false, false); + MascotActionBus.setSizeDp(sharedSizeDp); + + int sizePx = resolvedSizePx(currentSizeDp); + PositionBounds bounds = boundsFor(parent, sizePx, sizePx); + float xFraction = hasUserPosition + ? clamp01(preferences.getFloat(PREF_X_FRACTION, defaultXFraction)) + : defaultXFraction; + float yFraction = hasUserPosition + ? clamp01(preferences.getFloat(PREF_Y_FRACTION, defaultYFraction)) + : defaultYFraction; + float requestedX = bounds.minX + bounds.travelX() * xFraction; + float requestedY = bounds.minY + bounds.travelY() * yFraction; + moveWithinParent(requestedX, requestedY, sizePx, sizePx); + restorePending = false; + positionInitialized = true; + bringToFront(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!listeningForSize) { + listeningForSize = true; + MascotActionBus.addListener(this); + } + attachParentLayoutListener(); + post(this::restoreState); + } + + @Override + public void onMascotSizeChanged(int requestedSizeDp) { + int safeSizeDp = clampSizeDp(requestedSizeDp); + applySizeDp(safeSizeDp, true, false); + post(() -> { + clampCurrentPosition(); + persistState(); + }); + } + + @Override + public void onMascotOverlayStateChanged(boolean running) { + if (running) { + mascotView.onPause(); + stop(); + } else if (isAttachedToWindow() && getWindowVisibility() == VISIBLE) { + start(); + mascotView.onResume(); + } + } + + @Override + protected void onDetachedFromWindow() { + persistState(); + restoreActionAfterDrag(); + if (listeningForSize) { + MascotActionBus.removeListener(this); + listeningForSize = false; + } + detachParentLayoutListener(); + animate().cancel(); + super.onDetachedFromWindow(); + } + + /** + * Owns gestures only after Android has hit-tested this small child; sibling controls elsewhere + * in the dashboard continue to receive their touches normally. + */ + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + if (!isEnabled() || getVisibility() != VISIBLE) { + touchSequenceAccepted = false; + return false; + } + + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + // The host must stay rectangular for SurfaceView composition, but the character is + // approximately circular. Reject the visibly empty corners so FrameLayout can offer + // the DOWN event to the controls underneath this top-most child. + touchSequenceAccepted = isInsideMascotHitShape(event.getX(), event.getY()); + if (!touchSequenceAccepted) return false; + } else if (!touchSequenceAccepted) { + return false; + } + + scaleDetector.onTouchEvent(event); + gestureDetector.onTouchEvent(event); + handlePositionGesture(event); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + touchSequenceAccepted = false; + } + return true; + } + + private boolean isInsideMascotHitShape(float x, float y) { + float width = getWidth(); + float height = getHeight(); + if (width <= 0f || height <= 0f) return true; + float normalizedX = (x - width * 0.5f) / (width * 0.5f); + float normalizedY = (y - height * 0.5f) / (height * 0.5f); + // The SurfaceView needs a square host, while the visible character and particles leave a + // transparent outer ring. Passing that ring through prevents invisible pixels from + // stealing a nearby cockpit button without making the character itself hard to grab. + float hitRadiusSquared = MascotDockPlanner.HIT_DIAMETER_FRACTION + * MascotDockPlanner.HIT_DIAMETER_FRACTION; + return normalizedX * normalizedX + normalizedY * normalizedY <= hitRadiusSquared; + } + + @Override + public boolean performClick() { + super.performClick(); + MascotInteractionMapper.TapRegion region; + if (Float.isFinite(pendingTapX) && Float.isFinite(pendingTapY)) { + region = MascotInteractionMapper.mapTap( + pendingTapX, pendingTapY, getWidth(), getHeight()); + } else { + // Accessibility clicks have no pointer coordinates; petting the head is the safest + // and most familiar semantic default. + region = MascotInteractionMapper.TapRegion.HEAD; + } + pendingTapX = Float.NaN; + pendingTapY = Float.NaN; + performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + mascotView.playAction( + MascotInteractionMapper.actionForTap(region), + MascotInteractionMapper.durationForTap(region)); + return true; + } + + private void handlePositionGesture(MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + animate().cancel(); + restoreActionAfterDrag(); + activePointerId = event.getPointerId(0); + downParentX = event.getX(0) + getX(); + downParentY = event.getY(0) + getY(); + startLayerX = getX(); + startLayerY = getY(); + dragging = false; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + multiTouchSequence = false; + allowTap = true; + break; + + case MotionEvent.ACTION_POINTER_DOWN: + multiTouchSequence = true; + allowTap = false; + restoreActionAfterDrag(); + dragging = false; + if (!twoFingerRotationActive && event.getPointerCount() >= 2) { + beginTwoFingerRotation(event); + } + break; + + case MotionEvent.ACTION_MOVE: + if (event.getPointerCount() >= 2) { + updateTwoFingerRotation(event); + return; + } + if (scaling || multiTouchSequence || event.getPointerCount() != 1) return; + int pointerIndex = event.findPointerIndex(activePointerId); + if (pointerIndex < 0) return; + float parentX = event.getX(pointerIndex) + getX(); + float parentY = event.getY(pointerIndex) + getY(); + float deltaX = parentX - downParentX; + float deltaY = parentY - downParentY; + + if (!dragging) { + float distanceSquared = deltaX * deltaX + deltaY * deltaY; + if (distanceSquared > touchSlop * touchSlop) { + dragging = true; + allowTap = false; + updateDragAction(deltaX, deltaY); + } + } + if (dragging) { + updateDragAction(deltaX, deltaY); + moveWithinParent(startLayerX + deltaX, startLayerY + deltaY); + } + break; + + case MotionEvent.ACTION_POINTER_UP: + multiTouchSequence = true; + allowTap = false; + rebaselineRotationAfterPointerUp(event); + break; + + case MotionEvent.ACTION_UP: + if (dragging) { + hasUserPosition = true; + clampCurrentPosition(); + persistState(); + restoreActionAfterDrag(); + } + else if (multiTouchSequence) persistState(); + else restoreActionAfterDrag(); + activePointerId = MotionEvent.INVALID_POINTER_ID; + dragging = false; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + scaling = false; + resetTwoFingerRotation(); + break; + + case MotionEvent.ACTION_CANCEL: + if (dragging) hasUserPosition = true; + activePointerId = MotionEvent.INVALID_POINTER_ID; + dragging = false; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + scaling = false; + allowTap = false; + resetTwoFingerRotation(); + clampCurrentPosition(); + persistState(); + restoreActionAfterDrag(); + break; + + default: + break; + } + } + + private void beginTwoFingerRotation(MotionEvent event) { + if (event.getPointerCount() < 2) { + resetTwoFingerRotation(); + return; + } + setRotationPointers(event, 0, 1); + } + + private void updateTwoFingerRotation(MotionEvent event) { + if (!twoFingerRotationActive) beginTwoFingerRotation(event); + if (!twoFingerRotationActive) return; + + int indexA = event.findPointerIndex(rotationPointerIdA); + int indexB = event.findPointerIndex(rotationPointerIdB); + if (indexA < 0 || indexB < 0) { + beginTwoFingerRotation(event); + return; + } + + float angle = twoFingerAngleDegrees(event, indexA, indexB); + float delta = normalizeAngleDelta(angle - previousTwoFingerAngleDegrees); + if (Float.isFinite(delta) && Math.abs(delta) > 0.01f) { + mascotView.addUserRotation(delta); + } + previousTwoFingerAngleDegrees = angle; + } + + private void rebaselineRotationAfterPointerUp(MotionEvent event) { + int liftedIndex = event.getActionIndex(); + int liftedId = event.getPointerId(liftedIndex); + if (!twoFingerRotationActive + || (liftedId != rotationPointerIdA && liftedId != rotationPointerIdB)) { + return; + } + + int firstRemaining = -1; + int secondRemaining = -1; + for (int index = 0; index < event.getPointerCount(); index++) { + if (index == liftedIndex) continue; + if (firstRemaining < 0) firstRemaining = index; + else { + secondRemaining = index; + break; + } + } + if (secondRemaining >= 0) { + setRotationPointers(event, firstRemaining, secondRemaining); + } else { + resetTwoFingerRotation(); + } + } + + private void setRotationPointers(MotionEvent event, int indexA, int indexB) { + rotationPointerIdA = event.getPointerId(indexA); + rotationPointerIdB = event.getPointerId(indexB); + previousTwoFingerAngleDegrees = twoFingerAngleDegrees(event, indexA, indexB); + twoFingerRotationActive = true; + } + + private void resetTwoFingerRotation() { + rotationPointerIdA = MotionEvent.INVALID_POINTER_ID; + rotationPointerIdB = MotionEvent.INVALID_POINTER_ID; + previousTwoFingerAngleDegrees = 0f; + twoFingerRotationActive = false; + } + + private static float twoFingerAngleDegrees(MotionEvent event, int indexA, int indexB) { + return (float) Math.toDegrees(Math.atan2( + event.getY(indexB) - event.getY(indexA), + event.getX(indexB) - event.getX(indexA))); + } + + private static float normalizeAngleDelta(float degrees) { + float normalized = degrees; + while (normalized > 180f) normalized -= 360f; + while (normalized < -180f) normalized += 360f; + return normalized; + } + + private void playNextDemoAction() { + MascotActionBus.Action current = MascotActionBus.getAction(); + int currentIndex = -1; + for (int index = 0; index < DEMO_ACTIONS.length; index++) { + if (DEMO_ACTIONS[index] == current) { + currentIndex = index; + break; + } + } + MascotActionBus.Action next = DEMO_ACTIONS[(currentIndex + 1) % DEMO_ACTIONS.length]; + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + mascotView.playAction(next, + next == MascotActionBus.Action.MUSIC ? 0L : MascotActionTiming.demoMs(next)); + } + + private void applySizeDp(int requestedSizeDp, boolean preserveCenter, boolean notifyBus) { + int safeSizeDp = clampSizeDp(requestedSizeDp); + int newSizePx = resolvedSizePx(safeSizeDp); + boolean logicalSizeChanged = currentSizeDp != safeSizeDp; + float centerX = getX() + Math.max(getWidth(), resolvedSizePx(currentSizeDp)) * 0.5f; + float centerY = getY() + Math.max(getHeight(), resolvedSizePx(currentSizeDp)) * 0.5f; + + ViewGroup.LayoutParams rawParams = getLayoutParams(); + FrameLayout.LayoutParams params; + if (rawParams instanceof FrameLayout.LayoutParams) { + params = (FrameLayout.LayoutParams) rawParams; + } else { + params = new FrameLayout.LayoutParams(newSizePx, newSizePx); + params.gravity = Gravity.TOP | Gravity.START; + } + boolean layoutSizeChanged = params.width != newSizePx || params.height != newSizePx; + params.width = newSizePx; + params.height = newSizePx; + currentSizeDp = safeSizeDp; + // restoreState() can run from the parent's OnLayoutChangeListener. Calling + // setLayoutParams() there for an unchanged size produces a requestLayout-during-layout + // warning on Android 9, so only submit genuinely changed dimensions. + if (layoutSizeChanged) setLayoutParams(params); + + if (preserveCenter) { + moveWithinParent(centerX - newSizePx * 0.5f, + centerY - newSizePx * 0.5f, newSizePx, newSizePx); + } else { + moveWithinParent(getX(), getY(), newSizePx, newSizePx); + } + if (logicalSizeChanged) MascotPreferences.writeSizeDp(getContext(), safeSizeDp); + if (notifyBus) MascotActionBus.setSizeDp(safeSizeDp); + } + + private void moveWithinParent(float requestedX, float requestedY) { + int width = getWidth() > 0 ? getWidth() : resolvedSizePx(currentSizeDp); + int height = getHeight() > 0 ? getHeight() : resolvedSizePx(currentSizeDp); + moveWithinParent(requestedX, requestedY, width, height); + } + + private void moveWithinParent( + float requestedX, float requestedY, int childWidth, int childHeight) { + FrameLayout parent = parentFrameLayout(); + if (parent == null || parent.getWidth() <= 0 || parent.getHeight() <= 0) { + setX(requestedX); + setY(requestedY); + return; + } + PositionBounds bounds = boundsFor(parent, childWidth, childHeight); + setX(clamp(requestedX, bounds.minX, bounds.maxX)); + setY(clamp(requestedY, bounds.minY, bounds.maxY)); + } + + private void clampCurrentPosition() { + moveWithinParent(getX(), getY()); + } + + private void updateDragAction(float deltaX, float deltaY) { + MascotInteractionMapper.DragDirection resolved = + MascotInteractionMapper.resolveDrag(deltaX, deltaY, dragDirection); + if (resolved == MascotInteractionMapper.DragDirection.NONE + || resolved == dragDirection) { + return; + } + if (dragRestoreAction == null) { + MascotActionBus.Action underlying = + mascotView.takeUnderlyingActionForInteraction(); + dragRestoreAction = stableActionAfterDrag(underlying); + } + dragDirection = resolved; + MascotActionBus.perform(MascotInteractionMapper.actionForDrag(resolved)); + } + + private void restoreActionAfterDrag() { + MascotActionBus.Action restore = dragRestoreAction; + dragRestoreAction = null; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + if (restore != null + && MascotInteractionMapper.isDragAction(MascotActionBus.getAction())) { + MascotActionBus.perform(restore); + } + } + + private static MascotActionBus.Action stableActionAfterDrag( + MascotActionBus.Action current) { + if (current == MascotActionBus.Action.MUSIC + || current == MascotActionBus.Action.DRIVE + || current == MascotActionBus.Action.SLEEP) { + return current; + } + return MascotActionBus.Action.IDLE; + } + + private void persistState() { + FrameLayout parent = parentFrameLayout(); + MascotPreferences.writeSizeDp(getContext(), currentSizeDp); + SharedPreferences.Editor editor = preferences.edit() + .putBoolean(PREF_HAS_POSITION, hasUserPosition) + .putInt(PREF_LAYOUT_VERSION, CURRENT_LAYOUT_VERSION); + if (hasUserPosition + && parent != null + && parent.getWidth() > 0 + && parent.getHeight() > 0) { + int width = getWidth() > 0 ? getWidth() : resolvedSizePx(currentSizeDp); + int height = getHeight() > 0 ? getHeight() : resolvedSizePx(currentSizeDp); + PositionBounds bounds = boundsFor(parent, width, height); + float xFraction = bounds.travelX() <= 0f + ? 0f : (getX() - bounds.minX) / bounds.travelX(); + float yFraction = bounds.travelY() <= 0f + ? 0f : (getY() - bounds.minY) / bounds.travelY(); + editor.putFloat(PREF_X_FRACTION, clamp01(xFraction)) + .putFloat(PREF_Y_FRACTION, clamp01(yFraction)); + } else if (!hasUserPosition) { + editor.remove(PREF_X_FRACTION).remove(PREF_Y_FRACTION); + } + editor.apply(); + } + + private boolean migrateLegacyPagePosition(int pageId) { + if (!legacyPagePositionMigrationPending) return false; + legacyPagePositionMigrationPending = false; + boolean legacyHasPosition = preferences.getBoolean(pageHasPositionKey(pageId), false); + SharedPreferences.Editor editor = preferences.edit() + .putInt(PREF_LAYOUT_VERSION, CURRENT_LAYOUT_VERSION); + if (legacyHasPosition) { + float xFraction = clamp01(preferences.getFloat( + pageXFractionKey(pageId), defaultXFraction)); + float yFraction = clamp01(preferences.getFloat( + pageYFractionKey(pageId), defaultYFraction)); + editor.putBoolean(PREF_HAS_POSITION, true) + .putFloat(PREF_X_FRACTION, xFraction) + .putFloat(PREF_Y_FRACTION, yFraction); + hasUserPosition = true; + } + editor.apply(); + return legacyHasPosition; + } + + private static String pageHasPositionKey(int pageId) { + return PREF_PAGE_HAS_POSITION_PREFIX + pageId; + } + + private static String pageXFractionKey(int pageId) { + return PREF_PAGE_X_FRACTION_PREFIX + pageId; + } + + private static String pageYFractionKey(int pageId) { + return PREF_PAGE_Y_FRACTION_PREFIX + pageId; + } + + private void attachParentLayoutListener() { + FrameLayout parent = parentFrameLayout(); + if (parent != null && !parentLayoutListenerAttached) { + parent.addOnLayoutChangeListener(parentLayoutListener); + parentLayoutListenerAttached = true; + } + } + + private void detachParentLayoutListener() { + FrameLayout parent = parentFrameLayout(); + if (parent != null && parentLayoutListenerAttached) { + parent.removeOnLayoutChangeListener(parentLayoutListener); + } + parentLayoutListenerAttached = false; + } + + private FrameLayout parentFrameLayout() { + return getParent() instanceof FrameLayout ? (FrameLayout) getParent() : null; + } + + private PositionBounds boundsFor( + FrameLayout parent, int childWidth, int childHeight) { + float minX = parent.getPaddingLeft(); + float minY = parent.getPaddingTop(); + float maxX = Math.max(minX, + parent.getWidth() - parent.getPaddingRight() - childWidth); + float maxY = Math.max(minY, + parent.getHeight() - parent.getPaddingBottom() - childHeight); + return new PositionBounds(minX, minY, maxX, maxY); + } + + private int clampSizeDp(int sizeDp) { + return Math.max(MascotActionBus.MIN_SIZE_DP, + Math.min(MascotActionBus.MAX_SIZE_DP, sizeDp)); + } + + private int dp(float value) { + return Math.round(value * density); + } + + private int resolvedSizePx(int requestedSizeDp) { + int densitySize = dp(clampSizeDp(requestedSizeDp)); + int responsiveMaximum = CockpitLayoutProfile.from(getContext()).inAppMascotMaximumPx(); + return Math.max(1, Math.min(densitySize, responsiveMaximum)); + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static float clamp01(float value) { + return clamp(value, 0f, 1f); + } + + private static final class PositionBounds { + final float minX; + final float minY; + final float maxX; + final float maxY; + + PositionBounds(float minX, float minY, float maxX, float maxY) { + this.minX = minX; + this.minY = minY; + this.maxX = maxX; + this.maxY = maxY; + } + + float travelX() { + return maxX - minX; + } + + float travelY() { + return maxY - minY; + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MainActivity.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MainActivity.java new file mode 100644 index 0000000..6d7c929 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MainActivity.java @@ -0,0 +1,2363 @@ +package com.seeker.n7cockpit; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.Dialog; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.graphics.Typeface; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.util.TypedValue; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.SeekBar; +import android.widget.TextView; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Production-style, dependency-free cockpit shell. + * + *

The Activity deliberately talks only to the typed gateway boundaries. Emulator builds use + * the visible mock gateways while a head unit keeps the original helper, FID and USB-CAN + * allowlists. No free-form command, CAN identifier or shell input is exposed by this screen.

+ */ +@SuppressLint("SetTextI18n") +public final class MainActivity extends Activity { + private static final int PAGE_HOME = 1101; + private static final int PAGE_MODES = 1106; + private static final int PAGE_CLIMATE = 1102; + private static final int PAGE_CONTROLS = 1103; + private static final int PAGE_DEVICE = 1104; + private static final int PAGE_SETTINGS = 1105; + private static final int REQUEST_OVERLAY = 2201; + private static final long POLL_INTERVAL_MS = 5_000L; + /** Debug-only ADB hook used to capture deterministic 3D state evidence. */ + static final String EXTRA_3D_SHOWCASE = + "com.seeker.n7cockpit.extra.DEBUG_3D_SHOWCASE"; + /** Debug-only ADB hook that pins the mascot to one action for screenshot evidence. */ + static final String EXTRA_MASCOT_ACTION = + "com.seeker.n7cockpit.extra.DEBUG_MASCOT_ACTION"; + + private enum FeedbackKind { + INFO, WORKING, SUCCESS, WARNING, ERROR + } + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final List vehicleControls = new ArrayList<>(); + private final SimpleDateFormat shortTime = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); + + private VehicleGateway vehicleGateway; + private UsbCanGateway usbCanGateway; + private ModeApiClient modeApiClient; + private VehicleSnapshot latestSnapshot; + + private CockpitNavigationRail navigationRail; + private CockpitStatusBar statusBar; + private CockpitLayoutProfile layoutProfile; + private FrameLayout pageHost; + private InAppMascotLayer inAppMascotLayer; + private TextView simulationBanner; + private View feedbackAccent; + private TextView feedbackText; + private TextView feedbackTime; + + private final FrameLayout[] pages = new FrameLayout[6]; + private int currentPage = PAGE_HOME; + private boolean gatewayReady; + private boolean commandPending; + private boolean snapshotPending; + private boolean started; + private boolean destroyed; + private boolean musicStateReceiverRegistered; + private boolean startOverlayAfterPermission; + private Dialog safetyDialog; + private Boolean rememberedRearDefrost; + private Boolean rememberedInteriorLight; + private Boolean rememberedAmbientLight; + private Boolean rememberedSunshadeOpen; + private int lastFanLevel = 3; + private String debug3dShowcase = ""; + private long deviceOperationStartedMs; + private int deviceOperationGeneration; + private int deviceResultScheduledGeneration = -1; + private long remoteModeEventSequence; + private boolean remoteModeEventsPrimed; + private boolean remoteModeEventPending; + private String activeRemoteModeId = ""; + private String renderedRemoteExecutionId = ""; + + // Remote Cognitive_Agent mode page. + private TextView remoteModeConnectionText; + private TextView remoteModeStatusText; + private LinearLayout remoteModeList; + private final Map remoteModeButtons = new LinkedHashMap<>(); + + // Home page. + private TextView homeSpeed; + private TextView homeSoc; + private TextView homeMileage; + private TextView homePower; + private TextView homeGear; + private TextView homeEnvironment; + private TextView homeLastAction; + private TextView musicButton; + private VehicleSilhouetteView homeVehicle; + private ClimateDialView homeClimateDial; + + // Climate page. + private ClimateDialView climateDial; + private TextView climateSummary; + private TextView cabinTemperature; + private TextView outsideTemperature; + private TextView fanReadout; + private TextView climatePowerOnButton; + private TextView climatePowerOffButton; + private TextView climateAutoButton; + private TextView climateManualButton; + private TextView climateInnerButton; + private TextView climateOuterButton; + private TextView frontDefrostOnButton; + private TextView frontDefrostOffButton; + private TextView rearDefrostOnButton; + private TextView rearDefrostOffButton; + + // Vehicle controls page. + private WindowControlView windowControl; + private VehicleSilhouetteView controlVehicle; + private TextView interiorLightOnButton; + private TextView interiorLightOffButton; + private TextView ambientLightOnButton; + private TextView ambientLightOffButton; + private TextView drlOnButton; + private TextView drlOffButton; + private TextView sunshadeOpenButton; + private TextView sunshadeCloseButton; + + // Device page. + private TextView vehicleSensorText; + private TextView usbStatusText; + private TextView gatewayEnvironmentText; + + // Settings page. + private TextView overlayPermissionText; + private TextView overlayRunText; + private TextView mascotSizeText; + private SeekBar mascotSizeSeek; + + private final Runnable pollRunnable = new Runnable() { + @Override + public void run() { + if (!started || destroyed) return; + refreshSnapshot(false); + mainHandler.postDelayed(this, POLL_INTERVAL_MS); + } + }; + + private final Runnable remoteModePollRunnable = new Runnable() { + @Override + public void run() { + if (!started || destroyed || modeApiClient == null) return; + if (!remoteModeEventPending) { + remoteModeEventPending = true; + modeApiClient.loadEvents(remoteModeEventSequence, (success, events, detail) -> + mainHandler.post(() -> { + remoteModeEventPending = false; + if (destroyed) return; + if (success && events != null) { + remoteModeEventSequence = events.nextSequence; + if (remoteModeConnectionText != null) { + remoteModeConnectionText.setText( + "编排服务已连接 · " + + BuildConfig.ORCHESTRATOR_BASE_URL); + remoteModeConnectionText.setTextColor( + CockpitTheme.COLOR_SUCCESS); + } + if (!remoteModeEventsPrimed) { + remoteModeEventsPrimed = true; + for (ModeApiClient.Execution execution + : events.activeExecutions) { + applyRemoteModeExecution(execution); + } + } else { + for (ModeApiClient.Execution execution : events.executions) { + applyRemoteModeExecution(execution); + } + } + } else if (remoteModeConnectionText != null) { + remoteModeConnectionText.setText("编排服务连接失败 · " + detail); + remoteModeConnectionText.setTextColor(CockpitTheme.COLOR_DANGER); + } + })); + } + mainHandler.postDelayed(this, 1_000L); + } + }; + + private final BroadcastReceiver musicStateReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null + || !MascotMusicService.ACTION_STATE_CHANGED.equals(intent.getAction())) return; + updateMusicButton(); + if (statusBar != null) { + statusBar.setVolumeEnabled(MascotMusicService.isPlaying()); + } + if (!started) return; + String reason = intent.getStringExtra(MascotMusicService.EXTRA_REASON); + if (MascotMusicService.isPlaying() + && MascotMusicService.REASON_PLAYING.equals(reason)) { + showFeedback(FeedbackKind.SUCCESS, + "测试音乐循环播放中 · 派蒙由本项目实时 PCM 频率驱动"); + } else if (MascotMusicService.REASON_AUDIO_FOCUS.equals(reason)) { + showFeedback(FeedbackKind.WARNING, "其他音频取得焦点,测试音乐与频谱已停止"); + } else if (MascotMusicService.REASON_START_FAILED.equals(reason)) { + showFeedback(FeedbackKind.ERROR, "无法取得媒体音频焦点或初始化音频输出"); + showResultMascot(false); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (BuildConfig.DEBUG) { + String requestedShowcase = getIntent().getStringExtra(EXTRA_3D_SHOWCASE); + debug3dShowcase = requestedShowcase == null + ? "" : requestedShowcase.trim().toLowerCase(Locale.ROOT); + } getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + getWindow().setNavigationBarColor(CockpitTheme.COLOR_BACKGROUND); + applyImmersiveMode(); + + vehicleGateway = GatewayFactory.create(this); + buildCockpit(); + modeApiClient = new ModeApiClient( + BuildConfig.ORCHESTRATOR_BASE_URL, + BuildConfig.ORCHESTRATOR_API_TOKEN, + BuildConfig.ORCHESTRATOR_VEHICLE_ID, + BuildConfig.ORCHESTRATOR_HARDWARE_CLIENT_ID); + usbCanGateway = GatewayFactory.createUsb(this, detail -> + mainHandler.post(() -> handleUsbStatus(detail))); + + statusBar.setSimulationMode(vehicleGateway.isSimulation()); + simulationBanner.setVisibility(vehicleGateway.isSimulation() ? View.VISIBLE : View.GONE); + homeEnvironment.setText(vehicleGateway.environmentLabel()); + gatewayEnvironmentText.setText(environmentDescription()); + usbStatusText.setText(usbCanGateway.describe()); + updateOverlayState(); + updateVehicleControlAvailability(); + showFeedback( + FeedbackKind.WORKING, + vehicleGateway.isSimulation() + ? "正在启动模拟车辆网关;操作不会触达实车" + : "正在连接 DiLink 车辆控制服务"); + prepareGateway(); + usbCanGateway.refresh(); + loadRemoteModes(); + loadRemoteHardwareAccess(); + applyDebugMascotAction(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent != null) setIntent(intent); + applyDebugMascotAction(intent); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + int retainedPage = currentPage; + if (inAppMascotLayer != null) { + inAppMascotLayer.getMascotView().onPause(); + inAppMascotLayer.detach(); + inAppMascotLayer = null; + } + buildCockpit(); + navigationRail.setSelectedItem(retainedPage); + showPage(retainedPage); + loadRemoteModes(); + if (vehicleGateway != null) { + boolean simulation = vehicleGateway.isSimulation(); + statusBar.setSimulationMode(simulation); + simulationBanner.setVisibility(simulation ? View.VISIBLE : View.GONE); + homeEnvironment.setText(vehicleGateway.environmentLabel()); + gatewayEnvironmentText.setText(environmentDescription()); + } + if (usbCanGateway != null) usbStatusText.setText(usbCanGateway.describe()); + if (latestSnapshot != null) applySnapshot(latestSnapshot); + updateMusicButton(); + updateOverlayState(); + updateVehicleControlAvailability(); + } + + /** + * Debug-only: pins the mascot to one action so every performance can be captured + * deterministically from ADB. Release builds compile the call but can never enter it. + */ + private void applyDebugMascotAction(Intent intent) { + if (!BuildConfig.DEBUG || intent == null) return; + String requested = intent.getStringExtra(EXTRA_MASCOT_ACTION); + if (requested == null || requested.trim().isEmpty()) return; + try { + MascotActionBus.Action action = MascotActionBus.Action.valueOf( + requested.trim().toUpperCase(Locale.ROOT)); + MascotActionBus.perform(action); + showFeedback(FeedbackKind.INFO, + "调试:桌宠动作已固定为 " + MascotView.accessibilityName(action)); + } catch (IllegalArgumentException ignored) { + showFeedback(FeedbackKind.WARNING, "调试:未知桌宠动作 " + requested); + } + } + + @Override + protected void onStart() { + super.onStart(); + started = true; + registerMusicStateReceiver(); + updateMusicButton(); + if (statusBar != null) { + statusBar.setVolumeEnabled(MascotMusicService.isPlaying()); + } + if (homeVehicle != null) { + homeVehicle.setAnimationEnabled(currentPage == PAGE_HOME); + } + if (controlVehicle != null) { + controlVehicle.setAnimationEnabled(currentPage == PAGE_CONTROLS); + } + syncInAppMascotVisibility(MascotOverlayService.isRunning()); + mainHandler.removeCallbacks(pollRunnable); + mainHandler.post(pollRunnable); + mainHandler.removeCallbacks(remoteModePollRunnable); + mainHandler.post(remoteModePollRunnable); + } + + @Override + protected void onResume() { + super.onResume(); + updateOverlayState(); + if (startOverlayAfterPermission && MascotOverlayService.hasOverlayPermission(this)) { + startOverlayAfterPermission = false; + startMascotOverlay(); + } + } + + @Override + protected void onStop() { + started = false; + unregisterMusicStateReceiver(); + mainHandler.removeCallbacks(pollRunnable); + mainHandler.removeCallbacks(remoteModePollRunnable); + if (homeVehicle != null) homeVehicle.setAnimationEnabled(false); + if (controlVehicle != null) controlVehicle.setAnimationEnabled(false); + pauseInAppMascot(); + super.onStop(); + } + + @Override + protected void onDestroy() { + destroyed = true; + unregisterMusicStateReceiver(); + mainHandler.removeCallbacksAndMessages(null); + if (safetyDialog != null) { + safetyDialog.dismiss(); + safetyDialog = null; + } + if (usbCanGateway != null) usbCanGateway.close(); + if (vehicleGateway != null) vehicleGateway.shutdown(); + if (modeApiClient != null) modeApiClient.close(); + if (inAppMascotLayer != null) { + inAppMascotLayer.getMascotView().onPause(); + inAppMascotLayer.detach(); + inAppMascotLayer = null; + } + super.onDestroy(); + } + + private void registerMusicStateReceiver() { + if (musicStateReceiverRegistered) return; + IntentFilter filter = new IntentFilter(MascotMusicService.ACTION_STATE_CHANGED); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(musicStateReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(musicStateReceiver, filter); + } + musicStateReceiverRegistered = true; + } + + private void unregisterMusicStateReceiver() { + if (!musicStateReceiverRegistered) return; + try { + unregisterReceiver(musicStateReceiver); + } catch (IllegalArgumentException ignored) { + // Activity teardown can race framework receiver cleanup on vendor builds. + } + musicStateReceiverRegistered = false; + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_OVERLAY) updateOverlayState(); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) applyImmersiveMode(); + } + + private void applyImmersiveMode() { + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); + } + + private void buildCockpit() { + layoutProfile = CockpitLayoutProfile.from(this); + FrameLayout root = new FrameLayout(this); + root.setBackgroundColor(CockpitTheme.COLOR_BACKGROUND); + + LinearLayout shell = CockpitViews.horizontal(this); + navigationRail = new CockpitNavigationRail(this); + navigationRail.addItem(PAGE_HOME, CockpitIconView.Icon.HOME, "智舱"); + navigationRail.addItem(PAGE_MODES, CockpitIconView.Icon.DASHBOARD, "模式"); + navigationRail.addItem(PAGE_CLIMATE, CockpitIconView.Icon.CLIMATE, "空调"); + navigationRail.addItem(PAGE_CONTROLS, CockpitIconView.Icon.CONTROLS, "车控"); + navigationRail.addItem(PAGE_DEVICE, CockpitIconView.Icon.DEVICE, "设备"); + navigationRail.addItem(PAGE_SETTINGS, CockpitIconView.Icon.SETTINGS, "设置"); + navigationRail.setOnItemSelectedListener((itemId, button) -> showPage(itemId)); + shell.addView(navigationRail, new LinearLayout.LayoutParams( + dp(layoutProfile.navigationWidthUnits()), ViewGroup.LayoutParams.MATCH_PARENT)); + + LinearLayout right = CockpitViews.vertical(this); + statusBar = new CockpitStatusBar(this); + statusBar.setTitle("智舱"); + statusBar.setBatteryPercent(0); + right.addView(statusBar, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(layoutProfile.statusBarHeightUnits()))); + + simulationBanner = CockpitViews.text( + this, + layoutProfile.tier == CockpitLayoutProfile.Tier.COMPACT + ? "模拟环境 · 本地数据,不连接实车" + : "模拟器演示 · 数据与动作均为本地 Mock · 未验证 BYD Binder 或 USB-CAN 硬件", + 13f, + CockpitTheme.COLOR_WARNING, + CockpitTheme.TYPEFACE_MEDIUM); + simulationBanner.setGravity(Gravity.CENTER); + simulationBanner.setBackgroundColor( + CockpitTheme.withAlpha(CockpitTheme.COLOR_WARNING, 0.09f)); + right.addView(simulationBanner, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(layoutProfile.simulationBannerHeightUnits()))); + + pageHost = new FrameLayout(this); + pages[0] = buildHomePage(); + pages[1] = buildRemoteModesPage(); + pages[2] = buildClimatePage(); + pages[3] = buildControlsPage(); + pages[4] = buildDevicePage(); + pages[5] = buildSettingsPage(); + for (FrameLayout page : pages) { + page.setVisibility(View.GONE); + pageHost.addView(page, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + } + right.addView(pageHost, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + right.addView(buildFeedbackBar(), new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + dp(layoutProfile.feedbackBarHeightUnits()))); + shell.addView(right, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + root.addView(shell, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + setContentView(root); + inAppMascotLayer = InAppMascotLayer.attach(root); + + navigationRail.setSelectedItem(PAGE_HOME); + showPage(PAGE_HOME); + } + + private View buildFeedbackBar() { + LinearLayout bar = CockpitViews.horizontal(this); + bar.setPadding(dp(18f), dp(6f), dp(18f), dp(6f)); + bar.setBackground(CockpitDrawables.outlinedRect( + this, + CockpitTheme.COLOR_BACKGROUND_DEEP, + CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.82f), + 1f, + 0f)); + feedbackAccent = new View(this); + feedbackAccent.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.COLOR_ACTION, 100f)); + bar.addView(feedbackAccent, new LinearLayout.LayoutParams(dp(4f), dp(28f))); + feedbackText = CockpitViews.text( + this, + "系统准备中", + 14f, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_REGULAR); + LinearLayout.LayoutParams messageParams = new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 1f); + messageParams.leftMargin = dp(12f); + bar.addView(feedbackText, messageParams); + feedbackTime = CockpitViews.text( + this, + "--:--:--", + 12f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + feedbackTime.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + bar.addView(feedbackTime, new LinearLayout.LayoutParams(dp(76f), + ViewGroup.LayoutParams.MATCH_PARENT)); + return bar; + } + + private FrameLayout buildHomePage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageColumn(); + + LinearLayout upper = CockpitViews.horizontal(this); + LinearLayout metrics = card("实时驾驶", "来自车辆服务的只读数据"); + LinearLayout speedLine = CockpitViews.horizontal(this); + homeSpeed = CockpitViews.metric(this, "--"); + speedLine.addView(homeSpeed, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + TextView speedUnit = CockpitViews.text(this, "km/h", 14f, + CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_MEDIUM); + speedUnit.setGravity(Gravity.BOTTOM | Gravity.END); + speedLine.addView(speedUnit, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(48f))); + metrics.addView(speedLine, matchWidthWrap()); + metrics.addView(thinDivider(), marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(1f), 0f, 0f, 10f, 0f, 10f)); + homeSoc = valueRow(metrics, "动力电池", "--%"); + homeMileage = valueRow(metrics, "累计里程", "-- km"); + homePower = valueRow(metrics, "实时功率", "-- kW"); + homeGear = valueRow(metrics, "当前挡位", "--"); + homeLastAction = multiLineText("等待车辆状态", 12.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_REGULAR); + homeLastAction.setMaxLines(2); + homeLastAction.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.06f), 12f)); + homeLastAction.setPadding(dp(10f), dp(7f), dp(10f), dp(7f)); + metrics.addView(homeLastAction, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 10f, 0f, 0f)); + upper.addView(metrics, marginParams(dp(238f), ViewGroup.LayoutParams.MATCH_PARENT, + 0f, 0f, 0f, 10f, 0f)); + + LinearLayout vehicleCard = card("车辆状态", "车门、车窗与灯光状态"); + homeVehicle = new VehicleSilhouetteView(this); + homeVehicle.setOnVehicleZoneClickListener(door -> showFeedback( + FeedbackKind.INFO, doorName(door) + "为只读状态,请通过原车安全控制操作")); + vehicleCard.addView(homeVehicle, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + upper.addView(vehicleCard, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 0f, 0f, 10f, 0f)); + + LinearLayout companion = card("派蒙陪伴", "实时 3D 桌宠 · 始终悬浮在智舱前景"); + LinearLayout companionTop = CockpitViews.horizontal(this); + LinearLayout companionCopy = CockpitViews.vertical(this); + TextView companionTitle = CockpitViews.body(this, "派蒙已在前景待命"); + companionCopy.addView(companionTitle, matchWidthWrap()); + homeEnvironment = multiLineText("正在识别运行环境…", 12f, + CockpitTheme.COLOR_WARNING, CockpitTheme.TYPEFACE_MEDIUM); + homeEnvironment.setMaxLines(4); + companionCopy.addView(homeEnvironment, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 8f, 0f, 0f)); + TextView interactionHint = multiLineText( + "拖动贴边 · 双指缩放旋转\n双击切换动作", + 11.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + interactionHint.setMaxLines(2); + interactionHint.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + companionCopy.addView(interactionHint, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + companionTop.addView(companionCopy, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + companion.addView(companionTop, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + musicButton = actionButton("播放测试音乐", true, view -> toggleMusic()); + companion.addView(musicButton, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, 0f, 5f, 0f, 0f)); + LinearLayout shortcuts = CockpitViews.horizontal(this); + TextView climateShortcut = actionButton("空调", false, + view -> navigateTo(PAGE_CLIMATE)); + TextView controlShortcut = actionButton("车控", false, + view -> navigateTo(PAGE_CONTROLS)); + shortcuts.addView(climateShortcut, marginParams(0, dp(54f), 1f, + 0f, 0f, 5f, 0f)); + shortcuts.addView(controlShortcut, marginParams(0, dp(54f), 1f, + 5f, 0f, 0f, 0f)); + companion.addView(shortcuts, matchWidthWrap()); + upper.addView(companion, marginParams( + dp(layoutProfile.homeCompanionWidthUnits()), ViewGroup.LayoutParams.MATCH_PARENT, + 0f, 0f, 0f, 0f, 0f)); + + content.addView(upper, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + homeClimateDial = new ClimateDialView(this); + wireClimateDial(homeClimateDial); + content.addView(homeClimateDial, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(132f), 0f, + 0f, 12f, 0f, 0f)); + page.addView(content, fillFrame()); + return page; + } + + private FrameLayout buildClimatePage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageColumn(); + + LinearLayout overview = CockpitViews.horizontal(this); + climateSummary = CockpitViews.text(this, "空调状态等待刷新", 17f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + overview.addView(climateSummary, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + TextView readOnly = pill("风量只读", CockpitTheme.COLOR_WARNING); + overview.addView(readOnly, new LinearLayout.LayoutParams(dp(94f), dp(30f))); + overview.setPadding(dp(18f), 0, dp(18f), 0); + overview.setBackground(CockpitDrawables.outlinedRect( + this, + CockpitTheme.COLOR_SURFACE, + CockpitTheme.COLOR_STROKE, + 1f, + CockpitTheme.CORNER_SMALL_DP)); + content.addView(overview, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(58f))); + + climateDial = new ClimateDialView(this); + wireClimateDial(climateDial); + content.addView(climateDial, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(150f), 0f, + 0f, 10f, 0f, 10f)); + + LinearLayout lower = CockpitViews.horizontal(this); + LinearLayout readings = card("舱内环境", "传感器状态 · 每 5 秒刷新"); + cabinTemperature = largeReading(readings, "车内温度", "--℃"); + outsideTemperature = largeReading(readings, "车外温度", "--℃"); + fanReadout = largeReading(readings, "当前风量", "-- 档"); + TextView refresh = actionButton("立即刷新", false, view -> refreshSnapshot(true)); + readings.addView(refresh, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52f), 0f, 0f, 8f, 0f, 0f)); + lower.addView(readings, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 0.82f, 0f, 0f, 6f, 0f)); + + LinearLayout modes = card("运行模式", "温度 16–30℃ · 固定白名单"); + climatePowerOnButton = vehicleButton("空调开启", true, + VehicleCommands.climatePower(true), MascotActionBus.Action.CLIMATE); + climatePowerOffButton = vehicleButton("空调关闭", false, + VehicleCommands.climatePower(false), MascotActionBus.Action.CLIMATE); + modes.addView(buttonPair(climatePowerOnButton, climatePowerOffButton), matchWidthWrap()); + climateAutoButton = vehicleButton("自动模式", false, + VehicleCommands.climateAuto(true), MascotActionBus.Action.CLIMATE); + climateManualButton = vehicleButton("手动模式", false, + VehicleCommands.climateAuto(false), MascotActionBus.Action.CLIMATE); + modes.addView(buttonPair(climateAutoButton, climateManualButton), rowMargin()); + climateInnerButton = vehicleButton("内循环", false, + VehicleCommands.climateInnerCycle(true), MascotActionBus.Action.CLIMATE); + climateOuterButton = vehicleButton("外循环", false, + VehicleCommands.climateInnerCycle(false), MascotActionBus.Action.CLIMATE); + modes.addView(buttonPair(climateInnerButton, climateOuterButton), rowMargin()); + lower.addView(modes, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 6f, 0f, 6f, 0f)); + + LinearLayout defrost = card("玻璃除雾", "后挡无独立回读时,选中态仅表示命令已接受"); + frontDefrostOnButton = vehicleButton("前挡开启", false, + VehicleCommands.frontDefrost(true), MascotActionBus.Action.CLIMATE); + frontDefrostOffButton = vehicleButton("前挡关闭", false, + VehicleCommands.frontDefrost(false), MascotActionBus.Action.CLIMATE); + defrost.addView(buttonPair(frontDefrostOnButton, frontDefrostOffButton), matchWidthWrap()); + rearDefrostOnButton = vehicleButton("后挡开启", false, + VehicleCommands.rearDefrost(true), MascotActionBus.Action.CLIMATE); + rearDefrostOffButton = vehicleButton("后挡关闭", false, + VehicleCommands.rearDefrost(false), MascotActionBus.Action.CLIMATE); + defrost.addView(buttonPair(rearDefrostOnButton, rearDefrostOffButton), rowMargin()); + TextView note = multiLineText("风量档位当前仅展示车辆回读,界面不会发送未列入白名单的风量命令。", + 12.5f, CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_REGULAR); + defrost.addView(note, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, 0f, 10f, 0f, 0f)); + lower.addView(defrost, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 6f, 0f, 0f, 0f)); + content.addView(lower, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + page.addView(content, fillFrame()); + return page; + } + + private FrameLayout buildRemoteModesPage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageRow(); + + LinearLayout catalog = card( + "Cognitive_Agent 功能模式", + "模式配置来自服务器 A · 点击后由服务器 A 的 CAN 盒执行"); + remoteModeConnectionText = multiLineText( + "正在连接 " + BuildConfig.ORCHESTRATOR_BASE_URL, + 13f, + CockpitTheme.COLOR_WARNING, + CockpitTheme.TYPEFACE_MEDIUM); + catalog.addView(remoteModeConnectionText, matchWidthWrap()); + + ScrollView scroll = new ScrollView(this); + scroll.setFillViewport(true); + remoteModeList = CockpitViews.vertical(this); + scroll.addView(remoteModeList, new ScrollView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + catalog.addView(scroll, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 10f, 0f, 0f)); + content.addView(catalog, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1.42f, 0f, 0f, 7f, 0f)); + + LinearLayout right = CockpitViews.vertical(this); + LinearLayout execution = card("模式执行状态", "Web 与 Android 共享同一个 execution_id"); + remoteModeStatusText = multiLineText( + "等待选择功能模式", + 15f, + CockpitTheme.COLOR_TEXT_PRIMARY, + CockpitTheme.TYPEFACE_MEDIUM); + remoteModeStatusText.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.COLOR_BACKGROUND_DEEP, 14f)); + remoteModeStatusText.setPadding(dp(14f), dp(14f), dp(14f), dp(14f)); + execution.addView(remoteModeStatusText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + TextView refresh = actionButton("刷新服务器模式", false, view -> loadRemoteModes()); + TextView access = actionButton("申请真实硬件控制", true, + view -> requestRemoteHardwareAccess()); + execution.addView(refresh, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f), 0f, + 0f, 12f, 0f, 0f)); + execution.addView(access, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f), 0f, + 0f, 8f, 0f, 0f)); + right.addView(execution, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.25f)); + + LinearLayout explanation = card("远程链路", "Android → 服务器 A → CAN → 状态回读"); + TextView details = multiLineText( + "Android 仅提交服务器中已有的 mode_id,不发送座椅参数或原始 CAN。\n\n" + + "服务器 A 完成模式展开、安全校验和 CAN 执行;Web 页面每秒同步同一事件流。\n\n" + + "真实硬件执行仍需服务器 A 本机批准当前终端:" + + BuildConfig.ORCHESTRATOR_HARDWARE_CLIENT_ID, + 12.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + explanation.addView(details, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + right.addView(explanation, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.75f, + 0f, 12f, 0f, 0f)); + content.addView(right, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 0.88f, 7f, 0f, 0f, 0f)); + + page.addView(content, fillFrame()); + return page; + } + + private void loadRemoteModes() { + if (modeApiClient == null || remoteModeList == null) return; + remoteModeConnectionText.setText("正在连接 " + BuildConfig.ORCHESTRATOR_BASE_URL); + remoteModeConnectionText.setTextColor(CockpitTheme.COLOR_WARNING); + modeApiClient.loadModes((success, modes, detail) -> mainHandler.post(() -> { + if (destroyed || remoteModeList == null) return; + if (!success) { + remoteModeConnectionText.setText("编排服务不可用 · " + detail); + remoteModeConnectionText.setTextColor(CockpitTheme.COLOR_DANGER); + return; + } + remoteModeConnectionText.setText(detail + " · " + BuildConfig.ORCHESTRATOR_BASE_URL); + remoteModeConnectionText.setTextColor(CockpitTheme.COLOR_SUCCESS); + renderRemoteModes(modes); + })); + } + + private void renderRemoteModes(List modes) { + remoteModeList.removeAllViews(); + remoteModeButtons.clear(); + for (ModeApiClient.Mode mode : modes) { + LinearLayout row = CockpitViews.horizontal(this); + LinearLayout copy = CockpitViews.vertical(this); + TextView title = CockpitViews.text(this, mode.name, 15f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + TextView description = multiLineText( + mode.description + "\n联动域:" + remoteDomainNames(mode.domains), + 11.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + description.setMaxLines(3); + copy.addView(title, matchWidthWrap()); + copy.addView(description, matchWidthWrap()); + row.addView(copy, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 1f)); + TextView button = actionButton("执行", false, + view -> executeRemoteMode(mode)); + row.addView(button, marginParams(dp(116f), ViewGroup.LayoutParams.MATCH_PARENT, + 0f, 12f, 0f, 0f, 0f)); + remoteModeButtons.put(mode.id, button); + row.setPadding(dp(14f), dp(10f), dp(12f), dp(10f)); + row.setBackground(CockpitDrawables.outlinedRect( + this, + CockpitTheme.COLOR_SURFACE, + CockpitTheme.COLOR_STROKE, + 1f, + CockpitTheme.CORNER_SMALL_DP)); + remoteModeList.addView(row, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(92f), 0f, + 0f, 0f, 0f, 8f)); + } + updateRemoteModeButtons(); + } + + private void executeRemoteMode(ModeApiClient.Mode mode) { + if (modeApiClient == null || !activeRemoteModeId.isEmpty()) { + showFeedback(FeedbackKind.WARNING, "已有功能模式正在执行"); + return; + } + activeRemoteModeId = mode.id; + updateRemoteModeButtons(); + remoteModeStatusText.setText("正在提交" + mode.name + "\n目标:" + + BuildConfig.ORCHESTRATOR_EXECUTION_TARGET); + showFeedback(FeedbackKind.WORKING, "正在远程执行 · " + mode.name); + modeApiClient.executeMode( + mode.id, + BuildConfig.ORCHESTRATOR_EXECUTION_TARGET, + (success, execution, detail) -> mainHandler.post(() -> { + if (destroyed) return; + if (!success || execution == null) { + activeRemoteModeId = ""; + updateRemoteModeButtons(); + remoteModeStatusText.setText(mode.name + "提交失败\n" + detail); + showFeedback(FeedbackKind.ERROR, "模式提交失败 · " + detail); + return; + } + applyRemoteModeExecution(execution); + })); + } + + private void applyRemoteModeExecution(ModeApiClient.Execution execution) { + if (remoteModeStatusText == null) return; + if (!execution.isTerminal()) activeRemoteModeId = execution.modeId; + String source = "android".equals(execution.source) ? "N7Cockpit" : "Web 控制台"; + remoteModeStatusText.setText( + execution.message + "\n" + + "进度:" + execution.progress + "% · 来源:" + source + "\n" + + "任务:" + execution.executionId + + (execution.vehicleStateSummary.isEmpty() + ? "" : "\n车端状态:" + execution.vehicleStateSummary)); + if (execution.isTerminal()) { + activeRemoteModeId = ""; + boolean success = "succeeded".equals(execution.status) + || "partially_succeeded".equals(execution.status); + showFeedback(success ? FeedbackKind.SUCCESS : FeedbackKind.ERROR, execution.message); + showResultMascot(success); + if (execution.vehicleStateSummary.isEmpty() + && !execution.executionId.equals(renderedRemoteExecutionId)) { + renderedRemoteExecutionId = execution.executionId; + modeApiClient.loadExecution(execution.executionId, + (loaded, detail, _message) -> mainHandler.post(() -> { + if (loaded && detail != null && !destroyed) { + applyRemoteModeExecution(detail); + } + })); + } + } + updateRemoteModeButtons(); + } + + private void updateRemoteModeButtons() { + for (Map.Entry entry : remoteModeButtons.entrySet()) { + boolean running = entry.getKey().equals(activeRemoteModeId); + entry.getValue().setEnabled(activeRemoteModeId.isEmpty()); + entry.getValue().setAlpha(activeRemoteModeId.isEmpty() || running ? 1f : 0.48f); + entry.getValue().setText(running ? "执行中" : "执行"); + setSelectedAppearance(entry.getValue(), running); + } + } + + private void requestRemoteHardwareAccess() { + if (modeApiClient == null) return; + remoteModeStatusText.setText("正在向服务器 A 申请真实硬件控制权"); + modeApiClient.requestHardwareAccess((success, access, detail) -> mainHandler.post(() -> { + if (!success || access == null) { + remoteModeStatusText.setText("硬件控制申请失败\n" + detail); + return; + } + remoteModeStatusText.setText("真实硬件权限:" + access.state + "\n" + + "请在服务器 A 本机审批终端 " + + BuildConfig.ORCHESTRATOR_HARDWARE_CLIENT_ID); + })); + } + + private void loadRemoteHardwareAccess() { + if (modeApiClient == null) return; + modeApiClient.loadHardwareAccess((success, access, detail) -> mainHandler.post(() -> { + if (success && access != null && remoteModeConnectionText != null) { + remoteModeConnectionText.setText("编排服务已连接 · 硬件权限 " + access.state); + } + })); + } + + private static String remoteDomainNames(List domains) { + if (domains == null || domains.isEmpty()) return "未标注"; + List names = new ArrayList<>(); + for (String domain : domains) { + switch (domain) { + case "seat": names.add("座椅"); break; + case "climate": names.add("空调"); break; + case "roof": names.add("天幕"); break; + case "ambient_light": names.add("氛围灯"); break; + default: names.add(domain); break; + } + } + return String.join("、", names); + } + + private FrameLayout buildControlsPage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageColumn(); + + LinearLayout windowArea = CockpitViews.horizontal(this); + LinearLayout windowCard = card("四窗同步与实时状态", "移动部件操作均需二次确认"); + LinearLayout allPresets = CockpitViews.horizontal(this); + allPresets.addView(movingButton("全部关闭", VehicleCommands.allWindows(0), + MascotActionBus.Action.WINDOW), equalButtonParams(0f, 5f)); + allPresets.addView(movingButton("通风 10%", VehicleCommands.allWindows(10), + MascotActionBus.Action.WINDOW), equalButtonParams(5f, 5f)); + allPresets.addView(movingButton("半开 50%", VehicleCommands.allWindows(50), + MascotActionBus.Action.WINDOW), equalButtonParams(5f, 5f)); + allPresets.addView(movingButton("全部打开", VehicleCommands.allWindows(100), + MascotActionBus.Action.WINDOW), equalButtonParams(5f, 0f)); + windowCard.addView(allPresets, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f))); + windowControl = new WindowControlView(this); + // The visual component contains 10/50% per-window presets, but the real helper only + // allowlists 0/100 for an individual window. Keep it read-only and expose exact 0/100 + // controls in the adjacent panel so a partial request can never be widened to full-open. + windowControl.setControlsLocked(true); + windowCard.addView(windowControl, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 8f, 0f, 0f)); + windowArea.addView(windowCard, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 2.15f, 0f, 0f, 6f, 0f)); + + LinearLayout individual = card("四窗独立开关", "关闭 / 全开"); + addIndividualWindowRow(individual, "左前窗", VehicleCommands.Window.FRONT_LEFT); + addIndividualWindowRow(individual, "右前窗", VehicleCommands.Window.FRONT_RIGHT); + addIndividualWindowRow(individual, "左后窗", VehicleCommands.Window.REAR_LEFT); + addIndividualWindowRow(individual, "右后窗", VehicleCommands.Window.REAR_RIGHT); + windowArea.addView(individual, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 6f, 0f, 0f, 0f)); + content.addView(windowArea, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.10f)); + + LinearLayout lower = CockpitViews.horizontal(this); + LinearLayout lights = card("灯光控制", "不修改近光灯自动逻辑 · 无回读项显示已发送态"); + interiorLightOnButton = vehicleButton("车内灯 开", false, + VehicleCommands.light(VehicleCommands.Light.INTERIOR, true), + MascotActionBus.Action.LIGHT); + interiorLightOffButton = vehicleButton("车内灯 关", false, + VehicleCommands.light(VehicleCommands.Light.INTERIOR, false), + MascotActionBus.Action.LIGHT); + lights.addView(buttonPair(interiorLightOnButton, interiorLightOffButton), matchWidthWrap()); + ambientLightOnButton = vehicleButton("氛围灯 开", false, + VehicleCommands.light(VehicleCommands.Light.AMBIENT, true), + MascotActionBus.Action.LIGHT); + ambientLightOffButton = vehicleButton("氛围灯 关", false, + VehicleCommands.light(VehicleCommands.Light.AMBIENT, false), + MascotActionBus.Action.LIGHT); + lights.addView(buttonPair(ambientLightOnButton, ambientLightOffButton), + marginParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 5f, 0f, 0f)); + drlOnButton = vehicleButton("日行灯 开", false, + VehicleCommands.light(VehicleCommands.Light.DRL, true), + MascotActionBus.Action.LIGHT); + drlOffButton = actionButton("日行灯 关", false, view -> confirmDrlOff()); + registerVehicleControl(drlOffButton); + lights.addView(buttonPair(drlOnButton, drlOffButton), + marginParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 5f, 0f, 0f)); + lower.addView(lights, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1.2f, 0f, 10f, 6f, 0f)); + + LinearLayout sunshade = card("电动遮阳帘", "本车无可开启天窗 · 无独立位置回读"); + sunshadeOpenButton = movingButton("打开遮阳帘", VehicleCommands.sunshade(true), + MascotActionBus.Action.SUNSHADE); + sunshadeCloseButton = movingButton("关闭遮阳帘", VehicleCommands.sunshade(false), + MascotActionBus.Action.SUNSHADE); + sunshade.addView(sunshadeOpenButton, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(56f))); + sunshade.addView(sunshadeCloseButton, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 10f, 0f, 0f)); + TextView sunshadeSafety = multiLineText( + "执行前确认附近无人、无障碍物,车辆处于安全状态。", + 12f, + CockpitTheme.COLOR_WARNING, + CockpitTheme.TYPEFACE_MEDIUM); + sunshade.addView(sunshadeSafety, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 10f, 0f, 0f)); + lower.addView(sunshade, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 0.92f, 6f, 10f, 6f, 0f)); + + LinearLayout vehicleState = card( + "3D 实时状态", "拖动旋转 · 门窗、尾门、灯光与遮阳帘同步"); + controlVehicle = new VehicleSilhouetteView(this); + controlVehicle.setOnVehicleZoneClickListener(door -> showFeedback( + FeedbackKind.INFO, doorName(door) + "状态来自车辆回读")); + vehicleState.addView(controlVehicle, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + lower.addView(vehicleState, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 0.82f, 6f, 10f, 0f, 0f)); + content.addView(lower, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + page.addView(content, fillFrame()); + return page; + } + + private FrameLayout buildDevicePage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageRow(); + + LinearLayout sensors = card("车辆状态与传感器", "只读 · 5 秒自动刷新 · 未适配项显示未知"); + vehicleSensorText = multiLineText("正在读取车辆状态…", 13.5f, + CockpitTheme.COLOR_TEXT_PRIMARY, Typeface.create("sans-serif", Typeface.NORMAL)); + vehicleSensorText.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.COLOR_BACKGROUND_DEEP, 14f)); + vehicleSensorText.setPadding(dp(14f), dp(12f), dp(14f), dp(12f)); + sensors.addView(vehicleSensorText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + TextView refreshVehicle = actionButton("立即刷新车辆状态", false, + view -> refreshSnapshot(true)); + sensors.addView(refreshVehicle, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f), 0f, + 0f, 10f, 0f, 0f)); + content.addView(sensors, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1.08f, 0f, 0f, 7f, 0f)); + + LinearLayout right = CockpitViews.vertical(this); + LinearLayout usb = card("USB-CAN 设备诊断", "USBCANFD-200U · CAN0 500K/2M · 内部终端关闭"); + usbStatusText = multiLineText("CAN 网关准备中…", 12.5f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_REGULAR); + usbStatusText.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.COLOR_BACKGROUND_DEEP, 14f)); + usbStatusText.setPadding(dp(12f), dp(10f), dp(12f), dp(10f)); + usbStatusText.setMaxLines(8); + usb.addView(usbStatusText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + LinearLayout usbActions = CockpitViews.horizontal(this); + TextView refreshUsb = actionButton("刷新设备", false, view -> { + beginDeviceOperation(); + showFeedback(FeedbackKind.WORKING, "正在刷新 USB-CAN 设备状态"); + usbCanGateway.refresh(); + }); + TextView testUsb = actionButton("授权并自检", true, view -> { + beginDeviceOperation(); + showFeedback(FeedbackKind.WORKING, + usbCanGateway.isSimulation() ? "正在执行虚拟 CAN 自检" : "正在请求 USB 授权并执行自检"); + usbCanGateway.selfTest(); + }); + usbActions.addView(refreshUsb, equalButtonParams(0f, 5f)); + usbActions.addView(testUsb, equalButtonParams(5f, 0f)); + usb.addView(usbActions, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f), 0f, + 0f, 9f, 0f, 0f)); + LinearLayout canActions = CockpitViews.horizontal(this); + TextView canOn = actionButton("CAN 空调开", false, view -> sendCanAc(true)); + TextView canOff = actionButton("CAN 空调关", false, view -> sendCanAc(false)); + canActions.addView(canOn, equalButtonParams(0f, 5f)); + canActions.addView(canOff, equalButtonParams(5f, 0f)); + usb.addView(canActions, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f), 0f, + 0f, 9f, 0f, 0f)); + right.addView(usb, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.28f)); + + LinearLayout gateway = card("车辆网关", "认证 helper 与运行环境"); + gatewayEnvironmentText = multiLineText("正在初始化…", 12.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_REGULAR); + gatewayEnvironmentText.setMaxLines(5); + gateway.addView(gatewayEnvironmentText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + TextView reconnect = actionButton("重新连接车辆服务", false, + view -> prepareGateway()); + gateway.addView(reconnect, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52f), 0f, + 0f, 8f, 0f, 0f)); + right.addView(gateway, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.72f, + 0f, 12f, 0f, 0f)); + content.addView(right, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 7f, 0f, 0f, 0f)); + page.addView(content, fillFrame()); + return page; + } + + private FrameLayout buildSettingsPage() { + FrameLayout page = pageFrame(); + LinearLayout content = pageRow(); + + LinearLayout overlay = card("桌宠悬浮", "跨应用显示 · 可拖动、贴边、双击与长按缩放"); + LinearLayout overlayState = CockpitViews.horizontal(this); + overlayPermissionText = pill("悬浮权限未授权", CockpitTheme.COLOR_WARNING); + overlayRunText = pill("服务未运行", CockpitTheme.COLOR_TEXT_SECONDARY); + overlayState.addView(overlayPermissionText, new LinearLayout.LayoutParams(0, dp(32f), 1f)); + LinearLayout.LayoutParams runningParams = new LinearLayout.LayoutParams(0, dp(32f), 1f); + runningParams.leftMargin = dp(10f); + overlayState.addView(overlayRunText, runningParams); + overlay.addView(overlayState, matchWidthWrap()); + + LinearLayout overlayActions = CockpitViews.horizontal(this); + TextView permission = actionButton("授权悬浮窗", false, view -> requestOverlayPermission()); + TextView start = actionButton("启动桌宠", true, view -> startMascotOverlay()); + TextView stop = actionButton("停止桌宠", false, view -> { + MascotOverlayService.stop(this); + showFeedback(FeedbackKind.INFO, "已请求停止桌宠悬浮服务"); + mainHandler.postDelayed(this::updateOverlayState, 250L); + }); + overlayActions.addView(permission, equalButtonParams(0f, 5f)); + overlayActions.addView(start, equalButtonParams(5f, 5f)); + overlayActions.addView(stop, equalButtonParams(5f, 0f)); + overlay.addView(overlayActions, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 16f, 0f, 0f)); + + LinearLayout sizeHeader = CockpitViews.horizontal(this); + TextView sizeTitle = CockpitViews.body(this, "桌宠大小"); + mascotSizeText = CockpitViews.text(this, + MascotActionBus.DEFAULT_SIZE_DP + " dp", + 16f, + CockpitTheme.COLOR_PRIMARY, + CockpitTheme.TYPEFACE_MEDIUM); + mascotSizeText.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + sizeHeader.addView(sizeTitle, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + sizeHeader.addView(mascotSizeText, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + overlay.addView(sizeHeader, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 20f, 0f, 0f)); + mascotSizeSeek = new SeekBar(this); + mascotSizeSeek.setMin(MascotActionBus.MIN_SIZE_DP); + mascotSizeSeek.setMax(MascotActionBus.MAX_SIZE_DP); + mascotSizeSeek.setProgress(MascotActionBus.getSizeDp()); + mascotSizeSeek.setContentDescription("桌宠大小,范围 " + + MascotActionBus.MIN_SIZE_DP + " 到 " + + MascotActionBus.MAX_SIZE_DP + " dp"); + mascotSizeSeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + int safe = Math.max(MascotActionBus.MIN_SIZE_DP, + Math.min(MascotActionBus.MAX_SIZE_DP, progress)); + mascotSizeText.setText(safe + " dp"); + if (fromUser) { + if (inAppMascotLayer != null) inAppMascotLayer.setSizeDp(safe); + else MascotActionBus.setSizeDp(safe); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + MascotActionBus.perform(MascotActionBus.Action.SUCCESS); + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + int safe = MascotActionBus.getSizeDp(); + mascotSizeText.setText(safe + " dp"); + showFeedback(FeedbackKind.SUCCESS, "桌宠大小已调整为 " + safe + " dp"); + restoreMascotActionLater(MascotActionBus.Action.SUCCESS); + } + }); + overlay.addView(mascotSizeSeek, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(54f))); + LinearLayout scale = CockpitViews.horizontal(this); + TextView min = CockpitViews.label(this, + MascotActionBus.MIN_SIZE_DP + " dp · 小巧"); + TextView max = CockpitViews.label(this, + MascotActionBus.MAX_SIZE_DP + " dp · 醒目"); + max.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + scale.addView(min, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + scale.addView(max, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + overlay.addView(scale, matchWidthWrap()); + TextView overlayHint = multiLineText( + "悬浮后可直接拖动;松手会进入当前页面的安全停靠位,避开车控按钮。各页面分别保存位置。双击切换动作,双指可缩放与旋转。", + 13f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + overlayHint.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.06f), 14f)); + overlayHint.setPadding(dp(12f), dp(10f), dp(12f), dp(10f)); + overlay.addView(overlayHint, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 14f, 0f, 0f)); + content.addView(overlay, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1.14f, 0f, 0f, 7f, 0f)); + + LinearLayout right = CockpitViews.vertical(this); + LinearLayout actions = card("动作演示", "各功能拥有独立动作反馈"); + addActionDemoRow(actions, + demoButton("空调呼吸", MascotActionBus.Action.CLIMATE), + demoButton("车窗探头", MascotActionBus.Action.WINDOW), + demoButton("灯光摇摆", MascotActionBus.Action.LIGHT)); + addActionDemoRow(actions, + demoButton("遮阳帘伸展", MascotActionBus.Action.SUNSHADE), + demoButton("设备巡检", MascotActionBus.Action.DEVICE), + demoButton("音乐律动", MascotActionBus.Action.MUSIC)); + addActionDemoRow(actions, + demoButton("成功", MascotActionBus.Action.SUCCESS), + demoButton("错误", MascotActionBus.Action.ERROR), + demoButton("待机", MascotActionBus.Action.IDLE)); + right.addView(actions, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.06f)); + + LinearLayout safety = card("安全与兼容", "当前版本边界"); + TextView safetyCopy = multiLineText( + "模拟环境会显著标识,Mock 结果不会被描述为实车验证。\n" + + "真机控制继续使用固定 FID / 命令白名单;不开放任意 CAN ID、数据或 shell。\n" + + "车辆服务与 USB-CAN 能力需在目标 N7 车机上完成硬件验收。", + 12.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, + CockpitTheme.TYPEFACE_REGULAR); + safety.addView(safetyCopy, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + right.addView(safety, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 0.94f, + 0f, 12f, 0f, 0f)); + content.addView(right, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 7f, 0f, 0f, 0f)); + page.addView(content, fillFrame()); + return page; + } + + private void prepareGateway() { + if (destroyed || vehicleGateway == null) return; + gatewayReady = false; + updateVehicleControlAvailability(); + showFeedback(FeedbackKind.WORKING, + vehicleGateway.isSimulation() ? "正在准备模拟车辆网关" : "正在连接车辆控制 helper"); + vehicleGateway.prepare((success, detail) -> mainHandler.post(() -> { + if (destroyed) return; + gatewayReady = success; + updateVehicleControlAvailability(); + gatewayEnvironmentText.setText(environmentDescription() + "\n\n" + safeDetail(detail)); + if (success) { + showFeedback(FeedbackKind.SUCCESS, safeDetail(detail)); + refreshSnapshot(false); + } else { + showFeedback(FeedbackKind.ERROR, "车辆网关未就绪 · " + safeDetail(detail)); + showResultMascot(false); + } + })); + } + + private void refreshSnapshot(boolean announce) { + if (destroyed || snapshotPending || vehicleGateway == null) return; + snapshotPending = true; + if (announce) showFeedback(FeedbackKind.WORKING, "正在刷新车辆与空调状态"); + vehicleGateway.readSnapshot((success, snapshot, detail) -> mainHandler.post(() -> { + if (destroyed) return; + snapshotPending = false; + if (success && snapshot != null) { + if (!gatewayReady) { + gatewayReady = true; + updateVehicleControlAvailability(); + } + applySnapshot(snapshot); + if (announce) showFeedback(FeedbackKind.SUCCESS, safeDetail(detail)); + } else if (announce || latestSnapshot == null) { + showFeedback(FeedbackKind.ERROR, "状态刷新失败 · " + safeDetail(detail)); + } + })); + } + + private void sendVehicleCommand( + String label, String command, MascotActionBus.Action action) { + if (commandPending) { + showFeedback(FeedbackKind.WARNING, "上一项车辆操作尚未完成"); + return; + } + if (!gatewayReady) { + showFeedback(FeedbackKind.ERROR, "车辆网关未就绪,请在设备页重新连接"); + showResultMascot(false); + return; + } + commandPending = true; + updateVehicleControlAvailability(); + MascotActionBus.perform(action); + long mascotActionStartedMs = SystemClock.uptimeMillis(); + showFeedback(FeedbackKind.WORKING, "正在执行 · " + label); + vehicleGateway.sendCommand(command, (success, detail) -> mainHandler.post(() -> { + if (destroyed) return; + commandPending = false; + updateVehicleControlAvailability(); + String readbackNote = success && lacksIndependentReadback(command) + ? " · 该功能无独立车辆回读" : ""; + showFeedback(success ? FeedbackKind.SUCCESS : FeedbackKind.ERROR, + (success ? "命令已接受 · " : "未执行 · ") + + label + " · " + safeDetail(detail) + readbackNote); + showResultMascotAfterOperation(action, success, mascotActionStartedMs); + if (success) { + rememberCommandState(command); + if (latestSnapshot != null) applyToggleStates(latestSnapshot); + mainHandler.postDelayed(() -> refreshSnapshot(false), 320L); + } + else if (latestSnapshot != null) applySnapshot(latestSnapshot); + })); + } + + private void applySnapshot(VehicleSnapshot snapshot) { + latestSnapshot = snapshot; + statusBar.setSimulationMode(snapshot.isSimulation()); + float soc = snapshot.socPercent(); + if (Float.isFinite(soc)) statusBar.setBatteryPercent(Math.round(soc)); + // The mascot reads vehicle context directly: cabin temperature picks the shivering or + // fanning climate performance, and speed drives the forward-leaning travel pose. + MascotActionBus.publishContext(snapshot.speedKph(), snapshot.targetTemperatureC()); + + homeSpeed.setText(formatNumber(snapshot.speedKph(), 0)); + homeSoc.setText(formatNumber(soc, 0) + "%"); + homeMileage.setText(formatNumber(snapshot.mileageKm(), 1) + " km"); + homePower.setText(formatNumber(snapshot.powerKw(), 1) + " kW"); + homeGear.setText(gearName(snapshot.gearCode())); + String action = snapshot.lastAction().isEmpty() ? "车辆状态已同步" : snapshot.lastAction(); + homeLastAction.setText(action + " · " + shortTime.format(new Date(snapshot.capturedAtMillis()))); + homeEnvironment.setText(snapshot.source()); + + applyVehicleVisual(homeVehicle, snapshot); + applyVehicleVisual(controlVehicle, snapshot); + applyClimateDial(homeClimateDial, snapshot); + applyClimateDial(climateDial, snapshot); + applyWindowState(snapshot); + applyToggleStates(snapshot); + applySensorText(snapshot); + + boolean climateOn = snapshot.climatePowerOn(); + float target = snapshot.targetTemperatureC(); + climateSummary.setText((climateOn ? "空调运行中" : "空调已关闭") + + " · 目标 " + formatTemperature(target) + + " · " + (isAutoClimate(snapshot) ? "自动" : "手动")); + cabinTemperature.setText(formatTemperature(snapshot.climateFloat("inside", Float.NaN))); + outsideTemperature.setText(formatTemperature(snapshot.climateFloat("outside", Float.NaN))); + int fan = snapshot.fanLevel(); + fanReadout.setText(fan < 0 ? "未知" : fan + " 档"); + gatewayEnvironmentText.setText(environmentDescription() + + "\n最近同步:" + shortTime.format(new Date(snapshot.capturedAtMillis()))); + } + + private void applyVehicleVisual(VehicleSilhouetteView view, VehicleSnapshot snapshot) { + if (BuildConfig.DEBUG && applyDebug3dShowcase(view, debug3dShowcase)) return; + view.setDoorOpen(VehicleSilhouetteView.Door.FRONT_LEFT, + snapshot.vehicleInt("dfl", 0) != 0); + view.setDoorOpen(VehicleSilhouetteView.Door.FRONT_RIGHT, + snapshot.vehicleInt("dfr", 0) != 0); + view.setDoorOpen(VehicleSilhouetteView.Door.REAR_LEFT, + snapshot.vehicleInt("drl", 0) != 0); + view.setDoorOpen(VehicleSilhouetteView.Door.REAR_RIGHT, + snapshot.vehicleInt("drr", 0) != 0); + view.setDoorOpen(VehicleSilhouetteView.Door.TRUNK, + snapshot.vehicleInt("trunk", 0) != 0); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.FRONT_LEFT, + snapshot.vehicleInt("wfl", 0)); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.FRONT_RIGHT, + snapshot.vehicleInt("wfr", 0)); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.REAR_LEFT, + snapshot.vehicleInt("wrl", 0)); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.REAR_RIGHT, + snapshot.vehicleInt("wrr", 0)); + boolean ambient = snapshot.vehicle().containsKey("ambient") + ? snapshot.vehicleFlag("ambient", false) + : rememberedAmbientLight != null ? rememberedAmbientLight : view.isAmbientLightOn(); + view.setLights(snapshot.vehicleFlag("low", false), false, ambient); + + int fanLevel = Math.max(0, snapshot.fanLevel()); + float targetTemperature = snapshot.targetTemperatureC(); + if (!Float.isFinite(targetTemperature)) targetTemperature = 24f; + view.setClimateState(snapshot.climatePowerOn(), targetTemperature, fanLevel); + + int sunshadePercent; + if (snapshot.vehicle().containsKey("sunshade")) { + sunshadePercent = snapshot.vehicleInt("sunshade", 0); + } else { + sunshadePercent = Boolean.TRUE.equals(rememberedSunshadeOpen) ? 100 : 0; + } + view.setSunshadePercent(sunshadePercent); + } + + /** + * Applies deterministic states for emulator screenshots without changing the Mock gateway. + * Release builds compile the call site but can never enter it because {@link BuildConfig#DEBUG} + * is false, so production vehicle state always comes from the typed snapshot. + */ + private static boolean applyDebug3dShowcase( + VehicleSilhouetteView view, String requestedMode) { + if (view == null || requestedMode == null || requestedMode.isEmpty()) return false; + + boolean allOpen = "all_open".equals(requestedMode) || "all".equals(requestedMode); + boolean leftOpen = allOpen || "left_open".equals(requestedMode); + boolean rightOpen = allOpen || "right_open".equals(requestedMode); + boolean closed = "closed".equals(requestedMode); + boolean climateCold = allOpen || "climate_cold".equals(requestedMode); + boolean climateWarm = "climate_warm".equals(requestedMode); + if (!allOpen && !leftOpen && !rightOpen && !closed + && !climateCold && !climateWarm) return false; + + view.setDoorOpen(VehicleSilhouetteView.Door.FRONT_LEFT, leftOpen); + view.setDoorOpen(VehicleSilhouetteView.Door.REAR_LEFT, leftOpen); + view.setDoorOpen(VehicleSilhouetteView.Door.FRONT_RIGHT, rightOpen); + view.setDoorOpen(VehicleSilhouetteView.Door.REAR_RIGHT, rightOpen); + view.setDoorOpen(VehicleSilhouetteView.Door.TRUNK, allOpen); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.FRONT_LEFT, + leftOpen ? 78 : 0); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.REAR_LEFT, + leftOpen ? 48 : 0); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.FRONT_RIGHT, + rightOpen ? 64 : 0); + view.setWindowPercent(VehicleSilhouetteView.WindowPosition.REAR_RIGHT, + rightOpen ? 32 : 0); + view.setLights(!closed, allOpen, !closed); + view.setSunshadePercent(allOpen ? 72 : 0); + view.setClimateState(climateCold || climateWarm, + climateWarm ? 29f : 18f, climateCold || climateWarm ? 6 : 0); + view.setUserRotation(leftOpen ? -23f : rightOpen ? 23f : -12f); + return true; + } + + private void applyClimateDial(ClimateDialView dial, VehicleSnapshot snapshot) { + if (dial == null) return; + float temperature = snapshot.targetTemperatureC(); + if (Float.isFinite(temperature)) dial.setTemperature(temperature); + int fan = snapshot.fanLevel(); + if (fan >= 0) { + lastFanLevel = fan; + dial.setFanLevel(fan); + } + dial.setPowerOn(snapshot.climatePowerOn()); + dial.setAutoMode(isAutoClimate(snapshot)); + dial.setRecirculationOn(snapshot.climateFlag("cycle", false)); + } + + private void applyWindowState(VehicleSnapshot snapshot) { + if (windowControl == null) return; + windowControl.setWindowPercent(WindowControlView.Position.FRONT_LEFT, + snapshot.vehicleInt("wfl", 0)); + windowControl.setWindowPercent(WindowControlView.Position.FRONT_RIGHT, + snapshot.vehicleInt("wfr", 0)); + windowControl.setWindowPercent(WindowControlView.Position.REAR_LEFT, + snapshot.vehicleInt("wrl", 0)); + windowControl.setWindowPercent(WindowControlView.Position.REAR_RIGHT, + snapshot.vehicleInt("wrr", 0)); + } + + private void applyToggleStates(VehicleSnapshot snapshot) { + boolean power = snapshot.climatePowerOn(); + boolean auto = isAutoClimate(snapshot); + boolean inner = snapshot.climateFlag("cycle", false); + boolean front = snapshot.climateFlag("front", false); + rememberedRearDefrost = optionalFlag( + snapshot.climate().containsKey("rear"), + snapshot.climateFlag("rear", false), + rememberedRearDefrost); + rememberedInteriorLight = optionalFlag( + snapshot.vehicle().containsKey("interior"), + snapshot.vehicleFlag("interior", false), + rememberedInteriorLight); + rememberedAmbientLight = optionalFlag( + snapshot.vehicle().containsKey("ambient"), + snapshot.vehicleFlag("ambient", false), + rememberedAmbientLight); + rememberedSunshadeOpen = optionalFlag( + snapshot.vehicle().containsKey("sunshade"), + snapshot.vehicleInt("sunshade", 0) > 0, + rememberedSunshadeOpen); + setPairState(climatePowerOnButton, climatePowerOffButton, power); + setPairState(climateAutoButton, climateManualButton, auto); + setPairState(climateInnerButton, climateOuterButton, inner); + setPairState(frontDefrostOnButton, frontDefrostOffButton, front); + setOptionalPairState(rearDefrostOnButton, rearDefrostOffButton, rememberedRearDefrost); + setOptionalPairState(interiorLightOnButton, interiorLightOffButton, rememberedInteriorLight); + setOptionalPairState(ambientLightOnButton, ambientLightOffButton, rememberedAmbientLight); + setPairState(drlOnButton, drlOffButton, + snapshot.vehicleInt("drlight", 2) == 1); + setOptionalPairState(sunshadeOpenButton, sunshadeCloseButton, rememberedSunshadeOpen); + } + + private void applySensorText(VehicleSnapshot snapshot) { + String unknown = "未知"; + String text = "动力系统\n" + + "车速 " + formatNumber(snapshot.speedKph(), 1) + " km/h" + + " 电量 " + formatNumber(snapshot.socPercent(), 1) + "%" + + " 功率 " + formatNumber(snapshot.powerKw(), 1) + " kW\n" + + "里程 " + formatNumber(snapshot.mileageKm(), 1) + " km" + + " 12V " + field(snapshot, "v12", unknown) + " V" + + " 挡位 " + gearName(snapshot.gearCode()) + "\n\n" + + "轮胎压力(kPa)\n" + + "左前 " + field(snapshot, "tfl", unknown) + + " 右前 " + field(snapshot, "tfr", unknown) + + " 左后 " + field(snapshot, "trl", unknown) + + " 右后 " + field(snapshot, "trr", unknown) + "\n\n" + + "车身与安全\n" + + "四门 " + doorsText(snapshot) + + " 尾门 " + openText(snapshot.vehicleInt("trunk", 0)) + + " 机舱盖 " + openText(snapshot.vehicleInt("hood", 0)) + "\n" + + "门锁 " + field(snapshot, "lock", unknown) + + " 充电枪 " + field(snapshot, "gun", unknown) + + " BMS " + field(snapshot, "bms", unknown) + "\n" + + "雨刷 " + field(snapshot, "wiper", unknown) + + " 自动雨刷 " + field(snapshot, "autowiper", unknown) + + " 环境光级 " + field(snapshot, "lightlevel", unknown) + "\n\n" + + "电池温度 最高 " + field(snapshot, "batmax", unknown) + + "℃ / 最低 " + field(snapshot, "batmin", unknown) + "℃" + + " CAN " + field(snapshot, "can", unknown); + vehicleSensorText.setText(text); + } + + private void wireClimateDial(ClimateDialView dial) { + registerVehicleControl(dial); + dial.setTemperatureRange(16f, 30f, 1f); + dial.setOnClimateChangeListener(new ClimateDialView.OnClimateChangeListener() { + @Override + public void onTemperatureRequested(float celsius) { + int target = Math.round(celsius); + sendVehicleCommand("温度设为 " + target + "℃", + VehicleCommands.climateTemperature(target), + MascotActionBus.Action.CLIMATE); + } + + @Override + public void onFanLevelRequested(int level) { + showFeedback(FeedbackKind.WARNING, + "风量档位仅支持状态回读,未发送未列入白名单的命令"); + // ClimateDialView changes its local drawing before notifying. Restore the last + // vehicle readback immediately so a tap cannot look like a successful control. + dial.setFanLevel(lastFanLevel); + MascotActionBus.perform(MascotActionBus.Action.CLIMATE); + restoreMascotActionLater(MascotActionBus.Action.CLIMATE, + MascotActionTiming.minimumOperationMs(MascotActionBus.Action.CLIMATE)); + } + + @Override + public void onPowerRequested(boolean powerOn) { + sendVehicleCommand(powerOn ? "开启空调" : "关闭空调", + VehicleCommands.climatePower(powerOn), + MascotActionBus.Action.CLIMATE); + } + + @Override + public void onAutoModeRequested(boolean autoMode) { + sendVehicleCommand(autoMode ? "切换自动模式" : "切换手动模式", + VehicleCommands.climateAuto(autoMode), + MascotActionBus.Action.CLIMATE); + } + + @Override + public void onRecirculationRequested(boolean recirculationOn) { + sendVehicleCommand(recirculationOn ? "切换内循环" : "切换外循环", + VehicleCommands.climateInnerCycle(recirculationOn), + MascotActionBus.Action.CLIMATE); + } + }); + } + + private void toggleMusic() { + if (MascotMusicService.isActive()) { + if (MascotMusicService.requestStop(this)) { + showFeedback(FeedbackKind.INFO, "正在停止测试音乐"); + MascotActionBus.perform(pageAction(currentPage)); + } else { + showFeedback(FeedbackKind.ERROR, "无法向测试音乐服务发送停止指令"); + } + } else if (MascotMusicService.requestPlay(this)) { + showFeedback(FeedbackKind.WORKING, "正在启动测试音乐与实时 PCM 频谱…"); + } else { + showFeedback(FeedbackKind.ERROR, "Android 拒绝启动测试音乐前台服务"); + showResultMascot(false); + } + statusBar.setVolumeEnabled(MascotMusicService.isPlaying()); + updateMusicButton(); + } + + private void updateMusicButton() { + if (musicButton == null) return; + boolean active = MascotMusicService.isActive(); + musicButton.setText(active ? "停止测试音乐" : "播放测试音乐"); + musicButton.setBackground(active + ? CockpitDrawables.selectedButton(this) + : CockpitDrawables.actionButton(this)); + } + + private void confirmMovingPart( + String label, String command, MascotActionBus.Action action) { + showSafetyDialog( + "确认移动部件动作", + label + "。请确认车辆处于安全状态,车窗或遮阳帘附近无人且无障碍物。", + "确认执行", + () -> sendVehicleCommand(label, command, action), + () -> { + if (latestSnapshot != null) applySnapshot(latestSnapshot); + showFeedback(FeedbackKind.INFO, "已取消 · " + label); + }); + } + + private void confirmDrlOff() { + showSafetyDialog( + "确认关闭日行灯", + "关闭日行灯可能降低车辆可见性。请确认当前环境、当地法规和车辆状态允许此操作。", + "确认关闭", + () -> sendVehicleCommand("关闭日行灯", + VehicleCommands.light(VehicleCommands.Light.DRL, false), + MascotActionBus.Action.LIGHT), + () -> showFeedback(FeedbackKind.INFO, "已保留日行灯开启状态")); + } + + private void sendCanAc(boolean enabled) { + Runnable send = () -> { + beginDeviceOperation(); + showFeedback(FeedbackKind.WORKING, + (usbCanGateway.isSimulation() ? "正在模拟" : "正在通过 USB-CAN 实际发送") + + "空调" + (enabled ? "开" : "关") + "固定帧"); + usbCanGateway.sendAc(enabled); + }; + if (usbCanGateway.isSimulation()) { + send.run(); + return; + } + showSafetyDialog( + "确认实际发送 CAN", + "请确认车辆已驻车并上电。将通过 USB-CAN 盒发送固定空调" + + (enabled ? "开启" : "关闭") + + "帧 3 次;不会发送其他 CAN ID 或任意数据。", + "确认发送", + send, + () -> showFeedback(FeedbackKind.INFO, "已取消实际 CAN 发送")); + } + + private void handleUsbStatus(String detail) { + if (destroyed || usbStatusText == null) return; + String safe = safeDetail(detail); + usbStatusText.setText(safe); + if (containsAny(safe, "失败", "错误", "未找到", "不可用", "拒绝")) { + showFeedback(FeedbackKind.ERROR, firstLine(safe)); + showDeviceOperationResult(false); + } else if (containsAny(safe, "通过", "成功")) { + showFeedback(FeedbackKind.SUCCESS, firstLine(safe)); + showDeviceOperationResult(true); + } else if (containsAny(safe, "正在", "请求")) { + showFeedback(FeedbackKind.WORKING, firstLine(safe)); + } + } + + private void requestOverlayPermission() { + if (MascotOverlayService.hasOverlayPermission(this)) { + showFeedback(FeedbackKind.SUCCESS, "悬浮窗权限已授权"); + updateOverlayState(); + return; + } + try { + startActivityForResult(MascotOverlayService.createPermissionIntent(this), REQUEST_OVERLAY); + showFeedback(FeedbackKind.INFO, "请在系统页面允许 N7 智舱显示在其他应用上层"); + } catch (RuntimeException error) { + showFeedback(FeedbackKind.ERROR, "无法打开悬浮窗授权页面"); + } + } + + private void startMascotOverlay() { + if (!MascotOverlayService.hasOverlayPermission(this)) { + startOverlayAfterPermission = true; + requestOverlayPermission(); + return; + } + boolean accepted = MascotOverlayService.start(this); + if (accepted) { + showFeedback(FeedbackKind.SUCCESS, "桌宠悬浮服务正在启动"); + MascotActionBus.perform(MascotActionBus.Action.SUCCESS); + restoreMascotActionLater(MascotActionBus.Action.SUCCESS); + } else { + showFeedback(FeedbackKind.ERROR, "桌宠悬浮服务启动失败"); + showResultMascot(false); + } + mainHandler.postDelayed(this::updateOverlayState, 300L); + } + + private void updateOverlayState() { + boolean permission = MascotOverlayService.hasOverlayPermission(this); + boolean running = MascotOverlayService.isRunning(); + syncInAppMascotVisibility(running); + if (overlayPermissionText == null) return; + overlayPermissionText.setText(permission ? "悬浮权限已授权" : "悬浮权限未授权"); + overlayPermissionText.setTextColor(permission + ? CockpitTheme.COLOR_SUCCESS : CockpitTheme.COLOR_WARNING); + overlayPermissionText.setBackground(CockpitDrawables.pill( + this, + CockpitTheme.withAlpha(permission + ? CockpitTheme.COLOR_SUCCESS : CockpitTheme.COLOR_WARNING, 0.09f), + CockpitTheme.withAlpha(permission + ? CockpitTheme.COLOR_SUCCESS : CockpitTheme.COLOR_WARNING, 0.65f))); + overlayRunText.setText(running ? "悬浮服务运行中" : "悬浮服务未运行"); + overlayRunText.setTextColor(running + ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_SECONDARY); + overlayRunText.setBackground(CockpitDrawables.pill( + this, + CockpitTheme.withAlpha(running + ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_SECONDARY, 0.08f), + CockpitTheme.withAlpha(running + ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_SECONDARY, 0.50f))); + int size = MascotActionBus.getSizeDp(); + mascotSizeText.setText(size + " dp"); + if (mascotSizeSeek.getProgress() != size) mascotSizeSeek.setProgress(size); + } + + private void syncInAppMascotVisibility(boolean systemOverlayRunning) { + if (inAppMascotLayer == null) return; + if (started && !systemOverlayRunning) { + inAppMascotLayer.start(); + inAppMascotLayer.restoreState(); + inAppMascotLayer.getMascotView().onResume(); + } else { + pauseInAppMascot(); + } + } + + private void pauseInAppMascot() { + if (inAppMascotLayer == null) return; + inAppMascotLayer.getMascotView().onPause(); + inAppMascotLayer.stop(); + } + + private void showSafetyDialog( + String title, + String message, + String positiveLabel, + Runnable onConfirm, + Runnable onCancel) { + if (destroyed) return; + if (safetyDialog != null) safetyDialog.dismiss(); + Dialog dialog = new Dialog(this); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + LinearLayout panel = CockpitViews.vertical(this); + panel.setPadding(dp(24f), dp(22f), dp(24f), dp(20f)); + panel.setBackground(CockpitDrawables.outlinedRect( + this, + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.withAlpha(CockpitTheme.COLOR_WARNING, 0.72f), + 1.25f, + CockpitTheme.CORNER_LARGE_DP)); + + TextView risk = pill("安全确认", CockpitTheme.COLOR_WARNING); + panel.addView(risk, new LinearLayout.LayoutParams(dp(88f), dp(30f))); + TextView titleView = CockpitViews.title(this, title); + panel.addView(titleView, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 16f, 0f, 0f)); + TextView messageView = multiLineText(message, 16f, + CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_REGULAR); + panel.addView(messageView, matchWidthWrap()); + if (vehicleGateway.isSimulation()) { + TextView simulation = multiLineText( + "当前为模拟环境:确认后只改变本地 Mock 状态,不触达车辆硬件。", + 12.5f, + CockpitTheme.COLOR_WARNING, + CockpitTheme.TYPEFACE_MEDIUM); + simulation.setBackground(CockpitDrawables.roundedRect( + this, CockpitTheme.withAlpha(CockpitTheme.COLOR_WARNING, 0.08f), 12f)); + simulation.setPadding(dp(10f), dp(8f), dp(10f), dp(8f)); + panel.addView(simulation, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 16f, 0f, 0f)); + } + LinearLayout buttons = CockpitViews.horizontal(this); + TextView cancel = actionButton("取消", false, null); + TextView confirm = actionButton(positiveLabel, true, null); + buttons.addView(cancel, equalButtonParams(0f, 6f)); + buttons.addView(confirm, equalButtonParams(6f, 0f)); + panel.addView(buttons, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 20f, 0f, 0f)); + dialog.setContentView(panel); + dialog.setCanceledOnTouchOutside(false); + dialog.setOnCancelListener(ignored -> { + if (onCancel != null) onCancel.run(); + }); + cancel.setOnClickListener(view -> { + dialog.dismiss(); + if (onCancel != null) onCancel.run(); + }); + confirm.setOnClickListener(view -> { + dialog.dismiss(); + if (onConfirm != null) onConfirm.run(); + }); + dialog.setOnDismissListener(ignored -> { + if (safetyDialog == dialog) safetyDialog = null; + }); + dialog.show(); + Window dialogWindow = dialog.getWindow(); + if (dialogWindow != null) { + dialogWindow.setBackgroundDrawableResource(android.R.color.transparent); + dialogWindow.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND); + WindowManager.LayoutParams attributes = dialogWindow.getAttributes(); + attributes.dimAmount = 0.72f; + dialogWindow.setAttributes(attributes); + int width = Math.min(getResources().getDisplayMetrics().widthPixels - dp(48f), dp(560f)); + dialogWindow.setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT); + } + safetyDialog = dialog; + } + + private void showPage(int pageId) { + currentPage = pageId; + for (int i = 0; i < pages.length; i++) { + pages[i].setVisibility(pageForIndex(i) == pageId ? View.VISIBLE : View.GONE); + } + statusBar.setTitle(pageTitle(pageId)); + if (homeVehicle != null) { + homeVehicle.setAnimationEnabled(started && pageId == PAGE_HOME); + } + if (controlVehicle != null) { + controlVehicle.setAnimationEnabled(started && pageId == PAGE_CONTROLS); + } + if (inAppMascotLayer != null) { + // Keep the mascot as a genuine over-content companion while giving each information + // architecture zone a semantic starting point. InAppMascotLayer then projects this + // preference (or that page's saved drag position) away from live command controls. + float dockX = 0.98f; + float dockY; + switch (pageId) { + case PAGE_MODES: + dockX = 0.98f; + dockY = 0.88f; + break; + case PAGE_CLIMATE: + dockY = 0.97f; // Lower note area, below climate and defrost commands. + break; + case PAGE_CONTROLS: + dockY = 0.82f; // Read-only 3D status card, below window commands. + break; + case PAGE_DEVICE: + dockX = 0.44f; + dockY = 0.46f; // Vehicle telemetry field, away from USB-CAN actions. + break; + case PAGE_SETTINGS: + dockY = 0.90f; // Safety copy, below action demonstrations. + break; + default: + dockY = 0.28f; // Purpose-built companion card on the home page. + break; + } + inAppMascotLayer.setPageDefaultDock(pageId, dockX, dockY); + } + if (!MascotMusicService.isPlaying()) { + MascotActionBus.perform(pageAction(pageId)); + } + } + + private void navigateTo(int pageId) { + navigationRail.setSelectedItem(pageId); + showPage(pageId); + } + + private int pageForIndex(int index) { + switch (index) { + case 0: return PAGE_HOME; + case 1: return PAGE_MODES; + case 2: return PAGE_CLIMATE; + case 3: return PAGE_CONTROLS; + case 4: return PAGE_DEVICE; + default: return PAGE_SETTINGS; + } + } + + private String pageTitle(int pageId) { + switch (pageId) { + case PAGE_MODES: return "功能模式"; + case PAGE_CLIMATE: return "智能空调"; + case PAGE_CONTROLS: return "车辆控制"; + case PAGE_DEVICE: return "设备诊断"; + case PAGE_SETTINGS: return "桌宠与设置"; + default: return "智舱"; + } + } + + private MascotActionBus.Action pageAction(int pageId) { + float speed = latestSnapshot == null ? Float.NaN : latestSnapshot.speedKph(); + return Float.isFinite(speed) && speed >= 5f + ? MascotActionBus.Action.DRIVE : MascotActionBus.Action.IDLE; + } + + private void showResultMascot(boolean success) { + MascotActionBus.Action result = success + ? MascotActionBus.Action.SUCCESS : MascotActionBus.Action.ERROR; + MascotActionBus.perform(result); + restoreMascotActionLater(result, MascotActionTiming.resultMs(result)); + } + + private void showResultMascotAfterOperation( + MascotActionBus.Action operation, boolean success, long operationStartedMs) { + long elapsed = Math.max(0L, SystemClock.uptimeMillis() - operationStartedMs); + long delay = Math.max(0L, + MascotActionTiming.minimumOperationMs(operation) - elapsed); + mainHandler.postDelayed(() -> { + if (destroyed || MascotActionBus.getAction() != operation) return; + showResultMascot(success); + }, delay); + } + + private void restoreMascotActionLater(MascotActionBus.Action expected) { + restoreMascotActionLater(expected, MascotActionTiming.resultMs(expected)); + } + + private void restoreMascotActionLater( + MascotActionBus.Action expected, long delayMs) { + mainHandler.postDelayed(() -> { + if (destroyed || MascotActionBus.getAction() != expected) return; + MascotActionBus.perform(MascotMusicService.isPlaying() + ? MascotActionBus.Action.MUSIC : pageAction(currentPage)); + }, Math.max(250L, delayMs)); + } + + private void showFeedback(FeedbackKind kind, String message) { + if (feedbackText == null) return; + int color; + switch (kind) { + case SUCCESS: color = CockpitTheme.COLOR_SUCCESS; break; + case WARNING: color = CockpitTheme.COLOR_WARNING; break; + case ERROR: color = CockpitTheme.COLOR_DANGER; break; + case WORKING: color = CockpitTheme.COLOR_PRIMARY; break; + default: color = CockpitTheme.COLOR_ACTION; break; + } + feedbackAccent.setBackground(CockpitDrawables.roundedRect(this, color, 100f)); + feedbackText.setText(message == null || message.trim().isEmpty() ? "状态已更新" : message.trim()); + feedbackText.setTextColor(kind == FeedbackKind.ERROR + ? CockpitTheme.COLOR_DANGER : CockpitTheme.COLOR_TEXT_PRIMARY); + feedbackTime.setText(shortTime.format(new Date())); + } + + private void showDemoAction(MascotActionBus.Action action) { + if (action == MascotActionBus.Action.MUSIC && !MascotMusicService.isActive()) { + toggleMusic(); + return; + } + if (action == MascotActionBus.Action.IDLE && MascotMusicService.isActive()) { + MascotMusicService.requestStop(this); + updateMusicButton(); + } + MascotActionBus.perform(action); + showFeedback(FeedbackKind.INFO, "动作演示 · " + actionDisplayName(action)); + if (action != MascotActionBus.Action.MUSIC + && action != MascotActionBus.Action.IDLE + && action != MascotActionBus.Action.DRIVE + && action != MascotActionBus.Action.SLEEP) { + restoreMascotActionLater(action, MascotActionTiming.demoMs(action)); + } + } + + /** Starts the low-frequency device inspection phrase without delaying the actual I/O call. */ + private void beginDeviceOperation() { + deviceOperationStartedMs = SystemClock.uptimeMillis(); + deviceOperationGeneration++; + deviceResultScheduledGeneration = -1; + MascotActionBus.perform(MascotActionBus.Action.DEVICE); + } + + /** Keeps fast emulator/device callbacks from truncating the inspection choreography. */ + private void showDeviceOperationResult(boolean success) { + final long startedMs = deviceOperationStartedMs; + final int generation = deviceOperationGeneration; + if (startedMs <= 0L || MascotActionBus.getAction() != MascotActionBus.Action.DEVICE) { + showResultMascot(success); + return; + } + if (deviceResultScheduledGeneration == generation) return; + deviceResultScheduledGeneration = generation; + long elapsed = Math.max(0L, SystemClock.uptimeMillis() - startedMs); + long delay = Math.max(0L, + MascotActionTiming.minimumOperationMs(MascotActionBus.Action.DEVICE) - elapsed); + mainHandler.postDelayed(() -> { + if (destroyed || generation != deviceOperationGeneration + || MascotActionBus.getAction() != MascotActionBus.Action.DEVICE) { + return; + } + deviceOperationStartedMs = 0L; + showResultMascot(success); + }, delay); + } + + private void updateVehicleControlAvailability() { + boolean enabled = gatewayReady && !commandPending; + for (View control : vehicleControls) { + control.setEnabled(enabled); + control.setAlpha(enabled ? 1f : 0.48f); + } + if (windowControl != null) windowControl.setControlsLocked(true); + } + + private static boolean lacksIndependentReadback(String command) { + if (command == null) return false; + return command.startsWith("CLIMATE DEFROST_REAR ") + || command.startsWith("LIGHT INTERIOR ") + || command.startsWith("LIGHT AMBIENT ") + || command.startsWith("SUNSHADE "); + } + + private void registerVehicleControl(View control) { + if (control != null && !vehicleControls.contains(control)) vehicleControls.add(control); + } + + private TextView vehicleButton( + String label, + boolean primary, + String command, + MascotActionBus.Action action) { + TextView button = actionButton(label, primary, + view -> sendVehicleCommand(label, command, action)); + registerVehicleControl(button); + return button; + } + + private TextView movingButton( + String label, String command, MascotActionBus.Action action) { + TextView button = actionButton(label, false, + view -> confirmMovingPart(label, command, action)); + registerVehicleControl(button); + return button; + } + + private void addIndividualWindowRow( + LinearLayout parent, String name, VehicleCommands.Window position) { + LinearLayout row = CockpitViews.horizontal(this); + TextView label = CockpitViews.text(this, name, 14f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + row.addView(label, new LinearLayout.LayoutParams(0, + ViewGroup.LayoutParams.MATCH_PARENT, 0.78f)); + TextView close = movingButton(name + "关闭", VehicleCommands.window(position, false), + MascotActionBus.Action.WINDOW); + close.setText("关闭"); + TextView open = movingButton(name + "打开", VehicleCommands.window(position, true), + MascotActionBus.Action.WINDOW); + open.setText("打开"); + row.addView(close, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 0f, 0f, 4f, 0f)); + row.addView(open, marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, + 1f, 4f, 0f, 0f, 0f)); + parent.addView(row, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 5f, 0f, 0f)); + } + + private void addActionDemoRow(LinearLayout parent, View first, View second, View third) { + LinearLayout row = CockpitViews.horizontal(this); + row.addView(first, equalButtonParams(0f, 4f)); + row.addView(second, equalButtonParams(4f, 4f)); + row.addView(third, equalButtonParams(4f, 0f)); + parent.addView(row, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f, + 0f, 6f, 0f, 0f)); + } + + private TextView demoButton(String label, MascotActionBus.Action action) { + return actionButton(label, false, view -> showDemoAction(action)); + } + + private LinearLayout buttonPair(View first, View second) { + LinearLayout row = CockpitViews.horizontal(this); + row.addView(first, equalButtonParams(0f, 5f)); + row.addView(second, equalButtonParams(5f, 0f)); + return row; + } + + private TextView actionButton(String label, boolean primary, View.OnClickListener listener) { + TextView button = CockpitViews.actionButton(this, label, primary); + button.setTextSize(TypedValue.COMPLEX_UNIT_PX, CockpitTheme.sp(this, 14f)); + button.setOnClickListener(listener); + return button; + } + + private LinearLayout card(String title, String subtitle) { + LinearLayout card = CockpitViews.vertical(this); + card.setPadding(dp(16f), dp(13f), dp(16f), dp(13f)); + card.setBackground(CockpitDrawables.surface(this)); + TextView heading = CockpitViews.text(this, title, 18f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + card.addView(heading, matchWidthWrap()); + if (subtitle != null && !subtitle.isEmpty()) { + TextView hint = CockpitViews.text(this, subtitle, 12.5f, + CockpitTheme.COLOR_TEXT_SECONDARY, CockpitTheme.TYPEFACE_REGULAR); + card.addView(hint, marginParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, + 0f, 0f, 4f, 0f, 9f)); + } + return card; + } + + private TextView valueRow(LinearLayout parent, String label, String initial) { + LinearLayout row = CockpitViews.horizontal(this); + TextView labelView = CockpitViews.label(this, label); + TextView value = CockpitViews.text(this, initial, 16f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + value.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + row.addView(labelView, new LinearLayout.LayoutParams(0, dp(32f), 1f)); + row.addView(value, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(32f))); + parent.addView(row, matchWidthWrap()); + return value; + } + + private TextView largeReading(LinearLayout parent, String label, String value) { + LinearLayout row = CockpitViews.horizontal(this); + TextView labelView = CockpitViews.label(this, label); + TextView valueView = CockpitViews.text(this, value, 18f, + CockpitTheme.COLOR_TEXT_PRIMARY, CockpitTheme.TYPEFACE_MEDIUM); + valueView.setGravity(Gravity.CENTER_VERTICAL | Gravity.END); + row.addView(labelView, new LinearLayout.LayoutParams(0, dp(38f), 1f)); + row.addView(valueView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, dp(38f))); + parent.addView(row, matchWidthWrap()); + return valueView; + } + + private TextView pill(String text, int color) { + TextView pill = CockpitViews.text(this, text, 12f, color, CockpitTheme.TYPEFACE_MEDIUM); + pill.setGravity(Gravity.CENTER); + pill.setPadding(dp(9f), 0, dp(9f), 0); + pill.setBackground(CockpitDrawables.pill( + this, CockpitTheme.withAlpha(color, 0.09f), CockpitTheme.withAlpha(color, 0.62f))); + return pill; + } + + private TextView multiLineText(String value, float size, int color, Typeface typeface) { + TextView text = CockpitViews.text(this, value, size, color, typeface); + text.setSingleLine(false); + text.setEllipsize(null); + text.setGravity(Gravity.TOP | Gravity.START); + text.setLineSpacing(dp(2f), 1.06f); + return text; + } + + private View thinDivider() { + View divider = new View(this); + divider.setBackgroundColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.78f)); + return divider; + } + + private FrameLayout pageFrame() { + FrameLayout page = new FrameLayout(this); + page.setBackgroundColor(CockpitTheme.COLOR_BACKGROUND); + return page; + } + + private LinearLayout pageColumn() { + LinearLayout content = CockpitViews.vertical(this); + content.setPadding(dp(18f), dp(14f), dp(18f), dp(14f)); + return content; + } + + private LinearLayout pageRow() { + LinearLayout content = CockpitViews.horizontal(this); + content.setPadding(dp(18f), dp(14f), dp(18f), dp(14f)); + return content; + } + + private void setPairState(TextView positive, TextView negative, boolean positiveSelected) { + setSelectedAppearance(positive, positiveSelected); + setSelectedAppearance(negative, !positiveSelected); + } + + private void setOptionalPairState(TextView positive, TextView negative, Boolean state) { + if (state == null) { + setSelectedAppearance(positive, false); + setSelectedAppearance(negative, false); + return; + } + setPairState(positive, negative, state); + } + + private static Boolean optionalFlag(boolean present, boolean value, Boolean remembered) { + return present ? value : remembered; + } + + private void rememberCommandState(String command) { + if (VehicleCommands.rearDefrost(true).equals(command)) rememberedRearDefrost = true; + else if (VehicleCommands.rearDefrost(false).equals(command)) rememberedRearDefrost = false; + else if (VehicleCommands.light(VehicleCommands.Light.INTERIOR, true).equals(command)) { + rememberedInteriorLight = true; + } else if (VehicleCommands.light(VehicleCommands.Light.INTERIOR, false).equals(command)) { + rememberedInteriorLight = false; + } else if (VehicleCommands.light(VehicleCommands.Light.AMBIENT, true).equals(command)) { + rememberedAmbientLight = true; + } else if (VehicleCommands.light(VehicleCommands.Light.AMBIENT, false).equals(command)) { + rememberedAmbientLight = false; + } else if (VehicleCommands.sunshade(true).equals(command)) { + rememberedSunshadeOpen = true; + } else if (VehicleCommands.sunshade(false).equals(command)) { + rememberedSunshadeOpen = false; + } + } + + private void setSelectedAppearance(TextView button, boolean selected) { + if (button == null) return; + button.setBackground(selected + ? CockpitDrawables.selectedButton(this) + : CockpitDrawables.secondaryButton(this)); + button.setTextColor(selected + ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_PRIMARY); + } + + private String environmentDescription() { + if (vehicleGateway == null) return "车辆网关尚未创建"; + return (vehicleGateway.isSimulation() ? "【模拟环境】" : "【实车环境】") + + "\n" + vehicleGateway.environmentLabel() + + (vehicleGateway.isSimulation() + ? "\n不会连接 BYD autoservice、ADB helper 或真实 USB-CAN。" + : "\n通过认证 helper 访问固定车辆能力;控制范围由双层白名单限制。"); + } + + private static boolean isAutoClimate(VehicleSnapshot snapshot) { + // DiLink readback uses 0=automatic and 1=manual. + return snapshot.climateInt("auto", 1) == 0; + } + + private static String field(VehicleSnapshot snapshot, String key, String fallback) { + String value = snapshot.vehicleValue(key); + return "?".equals(value) ? fallback : value; + } + + private static String formatNumber(float value, int decimals) { + if (!Float.isFinite(value)) return "--"; + return String.format(Locale.getDefault(), decimals <= 0 ? "%.0f" : "%.1f", value); + } + + private static String formatTemperature(float value) { + if (!Float.isFinite(value)) return "--℃"; + boolean whole = Math.abs(value - Math.round(value)) < 0.01f; + return String.format(Locale.getDefault(), whole ? "%.0f℃" : "%.1f℃", value); + } + + private static String gearName(int gear) { + switch (gear) { + case 1: return "P"; + case 2: return "R"; + case 3: return "N"; + case 4: return "D"; + default: return gear < 0 ? "--" : Integer.toString(gear); + } + } + + private static String doorsText(VehicleSnapshot snapshot) { + return snapshot.vehicleInt("dfl", 0) == 0 + && snapshot.vehicleInt("dfr", 0) == 0 + && snapshot.vehicleInt("drl", 0) == 0 + && snapshot.vehicleInt("drr", 0) == 0 ? "均关闭" : "有车门开启"; + } + + private static String openText(int code) { + return code == 0 ? "关闭" : "开启"; + } + + private static String safeDetail(String detail) { + return detail == null || detail.trim().isEmpty() ? "未返回详细信息" : detail.trim(); + } + + private static String firstLine(String value) { + int newline = value.indexOf('\n'); + return newline < 0 ? value : value.substring(0, newline); + } + + private static boolean containsAny(String value, String... needles) { + for (String needle : needles) { + if (value.contains(needle)) return true; + } + return false; + } + + private static String actionDisplayName(MascotActionBus.Action action) { + switch (action) { + case CLIMATE: return "空调轻呼吸"; + case WINDOW: return "车窗侧向探头"; + case LIGHT: return "灯光发光摇摆"; + case SUNSHADE: return "遮阳帘伸展"; + case DEVICE: return "设备巡检扫描"; + case MUSIC: return "实时音乐律动"; + case SUCCESS: return "成功庆祝"; + case ERROR: return "错误提醒"; + default: return "安静待机"; + } + } + + private static String doorName(VehicleSilhouetteView.Door door) { + switch (door) { + case FRONT_LEFT: return "左前门"; + case FRONT_RIGHT: return "右前门"; + case REAR_LEFT: return "左后门"; + case REAR_RIGHT: return "右后门"; + default: return "尾门"; + } + } + + private int dp(float value) { + return CockpitTheme.dpInt(this, value); + } + + private FrameLayout.LayoutParams fillFrame() { + return new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + + private LinearLayout.LayoutParams matchWidthWrap() { + return new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + } + + private LinearLayout.LayoutParams rowMargin() { + return marginParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56f), 0f, + 0f, 9f, 0f, 0f); + } + + private LinearLayout.LayoutParams equalButtonParams(float leftDp, float rightDp) { + return marginParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f, + leftDp, 0f, rightDp, 0f); + } + + private LinearLayout.LayoutParams marginParams( + int width, + int height, + float weight, + float leftDp, + float topDp, + float rightDp, + float bottomDp) { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(width, height, weight); + params.setMargins(dp(leftDp), dp(topDp), dp(rightDp), dp(bottomDp)); + return params; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionBus.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionBus.java new file mode 100644 index 0000000..92f4f30 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionBus.java @@ -0,0 +1,313 @@ +package com.seeker.n7cockpit; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import java.lang.ref.WeakReference; +import java.util.Iterator; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Process-local state shared by the in-dashboard mascot and the overlay mascot. + * + *

All listener callbacks are delivered on the main thread. Listeners are held weakly so a + * forgotten unregister call cannot retain an Activity or Service; callers should still remove + * listeners when detached to avoid unnecessary callback work.

+ */ +public final class MascotActionBus { + public static final int MIN_SIZE_DP = 96; + public static final int MAX_SIZE_DP = 480; + public static final int DEFAULT_SIZE_DP = 240; + + /** Every dashboard function has a deliberately different visual response. */ + public enum Action { + IDLE, + GREET, + CLIMATE, + WINDOW, + LIGHT, + SUNSHADE, + DEVICE, + MUSIC, + SUCCESS, + ERROR, + /** Character braces and trails its limbs while the user moves its window. */ + DRAG, + /** Direction-specific balance reactions while the mascot window is being moved. */ + DRAG_LEFT, + DRAG_RIGHT, + DRAG_UP, + DRAG_DOWN, + /** Localized touch reactions. Left/right refer to the side visible on screen. */ + TAP_HEAD, + TAP_BODY, + TAP_LEFT_HAND, + TAP_RIGHT_HAND, + TAP_FEET, + DRIVE, + SLEEP + } + + /** Immutable audio analysis sample. Values other than frequencies are normalized to 0..1. */ + public static final class AudioFrame { + public static final AudioFrame SILENCE = + new AudioFrame(0f, 0f, 0f, 0f, 0f, 0L, 0L); + + public final float rms; + public final float bassEnergy; + public final float midEnergy; + public final float trebleEnergy; + public final float dominantHz; + public final long playbackFrame; + public final long timestampMs; + + public AudioFrame( + float rms, + float bassEnergy, + float midEnergy, + float trebleEnergy, + float dominantHz, + long playbackFrame, + long timestampMs) { + this.rms = unit(rms); + this.bassEnergy = unit(bassEnergy); + this.midEnergy = unit(midEnergy); + this.trebleEnergy = unit(trebleEnergy); + this.dominantHz = Math.max(0f, dominantHz); + this.playbackFrame = Math.max(0L, playbackFrame); + this.timestampMs = Math.max(0L, timestampMs); + } + + private static float unit(float value) { + if (Float.isNaN(value) || value <= 0f) return 0f; + return Math.min(1f, value); + } + } + + public interface Listener { + default void onMascotActionChanged(Action action) {} + + default void onMascotAudioFrame(AudioFrame frame) {} + + default void onMascotSizeChanged(int sizeDp) {} + + default void onMascotOverlayStateChanged(boolean running) {} + } + + /** + * Vehicle context the choreography reacts to. Read on the GL thread every frame, so it is a + * single immutable snapshot rather than a set of independent volatile fields. + */ + public static final class Context { + public static final Context UNKNOWN = new Context(Float.NaN, Float.NaN); + + /** Vehicle speed in km/h, or NaN when unknown. */ + public final float speedKph; + /** Climate target temperature in Celsius, or NaN when unknown. */ + public final float climateTargetC; + + public Context(float speedKph, float climateTargetC) { + this.speedKph = speedKph; + this.climateTargetC = climateTargetC; + } + } + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static final CopyOnWriteArrayList> LISTENERS = + new CopyOnWriteArrayList<>(); + private static final AtomicBoolean AUDIO_DISPATCH_QUEUED = new AtomicBoolean(false); + + private static volatile Action currentAction = Action.IDLE; + private static volatile AudioFrame currentAudioFrame = AudioFrame.SILENCE; + private static volatile Context currentContext = Context.UNKNOWN; + private static volatile int currentSizeDp = DEFAULT_SIZE_DP; + private static volatile boolean overlayRunning; + + private MascotActionBus() {} + + /** Adds a listener and immediately supplies a coherent snapshot on the main thread. */ + public static void addListener(Listener listener) { + if (listener == null) return; + removeListener(listener); + LISTENERS.add(new WeakReference<>(listener)); + runOnMain(() -> { + if (!contains(listener)) return; + safeAction(listener, currentAction); + safeAudio(listener, currentAudioFrame); + safeSize(listener, currentSizeDp); + safeOverlayState(listener, overlayRunning); + }); + } + + public static void removeListener(Listener listener) { + if (listener == null) return; + for (WeakReference reference : LISTENERS) { + Listener candidate = reference.get(); + if (candidate == null || candidate == listener) LISTENERS.remove(reference); + } + } + + public static Action getAction() { + return currentAction; + } + + /** Alias useful at call sites that describe an event rather than state. */ + public static void perform(Action action) { + setAction(action); + } + + public static void setAction(Action action) { + final Action safeAction = action == null ? Action.IDLE : action; + currentAction = safeAction; + runOnMain(() -> dispatchAction(safeAction)); + } + + public static AudioFrame getAudioFrame() { + return currentAudioFrame; + } + + public static Context getContext() { + return currentContext; + } + + /** + * Publishes vehicle context for the choreography. There is no listener fan-out: the renderer + * samples the latest snapshot once per frame, so a 5 s polling cadence costs nothing. + */ + public static void publishContext(float speedKph, float climateTargetC) { + currentContext = new Context(speedKph, climateTargetC); + } + + /** + * Publishes a high-rate audio frame without allowing analysis work to flood the UI queue. + * Multiple pending samples are coalesced and the newest one wins. + */ + public static void publishAudioFrame(AudioFrame frame) { + currentAudioFrame = frame == null ? AudioFrame.SILENCE : frame; + if (!AUDIO_DISPATCH_QUEUED.compareAndSet(false, true)) return; + MAIN.post(() -> { + AUDIO_DISPATCH_QUEUED.set(false); + dispatchAudio(currentAudioFrame); + }); + } + + public static int getSizeDp() { + return currentSizeDp; + } + + /** Sets the overlay size in dp, clamped to the production-safe range. */ + public static void setSizeDp(int sizeDp) { + final int safeSize = Math.max(MIN_SIZE_DP, Math.min(MAX_SIZE_DP, sizeDp)); + if (currentSizeDp == safeSize) return; + currentSizeDp = safeSize; + runOnMain(() -> dispatchSize(safeSize)); + } + + public static boolean isOverlayRunning() { + return overlayRunning; + } + + /** Publishes the state of the actual attached system window, not merely a service request. */ + public static void setOverlayRunning(boolean running) { + if (overlayRunning == running) return; + overlayRunning = running; + runOnMain(() -> dispatchOverlayState(running)); + } + + /** Convenience factory for non-player audio sources and tests. */ + public static AudioFrame audioFrame( + float rms, + float bass, + float mid, + float treble, + float dominantHz, + long playbackFrame) { + return new AudioFrame( + rms, + bass, + mid, + treble, + dominantHz, + playbackFrame, + SystemClock.uptimeMillis()); + } + + private static void dispatchAction(Action action) { + pruneAndVisit(listener -> safeAction(listener, action)); + } + + private static void dispatchAudio(AudioFrame frame) { + pruneAndVisit(listener -> safeAudio(listener, frame)); + } + + private static void dispatchSize(int sizeDp) { + pruneAndVisit(listener -> safeSize(listener, sizeDp)); + } + + private static void dispatchOverlayState(boolean running) { + pruneAndVisit(listener -> safeOverlayState(listener, running)); + } + + private interface Visitor { + void visit(Listener listener); + } + + private static void pruneAndVisit(Visitor visitor) { + for (WeakReference reference : LISTENERS) { + Listener listener = reference.get(); + if (listener == null) { + LISTENERS.remove(reference); + } else { + visitor.visit(listener); + } + } + } + + private static boolean contains(Listener listener) { + Iterator> iterator = LISTENERS.iterator(); + while (iterator.hasNext()) { + Listener candidate = iterator.next().get(); + if (candidate == listener) return true; + } + return false; + } + + private static void safeAction(Listener listener, Action action) { + try { + listener.onMascotActionChanged(action); + } catch (RuntimeException ignored) { + // One optional presentation listener must never break vehicle controls or audio. + } + } + + private static void safeAudio(Listener listener, AudioFrame frame) { + try { + listener.onMascotAudioFrame(frame); + } catch (RuntimeException ignored) { + // Keep other listeners alive if a detached view races a callback. + } + } + + private static void safeSize(Listener listener, int sizeDp) { + try { + listener.onMascotSizeChanged(sizeDp); + } catch (RuntimeException ignored) { + // The bus is deliberately failure-isolated from clients. + } + } + + private static void safeOverlayState(Listener listener, boolean running) { + try { + listener.onMascotOverlayStateChanged(running); + } catch (RuntimeException ignored) { + // Overlay teardown must not prevent the remaining listeners from recovering. + } + } + + private static void runOnMain(Runnable runnable) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable.run(); + else MAIN.post(runnable); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionTiming.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionTiming.java new file mode 100644 index 0000000..d45d79f --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotActionTiming.java @@ -0,0 +1,63 @@ +package com.seeker.n7cockpit; + +/** + * Product-level timing policy based on interaction frequency and operational significance. + * + *

Direct manipulation must answer immediately; frequently repeated climate and light controls + * stay compact; safety-confirmed moving parts and diagnostics get enough time for a full readable + * phrase. These values define presentation time only and never delay the actual vehicle command.

+ */ +final class MascotActionTiming { + private MascotActionTiming() {} + + static long minimumOperationMs(MascotActionBus.Action action) { + MascotActionBus.Action safe = action == null ? MascotActionBus.Action.IDLE : action; + switch (safe) { + case CLIMATE: return 3_400L; + case LIGHT: return 3_600L; + case WINDOW: return 4_800L; + case SUNSHADE: return 5_200L; + case DEVICE: return 5_800L; + case GREET: return 4_200L; + default: return 3_600L; + } + } + + static long resultMs(MascotActionBus.Action result) { + return result == MascotActionBus.Action.ERROR ? 3_800L : 3_300L; + } + + /** Manual demos are deliberately longer than real high-frequency control feedback. */ + static long demoMs(MascotActionBus.Action action) { + MascotActionBus.Action safe = action == null ? MascotActionBus.Action.IDLE : action; + switch (safe) { + case CLIMATE: return 5_200L; + case LIGHT: return 5_400L; + case WINDOW: return 5_800L; + case SUNSHADE: return 6_000L; + case DEVICE: return 6_400L; + case SUCCESS: return 4_300L; + case ERROR: return 4_800L; + case GREET: return 5_000L; + case TAP_HEAD: return 3_100L; + case TAP_BODY: return 2_850L; + case TAP_LEFT_HAND: + case TAP_RIGHT_HAND: return 2_400L; + case TAP_FEET: return 3_000L; + default: return 4_600L; + } + } + + static long directTouchMs(MascotInteractionMapper.TapRegion region) { + MascotInteractionMapper.TapRegion safe = region == null + ? MascotInteractionMapper.TapRegion.BODY : region; + switch (safe) { + case HEAD: return 3_100L; + case FEET: return 3_000L; + case LEFT_HAND: + case RIGHT_HAND: return 2_400L; + case BODY: + default: return 2_850L; + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotAnimator.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotAnimator.java new file mode 100644 index 0000000..076ca08 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotAnimator.java @@ -0,0 +1,381 @@ +package com.seeker.n7cockpit; + +import android.os.SystemClock; + +/** + * Turns cockpit actions into a continuous mascot performance. + * + *

Every frame is assembled in layers: a base pose from either a baked dance clip or the + * procedural choreography, a cross-fade against whatever was on screen when the action changed, + * frequency-driven accents for music, and finally the hair and cape springs. Nothing here ever + * snaps — that cross-fade plus the springs is what turns nine isolated poses into one character.

+ * + *

Idle motion stays deliberately restrained. Several quiet procedural micro-performances + * provide variety; large turns and raised-arm excerpts are reserved for explicit actions.

+ */ +final class MascotAnimator { + private static final long CROSSFADE_MS = 320L; + private static final long MUSIC_LOOP_CROSSFADE_MS = 560L; + private static final float MAX_CLIP_FRAME_DT_SECONDS = 0.10f; + /** The full supplied choreography now runs for about 84.6 s before it returns to frame one. */ + private static final String[] DANCE_CLIPS = {"dance_long"}; + + private final MascotRig rig; + private final MascotHands hands; + private final PaimonMotion motion; + private final MascotSprings springs; + private final MascotClipLibrary clips; + + private final MascotPose output; + private final MascotPose base; + private final MascotPose fadeFrom; + private final MascotPose clipPose; + private final float[] clipRoot = new float[3]; + + private MascotActionBus.Action action = MascotActionBus.Action.IDLE; + private long actionStartedMs = SystemClock.uptimeMillis(); + /** Repeating the same semantic action still creates a fresh acknowledgement epoch. */ + private long actionEpoch; + /** Epoch for which a non-looping baked gesture has already been started. */ + private long oneShotClipEpoch = -1L; + private long fadeStartedMs = -1L; + private long fadeDurationMs = CROSSFADE_MS; + private long lastFrameMs; + + private volatile MascotActionBus.AudioFrame audioFrame = MascotActionBus.AudioFrame.SILENCE; + private volatile MascotActionBus.Context context = MascotActionBus.Context.UNKNOWN; + + private float smoothRms; + private float smoothBass; + private float smoothMid; + private float smoothTreble; + private float smoothDominantHz = 240f; + + private MascotClipLibrary.Clip activeClip; + /** + * Integrated clip clock. Never derive this from total elapsed time multiplied by a changing + * speed: doing that makes every audio-speed update move the historical timeline and is the + * source of the increasingly violent whole-character jitter seen during music playback. + */ + private double clipPlayheadSeconds; + private long lastClipAdvanceMs; + private float clipSpeed = 1f; + private float smoothClipSpeed = 1f; + private float smoothMusicRootY; + private boolean musicRootInitialized; + private int danceCursor; + + MascotAnimator(MascotRig rig, MascotClipLibrary clips) { + if (rig == null) throw new IllegalArgumentException("rig == null"); + this.rig = rig; + this.clips = clips; + int jointCount = rig.jointCount(); + this.output = new MascotPose(jointCount); + this.base = new MascotPose(jointCount); + this.fadeFrom = new MascotPose(jointCount); + this.clipPose = new MascotPose(jointCount); + this.hands = new MascotHands(rig); + this.motion = new PaimonMotion(rig, hands); + this.springs = new MascotSprings(rig); + } + + void setAction(MascotActionBus.Action next, long startedMs) { + MascotActionBus.Action safe = next == null ? MascotActionBus.Action.IDLE : next; + if (safe == action && startedMs == actionStartedMs) return; + // Snapshot the pre-spring pose so the fade cannot double-count cloth dynamics. + fadeFrom.copyFrom(base); + fadeStartedMs = SystemClock.uptimeMillis(); + fadeDurationMs = CROSSFADE_MS; + action = safe; + actionStartedMs = startedMs; + actionEpoch++; + activeClip = null; + clipPlayheadSeconds = 0.0; + lastClipAdvanceMs = 0L; + clipSpeed = 1f; + smoothClipSpeed = 1f; + musicRootInitialized = false; + if (safe == MascotActionBus.Action.MUSIC) danceCursor = 0; + } + + void setAudioFrame(MascotActionBus.AudioFrame frame) { + audioFrame = frame == null ? MascotActionBus.AudioFrame.SILENCE : frame; + } + + void setContext(MascotActionBus.Context next) { + context = next == null ? MascotActionBus.Context.UNKNOWN : next; + } + + MascotActionBus.Action currentAction() { + return action; + } + + /** Smoothed loudness, exposed so the renderer can size the sparkle layer. */ + float energy() { + return clamp01(smoothRms * 0.5f + smoothBass * 0.42f + smoothMid * 0.18f); + } + + float bass() { + return smoothBass; + } + + float mid() { + return smoothMid; + } + + float treble() { + return smoothTreble; + } + + float dominantHz() { + return smoothDominantHz; + } + + void reset() { + springs.reset(); + lastFrameMs = 0L; + lastClipAdvanceMs = 0L; + fadeStartedMs = -1L; + } + + /** + * Evaluates one frame. + * + * @param nowMs monotonic clock used for action timing and cross-fades + * @param seconds continuous animation clock in double precision, so oscillator phases never + * jump when the process has been running for hours + */ + MascotPose evaluate(long nowMs, double seconds) { + float dt = lastFrameMs == 0L ? 1f / 60f : (nowMs - lastFrameMs) * 0.001f; + if (!(dt > 0f) || dt > 0.25f) dt = 1f / 60f; + lastFrameMs = nowMs; + + updateAudioSmoothing(nowMs); + // Fingers close over roughly 120 ms, fast enough to read as intent, slow enough to see. + float handBlend = clamp01(dt * 9f); + + MascotActionBus.Action effective = action; + float elapsed = Math.max(0f, (nowMs - actionStartedMs) * 0.001f); + chooseClip(nowMs, effective); + + motion.author(base, effective, seconds, elapsed, + smoothRms, smoothBass, smoothMid, smoothTreble, smoothDominantHz, + context, handBlend); + + if (activeClip != null) { + applyClip(base, nowMs, effective); + } + + float fade = 1f; + if (fadeStartedMs > 0L) { + fade = clamp01((nowMs - fadeStartedMs) / (float) fadeDurationMs); + if (fade >= 1f) fadeStartedMs = -1L; + } + if (fade < 1f) { + output.blend(fadeFrom, base, smoothStep(fade)); + } else { + output.copyFrom(base); + } + + springs.setEnergy(effective == MascotActionBus.Action.MUSIC + ? 0.25f + energy() * 1.35f + : MascotInteractionMapper.isDragAction(effective) ? 0.48f : 0.12f); + if (effective == MascotActionBus.Action.WINDOW) springs.gust(0f, 0.42f); + springs.update(output, dt); + return output; + } + + // ------------------------------------------------------------------ + // Clip selection and application + // ------------------------------------------------------------------ + + private void chooseClip(long nowMs, MascotActionBus.Action effective) { + if (clips == null || clips.isEmpty()) { + activeClip = null; + return; + } + switch (effective) { + case MUSIC: { + // The supplied choreography is intentionally one long performance. It does not + // return to the opening until all 84.6 seconds have played. + if (activeClip == null) { + startClip(nowMs, firstAvailable(DANCE_CLIPS), 1f); + } + break; + } + case GREET: { + startOneShot(nowMs, "gest_wave", 0.86f); + break; + } + case SUCCESS: { + // A compact V-pose acknowledges success without a large full-body spin. + startOneShot(nowMs, "gest_pose_v", 0.86f); + break; + } + default: + activeClip = null; + break; + } + } + + private void applyClip(MascotPose pose, long nowMs, MascotActionBus.Action effective) { + float clipFrameDt = advanceClipClock(nowMs, effective); + boolean finished = activeClip.sample( + (float) Math.max(0.0, clipPlayheadSeconds), clipPose, clipRoot); + + if (!activeClip.hasFingers) { + // Preserve the procedural proximal finger shape when a source clip did not animate + // hands. Full dance clips do animate every phalanx and bypass this compatibility path. + for (int slot = MascotPose.THUMB_L; slot <= MascotPose.PINKY_R; slot++) { + int offset = rig.modelJointForSlot(slot) * 4; + Quat.copy(pose.rot, offset, clipPose.rot, offset); + } + } + // The baked rotations are indexed exactly like the active GLB skin. This is the critical + // full-rig path: no finger, forearm, ankle or toe motion is collapsed into a remote slot. + System.arraycopy(clipPose.rot, 0, pose.rot, 0, pose.rot.length); + + // Baked performances own their complete body pose. Procedural root translation, rotation + // and scale must not leak through after the joint rotations have been replaced; that was + // the second source of apparent whole-model shaking during music playback. Horizontal + // stage travel remains deliberately discarded so the mascot stays in its host window. + pose.rootX = 0f; + pose.rootZ = 0f; + pose.rootYaw = 0f; + pose.rootPitch = 0f; + pose.rootRoll = 0f; + pose.scaleX = 1f; + pose.scaleY = 1f; + pose.scaleZ = 1f; + + float bakedRootY = 0.02f + clipRoot[1] * MascotView.CHARACTER_HEIGHT; + + if (effective == MascotActionBus.Action.MUSIC) { + // Retain authored jumps and landings while removing frame-scale root noise. Music now + // changes the integrated playback rate, lighting, particles and secondary motion; it + // never directly punches the whole model's position or scale. + if (!musicRootInitialized) { + smoothMusicRootY = bakedRootY; + musicRootInitialized = true; + } else { + float rootAlpha = 1f - (float) Math.exp(-Math.max(0f, clipFrameDt) * 11f); + smoothMusicRootY += (bakedRootY - smoothMusicRootY) * rootAlpha; + } + pose.rootY = smoothMusicRootY; + if (finished) advanceDanceSequence(nowMs, pose); + } else { + pose.rootY = bakedRootY; + // A one-shot stays on its authored final frame until the functional action is restored. + // This supplies a natural settle instead of dropping back into a procedural loop. + } + } + + /** Advances only this frame's delta by the current speed, so past time can never be re-timed. */ + private float advanceClipClock(long nowMs, MascotActionBus.Action effective) { + long previousMs = lastClipAdvanceMs; + lastClipAdvanceMs = nowMs; + float dt = previousMs <= 0L ? 0f : Math.max(0f, + Math.min(MAX_CLIP_FRAME_DT_SECONDS, (nowMs - previousMs) * 0.001f)); + + float targetSpeed = clipSpeed; + if (effective == MascotActionBus.Action.MUSIC) { + // A restrained 0.92..1.12 envelope reads as musical energy without turning transient + // spectrum changes into spasmodic movement. The extra low-pass is intentionally slower + // than the analyzer's band smoothing. + float musicalEnergy = clamp01( + smoothRms * 0.50f + smoothBass * 0.32f + smoothMid * 0.18f); + targetSpeed = 0.92f + musicalEnergy * 0.18f + smoothTreble * 0.02f; + } + if (dt <= 0f) { + smoothClipSpeed = targetSpeed; + } else { + float speedAlpha = 1f - (float) Math.exp(-dt * 2.4f); + smoothClipSpeed += (targetSpeed - smoothClipSpeed) * speedAlpha; + } + smoothClipSpeed = Math.max(0.75f, Math.min(1.20f, smoothClipSpeed)); + clipPlayheadSeconds += dt * smoothClipSpeed; + return dt; + } + + private void advanceDanceSequence(long nowMs, MascotPose finalPose) { + fadeFrom.copyFrom(finalPose); + fadeStartedMs = nowMs; + fadeDurationMs = MUSIC_LOOP_CROSSFADE_MS; + + MascotClipLibrary.Clip next = null; + for (int attempt = 0; attempt < DANCE_CLIPS.length; attempt++) { + danceCursor = (danceCursor + 1) % DANCE_CLIPS.length; + next = clips.get(DANCE_CLIPS[danceCursor]); + if (next != null) break; + } + startClip(nowMs, next, 1f); + } + + /** Starts at most one baked short gesture for each semantic action epoch. */ + private void startOneShot(long nowMs, String clipName, float speed) { + if (activeClip != null || oneShotClipEpoch == actionEpoch) return; + oneShotClipEpoch = actionEpoch; + MascotClipLibrary.Clip clip = clips.get(clipName); + if (clip == null || clip.loop) return; + startClip(nowMs, clip, speed); + } + + private void startClip(long nowMs, MascotClipLibrary.Clip clip, float speed) { + activeClip = clip; + clipPlayheadSeconds = 0.0; + lastClipAdvanceMs = nowMs; + clipSpeed = Float.isFinite(speed) && speed > 0f ? speed : 1f; + smoothClipSpeed = clipSpeed; + } + + private MascotClipLibrary.Clip firstAvailable(String[] names) { + for (String name : names) { + MascotClipLibrary.Clip clip = clips.get(name); + if (clip != null) return clip; + } + return null; + } + + // ------------------------------------------------------------------ + // Audio + // ------------------------------------------------------------------ + + private void updateAudioSmoothing(long nowMs) { + MascotActionBus.AudioFrame frame = audioFrame; + long age = nowMs - frame.timestampMs; + float freshness = age >= 0L && age < 280L ? 1f : 0f; + float targetRms = freshness * safeUnit(frame.rms * 4.5f); + float targetBass = freshness * safeUnit(frame.bassEnergy); + float targetMid = freshness * safeUnit(frame.midEnergy); + float targetTreble = freshness * safeUnit(frame.trebleEnergy); + float attack = targetRms > smoothRms ? 0.32f : 0.12f; + smoothRms += (targetRms - smoothRms) * attack; + smoothBass += (targetBass - smoothBass) * 0.24f; + smoothMid += (targetMid - smoothMid) * 0.22f; + smoothTreble += (targetTreble - smoothTreble) * 0.20f; + float safeDominantHz = safeFrequency(frame.dominantHz); + if (freshness > 0f && safeDominantHz > 20f) { + smoothDominantHz += (safeDominantHz - smoothDominantHz) * 0.12f; + } else { + smoothDominantHz += (240f - smoothDominantHz) * 0.025f; + } + } + + private static float smoothStep(float t) { + return t * t * (3f - 2f * t); + } + + private static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + private static float safeUnit(float value) { + if (!Float.isFinite(value) || value <= 0f) return 0f; + return Math.min(1f, value); + } + + private static float safeFrequency(float value) { + if (!Float.isFinite(value) || value <= 0f) return 0f; + return Math.min(6_000f, value); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotClipLibrary.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotClipLibrary.java new file mode 100644 index 0000000..b724ca4 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotClipLibrary.java @@ -0,0 +1,283 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Baked mascot animation clips produced by {@code scripts/paimon_pack_clips.py}. + * + *

The clips come from the user-supplied MMD choreography in {@code paimon_dance.blend}: the + * Blender stage bakes the IK-driven legs to explicit keys and exports an animation-only glTF; the + * packer then resamples to a fixed rate, converts every rotation to a delta from the joint's rest + * local transform and quantises it to normalised int16. A full clip retains all 136 deformation + * joints; a compatibility clip uses the compact 51-joint contract. Joint names embedded in the + * file are always reordered into the active model's skin order before playback.

+ * + *

The asset is optional. A missing or malformed file logs once and leaves the mascot fully + * procedural, so a broken art pipeline can never take the cockpit down.

+ */ +final class MascotClipLibrary { + private static final String TAG = "PaimonMascot"; + private static final int MAGIC = 0x31434d50; // "PMC1" little-endian + private static final int VERSION = 1; + private static final float INV_SHORT = 1f / 32767f; + + /** One baked clip, already re-ordered into active GLB skin order. */ + static final class Clip { + final String name; + final int fps; + final int frameCount; + final boolean loop; + /** + * True when the source motion actually keyframes the finger joints. When it does not, the + * runtime keeps its own hand shapes instead of letting the clip reset them to the splayed + * bind pose. + */ + final boolean hasFingers; + /** Quantised (x, y, z, w) per joint per frame in slot order. */ + private final short[] rotations; + /** Quantised root translation per frame, normalised to fractions of model height. */ + private final short[] rootPositions; + private final float rootScale; + private final int jointCount; + + Clip(String name, int fps, int frameCount, boolean loop, boolean hasFingers, + int jointCount, short[] rotations, short[] rootPositions, float rootScale) { + this.name = name; + this.fps = fps; + this.frameCount = frameCount; + this.loop = loop; + this.hasFingers = hasFingers; + this.jointCount = jointCount; + this.rotations = rotations; + this.rootPositions = rootPositions; + this.rootScale = rootScale; + } + + float durationSeconds() { + return frameCount / (float) fps; + } + + /** + * Samples the clip at a time in seconds and writes joint rotations into the pose. + * + * @param rootOut receives the root translation in fractions of model height + * @return true when a looping clip wrapped or a one-shot clip reached its end + */ + boolean sample(float seconds, MascotPose pose, float[] rootOut) { + if (pose == null || pose.jointCount() != jointCount) { + throw new IllegalArgumentException("Clip and pose use different mascot rigs"); + } + if (rootOut == null || rootOut.length < 3) { + throw new IllegalArgumentException("rootOut must contain at least three floats"); + } + float frame = seconds * fps; + boolean finished = false; + int lastFrame = frameCount - 1; + int frameA; + int frameB; + float t; + if (loop) { + float wrapped = frame % frameCount; + if (wrapped < 0f) wrapped += frameCount; + finished = frame >= frameCount; + frameA = (int) wrapped; + t = wrapped - frameA; + frameB = frameA + 1 >= frameCount ? 0 : frameA + 1; + } else if (frame >= lastFrame) { + frameA = lastFrame; + frameB = lastFrame; + t = 0f; + finished = true; + } else if (frame <= 0f) { + frameA = 0; + frameB = 0; + t = 0f; + } else { + frameA = (int) frame; + t = frame - frameA; + frameB = Math.min(lastFrame, frameA + 1); + } + + int baseA = frameA * jointCount * 4; + int baseB = frameB * jointCount * 4; + float[] quatA = SAMPLE_A.get(); + float[] quatB = SAMPLE_B.get(); + for (int joint = 0; joint < jointCount; joint++) { + int offset = joint * 4; + for (int c = 0; c < 4; c++) { + quatA[c] = rotations[baseA + offset + c] * INV_SHORT; + quatB[c] = rotations[baseB + offset + c] * INV_SHORT; + } + if (t <= 0f) { + Quat.copy(quatA, 0, pose.rot, offset); + Quat.normalize(pose.rot, offset); + } else { + Quat.slerp(quatA, 0, quatB, 0, t, pose.rot, offset); + } + } + + int rootA = frameA * 3; + int rootB = frameB * 3; + for (int c = 0; c < 3; c++) { + float a = rootPositions[rootA + c] * INV_SHORT * rootScale; + float b = rootPositions[rootB + c] * INV_SHORT * rootScale; + rootOut[c] = a + (b - a) * t; + } + return finished; + } + } + + private static final ThreadLocal SAMPLE_A = ThreadLocal.withInitial(() -> new float[4]); + private static final ThreadLocal SAMPLE_B = ThreadLocal.withInitial(() -> new float[4]); + + private final Map clips; + private final int jointCount; + + private MascotClipLibrary(Map clips, int jointCount) { + this.clips = clips; + this.jointCount = jointCount; + } + + Clip get(String name) { + return clips.get(name); + } + + boolean isEmpty() { + return clips.isEmpty(); + } + + int size() { + return clips.size(); + } + + /** Returns a library, or an empty one when the asset is absent or unreadable. */ + static MascotClipLibrary loadOrEmpty( + Context context, String assetPath, MascotGlbModel model) { + if (model == null) throw new IllegalArgumentException("model == null"); + String[] rigJointNames = new String[model.joints.length]; + for (int j = 0; j < model.joints.length; j++) rigJointNames[j] = model.joints[j].name; + try (InputStream input = context.getAssets().open(assetPath)) { + MascotClipLibrary library = parse(readAll(input), rigJointNames); + Log.i(TAG, "Loaded " + library.size() + " baked mascot clips for " + + library.jointCount + " joints"); + return library; + } catch (IOException | RuntimeException failure) { + Log.w(TAG, "Baked mascot clips unavailable, staying procedural: " + failure); + return new MascotClipLibrary(new HashMap<>(), rigJointNames.length); + } + } + + static MascotClipLibrary parse(byte[] bytes, String[] rigJointNames) throws IOException { + if (bytes == null || bytes.length < 12) throw new IOException("Clip file truncated"); + if (rigJointNames == null || rigJointNames.length < 1 || rigJointNames.length > 256) { + throw new IOException("Active mascot rig has an invalid joint count"); + } + Map rigIndexByName = new HashMap<>(); + for (int j = 0; j < rigJointNames.length; j++) { + String name = rigJointNames[j]; + if (name == null || name.isEmpty() || rigIndexByName.put(name, j) != null) { + throw new IOException("Active mascot rig has duplicate or empty joint names"); + } + } + ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + if (buffer.getInt() != MAGIC) throw new IOException("Not a mascot clip file"); + int version = buffer.getShort() & 0xFFFF; + if (version != VERSION) throw new IOException("Unsupported clip version " + version); + int jointCount = buffer.getShort() & 0xFFFF; + int clipCount = buffer.getShort() & 0xFFFF; + buffer.getShort(); // reserved + + if (jointCount != rigJointNames.length) { + throw new IOException("Clip joint count " + jointCount + " does not match rig " + + rigJointNames.length); + } + // Clips store their own joint names so a rig change fails loudly here instead of silently + // driving the wrong bone. + int[] rigIndexOfClipJoint = new int[jointCount]; + Set clipJointNames = new HashSet<>(); + for (int j = 0; j < jointCount; j++) { + String name = readString(buffer); + if (!clipJointNames.add(name)) { + throw new IOException("Clip repeats joint " + name); + } + Integer rigIndex = rigIndexByName.get(name); + if (rigIndex == null) throw new IOException("Clip references unknown joint " + name); + rigIndexOfClipJoint[j] = rigIndex; + } + + Map clips = new HashMap<>(); + for (int c = 0; c < clipCount; c++) { + String name = readString(buffer); + int fps = buffer.getShort() & 0xFFFF; + int frameCount = buffer.getShort() & 0xFFFF; + int flags = buffer.get() & 0xFF; + buffer.get(); // reserved + float rootScale = buffer.getFloat(); + if (fps <= 0 || frameCount <= 0 || !Float.isFinite(rootScale) + || rootScale <= 0f) { + throw new IOException("Clip " + name + " has invalid metadata"); + } + + long rotationCount = (long) frameCount * jointCount * 4L; + long rootCount = (long) frameCount * 3L; + long payloadBytes = (rotationCount + rootCount) * 2L; + if (rotationCount > Integer.MAX_VALUE || rootCount > Integer.MAX_VALUE + || payloadBytes > buffer.remaining()) { + throw new IOException("Clip " + name + " payload is truncated or too large"); + } + short[] rotations = new short[(int) rotationCount]; + for (int f = 0; f < frameCount; f++) { + for (int j = 0; j < jointCount; j++) { + int target = (f * jointCount + rigIndexOfClipJoint[j]) * 4; + rotations[target] = buffer.getShort(); + rotations[target + 1] = buffer.getShort(); + rotations[target + 2] = buffer.getShort(); + rotations[target + 3] = buffer.getShort(); + } + } + short[] rootPositions = new short[(int) rootCount]; + for (int i = 0; i < rootPositions.length; i++) { + rootPositions[i] = buffer.getShort(); + } + if (clips.put(name, new Clip(name, fps, frameCount, + (flags & 1) != 0, (flags & 2) != 0, jointCount, + rotations, rootPositions, rootScale)) != null) { + throw new IOException("Clip file repeats animation " + name); + } + } + if (buffer.hasRemaining()) { + throw new IOException("Clip file has " + buffer.remaining() + " trailing bytes"); + } + return new MascotClipLibrary(clips, jointCount); + } + + private static String readString(ByteBuffer buffer) throws IOException { + int length = buffer.get() & 0xFF; + if (length <= 0 || length > buffer.remaining()) { + throw new IOException("Clip string is malformed"); + } + byte[] raw = new byte[length]; + buffer.get(raw); + return new String(raw, StandardCharsets.UTF_8); + } + + private static byte[] readAll(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(1 << 16); + byte[] chunk = new byte[1 << 14]; + int read; + while ((read = input.read(chunk)) > 0) output.write(chunk, 0, read); + return output.toByteArray(); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotDockPlanner.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotDockPlanner.java new file mode 100644 index 0000000..47925ec --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotDockPlanner.java @@ -0,0 +1,249 @@ +package com.seeker.n7cockpit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Pure geometry planner for the in-app mascot's resting position. + * + *

The mascot is intentionally rendered above the cockpit rather than in a reserved lane. This + * planner keeps that overlay model while preventing its circular hit target from resting on top of + * driver controls. Candidate positions are compared lexicographically: critical command controls + * first, ordinary interactive surfaces second, read-only/low-risk visuals third, and only then + * distance from the requested point. This means a decorative 3D card may sit behind the mascot, + * but a window or climate command never loses to a cosmetically cleaner placement.

+ */ +final class MascotDockPlanner { + static final int PRIORITY_VISUAL = 1; + static final int PRIORITY_INTERACTIVE = 2; + static final int PRIORITY_CRITICAL = 3; + /** The rendered character occupies roughly 90% of its transparent square host. */ + static final float HIT_DIAMETER_FRACTION = 0.90f; + + private MascotDockPlanner() {} + + static Point findSafest( + float minX, + float minY, + float maxX, + float maxY, + float childWidth, + float childHeight, + float requestedX, + float requestedY, + List exclusions, + float clearance) { + float safeMinX = finite(minX, 0f); + float safeMinY = finite(minY, 0f); + float safeMaxX = Math.max(safeMinX, finite(maxX, safeMinX)); + float safeMaxY = Math.max(safeMinY, finite(maxY, safeMinY)); + float safeWidth = Math.max(1f, finite(childWidth, 1f)); + float safeHeight = Math.max(1f, finite(childHeight, 1f)); + float preferredX = clamp(finite(requestedX, safeMinX), safeMinX, safeMaxX); + float preferredY = clamp(finite(requestedY, safeMinY), safeMinY, safeMaxY); + float safeClearance = Math.max(0f, finite(clearance, 0f)); + List safeExclusions = exclusions == null + ? Collections.emptyList() : exclusions; + + // One tenth of the footprint gives deterministic, sub-button precision without adding + // meaningful work: even a 1920x1080 cockpit evaluates only a few thousand candidates. + float step = Math.max(8f, Math.min(safeWidth, safeHeight) / 10f); + ArrayList xSamples = samples(safeMinX, safeMaxX, step, preferredX); + ArrayList ySamples = samples(safeMinY, safeMaxY, step, preferredY); + + // Exclusion edges are valuable candidates when the available gap is narrower than a grid + // step. Add square-edge approximations as well as the regular grid; the final collision + // score still uses the mascot's actual circular touch footprint. + for (Exclusion exclusion : safeExclusions) { + if (exclusion == null || !exclusion.isValid()) continue; + addSample(xSamples, clamp( + exclusion.left - safeClearance - safeWidth, safeMinX, safeMaxX)); + addSample(xSamples, clamp( + exclusion.right + safeClearance, safeMinX, safeMaxX)); + addSample(ySamples, clamp( + exclusion.top - safeClearance - safeHeight, safeMinY, safeMaxY)); + addSample(ySamples, clamp( + exclusion.bottom + safeClearance, safeMinY, safeMaxY)); + } + + Candidate best = null; + float travelX = Math.max(1f, safeMaxX - safeMinX); + float travelY = Math.max(1f, safeMaxY - safeMinY); + for (float x : xSamples) { + for (float y : ySamples) { + Candidate candidate = score( + x, + y, + safeWidth, + safeHeight, + preferredX, + preferredY, + travelX, + travelY, + safeExclusions, + safeClearance); + if (best == null || candidate.isBetterThan(best)) best = candidate; + } + } + return best == null ? new Point(preferredX, preferredY) : new Point(best.x, best.y); + } + + private static Candidate score( + float x, + float y, + float width, + float height, + float preferredX, + float preferredY, + float travelX, + float travelY, + List exclusions, + float clearance) { + float centerX = x + width * 0.5f; + float centerY = y + height * 0.5f; + float radius = Math.max(1f, + Math.min(width, height) * 0.5f * HIT_DIAMETER_FRACTION); + float critical = 0f; + float interactive = 0f; + float visual = 0f; + for (Exclusion exclusion : exclusions) { + if (exclusion == null || !exclusion.isValid()) continue; + float penetration = circleRectPenetration( + centerX, centerY, radius, exclusion, clearance); + if (penetration <= 0f) continue; + float penalty = penetration * penetration; + if (exclusion.priority >= PRIORITY_CRITICAL) critical += penalty; + else if (exclusion.priority >= PRIORITY_INTERACTIVE) interactive += penalty; + else visual += penalty; + } + float normalizedX = (x - preferredX) / travelX; + float normalizedY = (y - preferredY) / travelY; + float distance = normalizedX * normalizedX + normalizedY * normalizedY; + return new Candidate(x, y, critical, interactive, visual, distance); + } + + private static float circleRectPenetration( + float centerX, + float centerY, + float radius, + Exclusion exclusion, + float clearance) { + float left = exclusion.left - clearance; + float top = exclusion.top - clearance; + float right = exclusion.right + clearance; + float bottom = exclusion.bottom + clearance; + float nearestX = clamp(centerX, left, right); + float nearestY = clamp(centerY, top, bottom); + float dx = centerX - nearestX; + float dy = centerY - nearestY; + float distance = (float) Math.sqrt(dx * dx + dy * dy); + return Math.max(0f, radius - distance); + } + + private static ArrayList samples( + float minimum, float maximum, float step, float preferred) { + ArrayList result = new ArrayList<>(); + addSample(result, minimum); + addSample(result, preferred); + if (maximum > minimum) { + for (float value = minimum + step; value < maximum; value += step) { + addSample(result, value); + } + addSample(result, maximum); + } + return result; + } + + private static void addSample(ArrayList samples, float value) { + for (float existing : samples) { + if (Math.abs(existing - value) < 0.25f) return; + } + samples.add(value); + } + + private static float finite(float value, float fallback) { + return Float.isFinite(value) ? value : fallback; + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + static final class Point { + final float x; + final float y; + + Point(float x, float y) { + this.x = x; + this.y = y; + } + } + + static final class Exclusion { + final float left; + final float top; + final float right; + final float bottom; + final int priority; + + Exclusion(float left, float top, float right, float bottom, int priority) { + this.left = Math.min(left, right); + this.top = Math.min(top, bottom); + this.right = Math.max(left, right); + this.bottom = Math.max(top, bottom); + this.priority = Math.max(PRIORITY_VISUAL, + Math.min(PRIORITY_CRITICAL, priority)); + } + + boolean isValid() { + return Float.isFinite(left) + && Float.isFinite(top) + && Float.isFinite(right) + && Float.isFinite(bottom) + && right > left + && bottom > top; + } + } + + private static final class Candidate { + private static final float EPSILON = 0.001f; + + final float x; + final float y; + final float critical; + final float interactive; + final float visual; + final float distance; + + Candidate( + float x, + float y, + float critical, + float interactive, + float visual, + float distance) { + this.x = x; + this.y = y; + this.critical = critical; + this.interactive = interactive; + this.visual = visual; + this.distance = distance; + } + + boolean isBetterThan(Candidate other) { + int criticalOrder = compare(critical, other.critical); + if (criticalOrder != 0) return criticalOrder < 0; + int interactiveOrder = compare(interactive, other.interactive); + if (interactiveOrder != 0) return interactiveOrder < 0; + int visualOrder = compare(visual, other.visual); + if (visualOrder != 0) return visualOrder < 0; + return distance + EPSILON < other.distance; + } + + private static int compare(float first, float second) { + if (Math.abs(first - second) <= EPSILON) return 0; + return first < second ? -1 : 1; + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotGlbModel.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotGlbModel.java new file mode 100644 index 0000000..d5fb1fb --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotGlbModel.java @@ -0,0 +1,819 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.opengl.GLES20; +import android.opengl.Matrix; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Loader for the skinned mascot character exported by {@code scripts/paimon_convert.py}. + * + *

It reads exactly the GLB subset that the checked-in Blender pipeline produces: one skinned + * mesh whose primitives use tightly packed float POSITION/NORMAL/TEXCOORD_0/WEIGHTS_0 plus + * unsigned-byte JOINTS_0, unsigned-short indices, sparse-or-dense POSITION morph targets, one + * skin with inverse bind matrices, and JPEG/PNG images embedded in the BIN chunk. Everything is + * validated defensively so a malformed asset fails at load rather than corrupting GL state.

+ */ +final class MascotGlbModel { + private static final int GLB_MAGIC = 0x46546c67; + private static final int GLB_VERSION = 2; + private static final int CHUNK_JSON = 0x4e4f534a; + private static final int CHUNK_BIN = 0x004e4942; + + private static final int COMPONENT_UNSIGNED_BYTE = 5121; + private static final int COMPONENT_UNSIGNED_SHORT = 5123; + private static final int COMPONENT_UNSIGNED_INT = 5125; + private static final int COMPONENT_FLOAT = 5126; + /** JOINTS_0 is exported as unsigned bytes, so indices 0..255 are representable. */ + private static final int MAX_SKIN_JOINTS = 256; + + /** Morph target slots baked by the converter, in {@code targetNames} order. */ + static final int MORPH_COUNT = 3; + static final String[] MORPH_NAMES = {"blink", "smile", "trouble"}; + + /** Floats per vertex in the static interleaved buffer: pos3 + normal3 + uv2 + weights4. */ + static final int STATIC_FLOATS = 12; + static final int STATIC_STRIDE_BYTES = STATIC_FLOATS * 4; + /** Floats per vertex in the morph delta buffer: three vec3 targets. */ + static final int MORPH_FLOATS = MORPH_COUNT * 3; + static final int MORPH_STRIDE_BYTES = MORPH_FLOATS * 4; + + static final class Joint { + final String name; + /** Index into {@link #joints} of the parent joint, or -1 for the skeleton root. */ + final int parent; + /** Rest local transform relative to the parent joint (root includes armature nodes). */ + final float[] restLocal = new float[16]; + final float[] inverseBind = new float[16]; + /** Rotation-only part of the rest world matrix; lets animation use world-space axes. */ + final float[] restWorldRotation = new float[16]; + + Joint(String name, int parent) { + this.name = name; + this.parent = parent; + } + } + + static final class Primitive { + final String materialName; + final int imageIndex; + final boolean blend; + final float baseAlpha; + final int vertexCount; + final float[] staticData; + final byte[] jointData; + final float[] morphData; + final short[] indices; + + private int staticVbo; + private int jointVbo; + private int morphVbo; + private int ibo; + + Primitive(String materialName, int imageIndex, boolean blend, float baseAlpha, + int vertexCount, float[] staticData, byte[] jointData, float[] morphData, + short[] indices) { + this.materialName = materialName; + this.imageIndex = imageIndex; + this.blend = blend; + this.baseAlpha = baseAlpha; + this.vertexCount = vertexCount; + this.staticData = staticData; + this.jointData = jointData; + this.morphData = morphData; + this.indices = indices; + } + + void upload() { + release(); + int[] ids = new int[4]; + GLES20.glGenBuffers(4, ids, 0); + staticVbo = ids[0]; + jointVbo = ids[1]; + morphVbo = ids[2]; + ibo = ids[3]; + + ByteBuffer staticBytes = ByteBuffer + .allocateDirect(staticData.length * 4) + .order(ByteOrder.nativeOrder()); + staticBytes.asFloatBuffer().put(staticData); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, staticVbo); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, staticData.length * 4, + staticBytes, GLES20.GL_STATIC_DRAW); + + ByteBuffer jointBytes = ByteBuffer + .allocateDirect(jointData.length) + .order(ByteOrder.nativeOrder()); + jointBytes.put(jointData).position(0); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, jointVbo); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, jointData.length, + jointBytes, GLES20.GL_STATIC_DRAW); + + ByteBuffer morphBytes = ByteBuffer + .allocateDirect(morphData.length * 4) + .order(ByteOrder.nativeOrder()); + morphBytes.asFloatBuffer().put(morphData); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, morphVbo); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, morphData.length * 4, + morphBytes, GLES20.GL_STATIC_DRAW); + + ByteBuffer indexBytes = ByteBuffer + .allocateDirect(indices.length * 2) + .order(ByteOrder.nativeOrder()); + indexBytes.asShortBuffer().put(indices); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, ibo); + GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indices.length * 2, + indexBytes, GLES20.GL_STATIC_DRAW); + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } + + void release() { + if (staticVbo != 0 || jointVbo != 0 || morphVbo != 0 || ibo != 0) { + int[] ids = {staticVbo, jointVbo, morphVbo, ibo}; + GLES20.glDeleteBuffers(4, ids, 0); + } + staticVbo = 0; + jointVbo = 0; + morphVbo = 0; + ibo = 0; + } + + void markContextLost() { + staticVbo = 0; + jointVbo = 0; + morphVbo = 0; + ibo = 0; + } + + int staticVbo() { + return staticVbo; + } + + int jointVbo() { + return jointVbo; + } + + int morphVbo() { + return morphVbo; + } + + int ibo() { + return ibo; + } + + int indexCount() { + return indices.length; + } + } + + private static final class BufferView { + final int offset; + final int length; + + BufferView(int offset, int length) { + this.offset = offset; + this.length = length; + } + } + + private static final class Accessor { + final int bufferView; + final int byteOffset; + final int componentType; + final int count; + final int components; + final JSONObject sparse; + + Accessor(int bufferView, int byteOffset, int componentType, int count, + int components, JSONObject sparse) { + this.bufferView = bufferView; + this.byteOffset = byteOffset; + this.componentType = componentType; + this.count = count; + this.components = components; + this.sparse = sparse; + } + } + + final Joint[] joints; + final Primitive[] primitives; + final byte[][] images; + final float[] boundsMin = {Float.MAX_VALUE, Float.MAX_VALUE, Float.MAX_VALUE}; + final float[] boundsMax = {-Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE}; + private final Map jointIndexByName = new HashMap<>(); + + private MascotGlbModel(Joint[] joints, Primitive[] primitives, byte[][] images) { + this.joints = joints; + this.primitives = primitives; + this.images = images; + for (int i = 0; i < joints.length; i++) { + jointIndexByName.put(joints[i].name, i); + } + } + + /** Returns the joint index for an exported bone name, or throws if it is absent. */ + int requireJoint(String name) { + Integer index = jointIndexByName.get(name); + if (index == null) { + throw new IllegalStateException("Mascot skeleton is missing joint: " + name); + } + return index; + } + + /** Returns the model joint index, or {@code -1} when the name is absent. */ + int findJoint(String name) { + Integer index = jointIndexByName.get(name); + return index == null ? -1 : index; + } + + void upload() { + for (Primitive primitive : primitives) primitive.upload(); + } + + void release() { + for (Primitive primitive : primitives) primitive.release(); + } + + void markContextLost() { + for (Primitive primitive : primitives) primitive.markContextLost(); + } + + static MascotGlbModel load(Context context, String assetPath) + throws IOException, JSONException { + if (context == null) throw new IllegalArgumentException("context == null"); + byte[] bytes; + try (InputStream input = context.getAssets().open(assetPath)) { + bytes = readAll(input); + } + return parse(bytes); + } + + static MascotGlbModel parse(byte[] bytes) throws IOException, JSONException { + if (bytes == null || bytes.length < 20) throw new IOException("Mascot GLB is truncated"); + ByteBuffer glb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + if (glb.getInt() != GLB_MAGIC) throw new IOException("Not a glTF binary file"); + if (glb.getInt() != GLB_VERSION) throw new IOException("Unsupported GLB version"); + if (glb.getInt() != bytes.length) throw new IOException("GLB length mismatch"); + + byte[] jsonBytes = null; + byte[] binBytes = null; + while (glb.remaining() >= 8) { + int chunkLength = glb.getInt(); + int chunkType = glb.getInt(); + if (chunkLength < 0 || chunkLength > glb.remaining()) { + throw new IOException("GLB chunk exceeds file bounds"); + } + byte[] chunk = new byte[chunkLength]; + glb.get(chunk); + if (chunkType == CHUNK_JSON) jsonBytes = chunk; + else if (chunkType == CHUNK_BIN) binBytes = chunk; + } + if (jsonBytes == null || binBytes == null) { + throw new IOException("GLB must contain JSON and BIN chunks"); + } + + JSONObject json = new JSONObject( + new String(jsonBytes, StandardCharsets.UTF_8).replace("\u0000", "").trim()); + ByteBuffer bin = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN); + + BufferView[] views = parseBufferViews(json.optJSONArray("bufferViews"), binBytes.length); + Accessor[] accessors = parseAccessors(json.optJSONArray("accessors"), views); + byte[][] images = parseImages(json.optJSONArray("images"), views, binBytes); + int[] imageOfTexture = parseTextures(json.optJSONArray("textures"), images.length); + Joint[] joints = parseSkeleton(json, accessors, views, bin); + Primitive[] primitives = parsePrimitives( + json, accessors, views, bin, imageOfTexture, joints.length); + + MascotGlbModel model = new MascotGlbModel(joints, primitives, images); + model.computeBounds(); + return model; + } + + private void computeBounds() { + for (Primitive primitive : primitives) { + for (int v = 0; v < primitive.vertexCount; v++) { + int base = v * STATIC_FLOATS; + for (int axis = 0; axis < 3; axis++) { + float value = primitive.staticData[base + axis]; + if (value < boundsMin[axis]) boundsMin[axis] = value; + if (value > boundsMax[axis]) boundsMax[axis] = value; + } + } + } + if (boundsMin[0] > boundsMax[0]) { + throw new IllegalStateException("Mascot model contains no vertices"); + } + } + + private static Joint[] parseSkeleton(JSONObject json, Accessor[] accessors, + BufferView[] views, ByteBuffer bin) throws IOException, JSONException { + JSONArray jsonNodes = json.optJSONArray("nodes"); + JSONArray jsonSkins = json.optJSONArray("skins"); + if (jsonNodes == null || jsonSkins == null || jsonSkins.length() != 1) { + throw new IOException("Mascot GLB must contain nodes and exactly one skin"); + } + JSONObject skin = jsonSkins.getJSONObject(0); + JSONArray jointNodes = skin.getJSONArray("joints"); + int jointCount = jointNodes.length(); + if (jointCount < 1 || jointCount > MAX_SKIN_JOINTS) { + throw new IOException("Unsupported mascot joint count: " + jointCount); + } + + int nodeCount = jsonNodes.length(); + float[][] nodeLocal = new float[nodeCount][16]; + int[] nodeParent = new int[nodeCount]; + String[] nodeName = new String[nodeCount]; + java.util.Arrays.fill(nodeParent, -1); + for (int i = 0; i < nodeCount; i++) { + JSONObject node = jsonNodes.getJSONObject(i); + nodeName[i] = node.optString("name", "node_" + i); + nodeLocal[i] = parseNodeTransform(node); + JSONArray children = node.optJSONArray("children"); + if (children == null) continue; + for (int c = 0; c < children.length(); c++) { + int child = children.getInt(c); + if (child < 0 || child >= nodeCount) { + throw new IOException("GLB child node outside node table"); + } + nodeParent[child] = i; + } + } + + int[] jointOfNode = new int[nodeCount]; + java.util.Arrays.fill(jointOfNode, -1); + for (int j = 0; j < jointCount; j++) { + int node = jointNodes.getInt(j); + if (node < 0 || node >= nodeCount) throw new IOException("Skin joint outside nodes"); + jointOfNode[node] = j; + } + + Joint[] joints = new Joint[jointCount]; + float[][] restWorld = new float[jointCount][16]; + for (int j = 0; j < jointCount; j++) { + int node = jointNodes.getInt(j); + // Fold any non-joint ancestor chain (the armature object node) into the root local. + float[] local = new float[16]; + System.arraycopy(nodeLocal[node], 0, local, 0, 16); + int ancestor = nodeParent[node]; + int parentJoint = -1; + while (ancestor >= 0) { + if (jointOfNode[ancestor] >= 0) { + parentJoint = jointOfNode[ancestor]; + break; + } + float[] folded = new float[16]; + Matrix.multiplyMM(folded, 0, nodeLocal[ancestor], 0, local, 0); + local = folded; + ancestor = nodeParent[ancestor]; + } + if (parentJoint >= j && parentJoint != -1) { + throw new IOException("Mascot skin joints are not parent-before-child ordered"); + } + Joint joint = new Joint(nodeName[node], parentJoint); + System.arraycopy(local, 0, joint.restLocal, 0, 16); + joints[j] = joint; + if (parentJoint < 0) { + System.arraycopy(local, 0, restWorld[j], 0, 16); + } else { + Matrix.multiplyMM(restWorld[j], 0, restWorld[parentJoint], 0, local, 0); + } + extractRotation(restWorld[j], joint.restWorldRotation); + } + + int ibmIndex = skin.optInt("inverseBindMatrices", -1); + if (ibmIndex < 0 || ibmIndex >= accessors.length) { + throw new IOException("Mascot skin has no inverse bind matrices"); + } + Accessor ibm = accessors[ibmIndex]; + if (ibm.componentType != COMPONENT_FLOAT || ibm.components != 16 + || ibm.count < jointCount) { + throw new IOException("Mascot inverse bind matrices are malformed"); + } + int base = views[ibm.bufferView].offset + ibm.byteOffset; + for (int j = 0; j < jointCount; j++) { + for (int k = 0; k < 16; k++) { + joints[j].inverseBind[k] = bin.getFloat(base + (j * 16 + k) * 4); + } + } + return joints; + } + + private static Primitive[] parsePrimitives(JSONObject json, Accessor[] accessors, + BufferView[] views, ByteBuffer bin, int[] imageOfTexture, int jointCount) + throws IOException, JSONException { + JSONArray jsonMeshes = json.optJSONArray("meshes"); + if (jsonMeshes == null || jsonMeshes.length() != 1) { + throw new IOException("Mascot GLB must contain exactly one mesh"); + } + JSONArray jsonMaterials = json.optJSONArray("materials"); + if (jsonMaterials == null) throw new IOException("Mascot GLB has no materials"); + + JSONObject mesh = jsonMeshes.getJSONObject(0); + JSONArray jsonPrimitives = mesh.getJSONArray("primitives"); + int[] morphSlotOfTarget = morphSlots(mesh); + + Primitive[] result = new Primitive[jsonPrimitives.length()]; + for (int p = 0; p < result.length; p++) { + JSONObject primitive = jsonPrimitives.getJSONObject(p); + if (primitive.optInt("mode", 4) != 4) { + throw new IOException("Mascot primitives must be triangles"); + } + JSONObject attributes = primitive.getJSONObject("attributes"); + Accessor positions = accessor(accessors, attributes, "POSITION"); + Accessor normals = accessor(accessors, attributes, "NORMAL"); + Accessor uvs = accessor(accessors, attributes, "TEXCOORD_0"); + Accessor jointsAttr = accessor(accessors, attributes, "JOINTS_0"); + Accessor weights = accessor(accessors, attributes, "WEIGHTS_0"); + + int vertexCount = positions.count; + if (vertexCount <= 0 || vertexCount >= 65_535) { + throw new IOException("Mascot primitive exceeds GLES2 vertex budget"); + } + requireLayout(positions, COMPONENT_FLOAT, 3, vertexCount); + requireLayout(normals, COMPONENT_FLOAT, 3, vertexCount); + requireLayout(uvs, COMPONENT_FLOAT, 2, vertexCount); + requireLayout(jointsAttr, COMPONENT_UNSIGNED_BYTE, 4, vertexCount); + requireLayout(weights, COMPONENT_FLOAT, 4, vertexCount); + + float[] staticData = new float[vertexCount * STATIC_FLOATS]; + readFloats(bin, views, positions, staticData, 0, STATIC_FLOATS); + readFloats(bin, views, normals, staticData, 3, STATIC_FLOATS); + readFloats(bin, views, uvs, staticData, 6, STATIC_FLOATS); + readFloats(bin, views, weights, staticData, 8, STATIC_FLOATS); + + byte[] jointData = new byte[vertexCount * 4]; + int jointBase = views[jointsAttr.bufferView].offset + jointsAttr.byteOffset; + for (int i = 0; i < jointData.length; i++) { + byte joint = bin.get(jointBase + i); + if ((joint & 0xff) >= jointCount) { + throw new IOException("Mascot vertex references absent joint"); + } + jointData[i] = joint; + } + + float[] morphData = new float[vertexCount * MORPH_FLOATS]; + JSONArray targets = primitive.optJSONArray("targets"); + if (targets != null) { + for (int t = 0; t < targets.length(); t++) { + int slot = t < morphSlotOfTarget.length ? morphSlotOfTarget[t] : -1; + if (slot < 0) continue; + JSONObject target = targets.getJSONObject(t); + if (!target.has("POSITION")) continue; + Accessor delta = accessors[target.getInt("POSITION")]; + if (delta.componentType != COMPONENT_FLOAT || delta.components != 3 + || delta.count != vertexCount) { + throw new IOException("Mascot morph target is malformed"); + } + readMorphDeltas(bin, views, delta, morphData, slot * 3); + } + } + + Accessor indexAccessor = accessors[primitive.getInt("indices")]; + if (indexAccessor.components != 1 || indexAccessor.count % 3 != 0) { + throw new IOException("Mascot indices must be triangle scalars"); + } + short[] indices = readIndices(bin, views, indexAccessor, vertexCount); + + int materialIndex = primitive.optInt("material", -1); + if (materialIndex < 0 || materialIndex >= jsonMaterials.length()) { + throw new IOException("Mascot primitive has no material"); + } + JSONObject material = jsonMaterials.getJSONObject(materialIndex); + String materialName = material.optString("name", "material_" + materialIndex); + int textureIndex = -1; + JSONObject pbr = material.optJSONObject("pbrMetallicRoughness"); + if (pbr != null) { + JSONObject baseTexture = pbr.optJSONObject("baseColorTexture"); + if (baseTexture != null) textureIndex = baseTexture.optInt("index", -1); + } + if (textureIndex < 0 || textureIndex >= imageOfTexture.length) { + throw new IOException("Mascot material lacks a base color texture: " + + materialName); + } + // The semi-transparent cape and the face-overlay expression sheet are decided by + // material identity: the Blender exporter's alphaMode is advisory only. + boolean blend = "expression".equals(materialName) || "cape".equals(materialName) + || "BLEND".equals(material.optString("alphaMode", "OPAQUE")); + float baseAlpha = "cape".equals(materialName) ? 0.86f : 1f; + + result[p] = new Primitive(materialName, imageOfTexture[textureIndex], blend, + baseAlpha, vertexCount, staticData, jointData, morphData, indices); + } + return result; + } + + /** Maps GLB target order to the fixed blink/smile/trouble slots via targetNames. */ + private static int[] morphSlots(JSONObject mesh) { + JSONObject extras = mesh.optJSONObject("extras"); + JSONArray names = extras == null ? null : extras.optJSONArray("targetNames"); + int targetCount = names == null ? MORPH_COUNT : names.length(); + int[] slots = new int[targetCount]; + for (int t = 0; t < targetCount; t++) { + slots[t] = -1; + String name = names == null + ? (t < MORPH_NAMES.length ? MORPH_NAMES[t] : "") + : names.optString(t, ""); + for (int s = 0; s < MORPH_NAMES.length; s++) { + if (MORPH_NAMES[s].equals(name)) { + slots[t] = s; + break; + } + } + } + return slots; + } + + private static void readMorphDeltas(ByteBuffer bin, BufferView[] views, Accessor delta, + float[] destination, int destinationOffset) throws IOException { + if (delta.sparse == null) { + if (delta.bufferView >= 0) { + readFloats(bin, views, delta, destination, destinationOffset, MORPH_FLOATS); + } + return; + } + try { + JSONObject sparse = delta.sparse; + int count = sparse.getInt("count"); + JSONObject indicesJson = sparse.getJSONObject("indices"); + JSONObject valuesJson = sparse.getJSONObject("values"); + int indexComponent = indicesJson.getInt("componentType"); + BufferView indexView = views[indicesJson.getInt("bufferView")]; + BufferView valueView = views[valuesJson.getInt("bufferView")]; + int indexOffset = indexView.offset + indicesJson.optInt("byteOffset", 0); + int valueOffset = valueView.offset + valuesJson.optInt("byteOffset", 0); + for (int i = 0; i < count; i++) { + int vertex; + switch (indexComponent) { + case COMPONENT_UNSIGNED_BYTE: + vertex = bin.get(indexOffset + i) & 0xff; + break; + case COMPONENT_UNSIGNED_SHORT: + vertex = bin.getShort(indexOffset + i * 2) & 0xffff; + break; + case COMPONENT_UNSIGNED_INT: + vertex = bin.getInt(indexOffset + i * 4); + break; + default: + throw new IOException("Unsupported sparse index component"); + } + if (vertex < 0 || vertex >= delta.count) { + throw new IOException("Sparse morph index outside vertex range"); + } + int target = vertex * MORPH_FLOATS + destinationOffset; + destination[target] = bin.getFloat(valueOffset + i * 12); + destination[target + 1] = bin.getFloat(valueOffset + i * 12 + 4); + destination[target + 2] = bin.getFloat(valueOffset + i * 12 + 8); + } + } catch (JSONException failure) { + throw new IOException("Malformed sparse morph target", failure); + } + } + + private static short[] readIndices(ByteBuffer bin, BufferView[] views, + Accessor accessor, int vertexCount) throws IOException { + int base = views[accessor.bufferView].offset + accessor.byteOffset; + short[] result = new short[accessor.count]; + for (int i = 0; i < result.length; i++) { + int index; + switch (accessor.componentType) { + case COMPONENT_UNSIGNED_BYTE: + index = bin.get(base + i) & 0xff; + break; + case COMPONENT_UNSIGNED_SHORT: + index = bin.getShort(base + i * 2) & 0xffff; + break; + case COMPONENT_UNSIGNED_INT: + index = bin.getInt(base + i * 4); + break; + default: + throw new IOException("Unsupported index component type"); + } + if (index < 0 || index >= vertexCount) { + throw new IOException("Mascot index outside vertex range"); + } + result[i] = (short) index; + } + return result; + } + + private static void readFloats(ByteBuffer bin, BufferView[] views, Accessor accessor, + float[] destination, int destinationOffset, int destinationStride) { + int base = views[accessor.bufferView].offset + accessor.byteOffset; + for (int i = 0; i < accessor.count; i++) { + int source = base + i * accessor.components * 4; + int target = i * destinationStride + destinationOffset; + for (int c = 0; c < accessor.components; c++) { + destination[target + c] = bin.getFloat(source + c * 4); + } + } + } + + private static void requireLayout(Accessor accessor, int componentType, int components, + int count) throws IOException { + if (accessor.componentType != componentType || accessor.components != components + || accessor.count != count || accessor.bufferView < 0) { + throw new IOException("Mascot attribute layout is unsupported"); + } + } + + private static Accessor accessor(Accessor[] accessors, JSONObject attributes, String key) + throws IOException, JSONException { + if (!attributes.has(key)) throw new IOException("Mascot primitive lacks " + key); + int index = attributes.getInt(key); + if (index < 0 || index >= accessors.length) throw new IOException("Absent accessor"); + return accessors[index]; + } + + private static byte[][] parseImages(JSONArray jsonImages, BufferView[] views, byte[] bin) + throws IOException, JSONException { + if (jsonImages == null || jsonImages.length() == 0) { + throw new IOException("Mascot GLB contains no images"); + } + byte[][] result = new byte[jsonImages.length()][]; + for (int i = 0; i < result.length; i++) { + JSONObject image = jsonImages.getJSONObject(i); + int viewIndex = image.optInt("bufferView", -1); + if (viewIndex < 0 || viewIndex >= views.length) { + throw new IOException("Mascot image is not embedded"); + } + BufferView view = views[viewIndex]; + byte[] data = new byte[view.length]; + System.arraycopy(bin, view.offset, data, 0, view.length); + result[i] = data; + } + return result; + } + + private static int[] parseTextures(JSONArray jsonTextures, int imageCount) + throws IOException, JSONException { + if (jsonTextures == null) throw new IOException("Mascot GLB contains no textures"); + int[] result = new int[jsonTextures.length()]; + for (int i = 0; i < result.length; i++) { + int source = jsonTextures.getJSONObject(i).optInt("source", -1); + if (source < 0 || source >= imageCount) { + throw new IOException("Mascot texture references absent image"); + } + result[i] = source; + } + return result; + } + + private static BufferView[] parseBufferViews(JSONArray jsonViews, int binLength) + throws IOException, JSONException { + if (jsonViews == null) return new BufferView[0]; + BufferView[] result = new BufferView[jsonViews.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject view = jsonViews.getJSONObject(i); + int offset = view.optInt("byteOffset", 0); + int length = view.getInt("byteLength"); + if (view.optInt("byteStride", 0) != 0) { + throw new IOException("Mascot GLB must use tightly packed buffer views"); + } + if (offset < 0 || length < 0 || (long) offset + length > binLength) { + throw new IOException("Mascot bufferView outside embedded buffer"); + } + result[i] = new BufferView(offset, length); + } + return result; + } + + private static Accessor[] parseAccessors(JSONArray jsonAccessors, BufferView[] views) + throws IOException, JSONException { + if (jsonAccessors == null) return new Accessor[0]; + Accessor[] result = new Accessor[jsonAccessors.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject accessor = jsonAccessors.getJSONObject(i); + int view = accessor.optInt("bufferView", -1); + int count = accessor.getInt("count"); + int components = componentsForType(accessor.getString("type")); + if (count <= 0 || (view >= 0 && view >= views.length)) { + throw new IOException("Invalid mascot accessor"); + } + result[i] = new Accessor(view, accessor.optInt("byteOffset", 0), + accessor.getInt("componentType"), count, components, + accessor.optJSONObject("sparse")); + } + return result; + } + + private static float[] parseNodeTransform(JSONObject node) throws JSONException { + float[] result = new float[16]; + JSONArray matrix = node.optJSONArray("matrix"); + if (matrix != null && matrix.length() == 16) { + for (int i = 0; i < 16; i++) result[i] = (float) matrix.getDouble(i); + return result; + } + float tx = 0f; + float ty = 0f; + float tz = 0f; + JSONArray translation = node.optJSONArray("translation"); + if (translation != null && translation.length() == 3) { + tx = (float) translation.getDouble(0); + ty = (float) translation.getDouble(1); + tz = (float) translation.getDouble(2); + } + float qx = 0f; + float qy = 0f; + float qz = 0f; + float qw = 1f; + JSONArray rotation = node.optJSONArray("rotation"); + if (rotation != null && rotation.length() == 4) { + qx = (float) rotation.getDouble(0); + qy = (float) rotation.getDouble(1); + qz = (float) rotation.getDouble(2); + qw = (float) rotation.getDouble(3); + float length = (float) Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw); + if (length > 0.000001f) { + qx /= length; + qy /= length; + qz /= length; + qw /= length; + } else { + qx = qy = qz = 0f; + qw = 1f; + } + } + float sx = 1f; + float sy = 1f; + float sz = 1f; + JSONArray scale = node.optJSONArray("scale"); + if (scale != null && scale.length() == 3) { + sx = (float) scale.getDouble(0); + sy = (float) scale.getDouble(1); + sz = (float) scale.getDouble(2); + } + float xx = qx * qx; + float yy = qy * qy; + float zz = qz * qz; + float xy = qx * qy; + float xz = qx * qz; + float yz = qy * qz; + float wx = qw * qx; + float wy = qw * qy; + float wz = qw * qz; + result[0] = (1f - 2f * (yy + zz)) * sx; + result[1] = (2f * (xy + wz)) * sx; + result[2] = (2f * (xz - wy)) * sx; + result[4] = (2f * (xy - wz)) * sy; + result[5] = (1f - 2f * (xx + zz)) * sy; + result[6] = (2f * (yz + wx)) * sy; + result[8] = (2f * (xz + wy)) * sz; + result[9] = (2f * (yz - wx)) * sz; + result[10] = (1f - 2f * (xx + yy)) * sz; + result[12] = tx; + result[13] = ty; + result[14] = tz; + result[15] = 1f; + return result; + } + + /** Copies the normalized rotation columns of an affine matrix into a 4x4 rotation. */ + private static void extractRotation(float[] source, float[] destination) { + Matrix.setIdentityM(destination, 0); + for (int column = 0; column < 3; column++) { + float x = source[column * 4]; + float y = source[column * 4 + 1]; + float z = source[column * 4 + 2]; + float length = (float) Math.sqrt(x * x + y * y + z * z); + if (length < 0.000001f) length = 1f; + destination[column * 4] = x / length; + destination[column * 4 + 1] = y / length; + destination[column * 4 + 2] = z / length; + } + } + + private static int componentsForType(String type) throws IOException { + switch (type) { + case "SCALAR": return 1; + case "VEC2": return 2; + case "VEC3": return 3; + case "VEC4": return 4; + case "MAT4": return 16; + default: throw new IOException("Unknown accessor type: " + type); + } + } + + private static byte[] readAll(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(1024 * 1024); + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) output.write(buffer, 0, read); + } + return output.toByteArray(); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotHands.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotHands.java new file mode 100644 index 0000000..0161726 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotHands.java @@ -0,0 +1,117 @@ +package com.seeker.n7cockpit; + +/** + * Hand shape library for the mascot. + * + *

The previous 39-joint rig dropped every finger bone, so both hands were frozen in the PMX + * bind pose with all five fingers splayed — the single most artificial thing about the character + * at any size. The rig now keeps one bone per finger, and this class turns that into readable + * gestures: a relaxed curl at idle, a fist, a pointing index finger, a V sign, and the pinch and + * snap shapes the cockpit actions use.

+ * + *

Curl angles are authored per finger in the 0..1 range and multiplied by {@link #FULL_CURL}. + * Because each finger is one merged bone carrying the weights of all three phalanges, a full fold + * needs less rotation than a real proximal joint would.

+ */ +final class MascotHands { + /** Degrees a finger rotates at curl = 1. Tuned so a fist reads without collapsing the mesh. */ + private static final float FULL_CURL = 78f; + private static final float FULL_THUMB_CURL = 52f; + /** Degrees of fan between the index and little finger at spread = 1. */ + private static final float FULL_SPREAD = 13f; + + /** Ordered thumb, index, middle, ring, little. */ + enum Shape { + /** Loose natural curl; the resting shape for every idle and transport pose. */ + RELAX(0.30f, new float[]{0.26f, 0.30f, 0.34f, 0.40f, 0.46f}, 0.15f), + /** Flat palm, used for pushing air, waving and presenting. */ + OPEN(0.10f, new float[]{0.06f, 0.03f, 0.03f, 0.05f, 0.08f}, 0.60f), + /** Closed fist for determination and error beats. */ + FIST(0.85f, new float[]{0.92f, 1.00f, 1.00f, 1.00f, 1.00f}, 0.00f), + /** Index extended, the rest folded: pointing at a control or poking a panel. */ + POINT(0.72f, new float[]{0.80f, 0.02f, 0.95f, 1.00f, 1.00f}, 0.05f), + /** Index and middle extended in a V. */ + VICTORY(0.80f, new float[]{0.85f, 0.02f, 0.05f, 1.00f, 1.00f}, 0.55f), + /** Thumb and middle finger meeting, ready to snap. */ + PINCH(0.55f, new float[]{0.62f, 0.30f, 0.55f, 0.62f, 0.68f}, 0.05f), + /** Just after the snap: middle flicks past the thumb, the others spring open. */ + SNAP(0.30f, new float[]{0.20f, 0.16f, 0.86f, 0.30f, 0.24f}, 0.45f), + /** Curled as if holding something, for the climate "cupping warm air" beat. */ + CUP(0.45f, new float[]{0.48f, 0.52f, 0.55f, 0.58f, 0.62f}, 0.30f); + + final float thumb; + final float[] curls; + final float spread; + + Shape(float thumb, float[] curls, float spread) { + this.thumb = thumb; + this.curls = curls; + this.spread = spread; + } + } + + private final MascotRig rig; + /** Live per-finger curl, smoothed toward the requested shape so hands never snap. */ + private final float[] currentL = new float[5]; + private final float[] currentR = new float[5]; + private float spreadL = Shape.RELAX.spread; + private float spreadR = Shape.RELAX.spread; + + MascotHands(MascotRig rig) { + this.rig = rig; + System.arraycopy(Shape.RELAX.curls, 0, currentL, 0, 5); + System.arraycopy(Shape.RELAX.curls, 0, currentR, 0, 5); + currentL[0] = Shape.RELAX.thumb; + currentR[0] = Shape.RELAX.thumb; + } + + /** + * Advances one hand toward a target shape and writes the resulting finger rotations. + * + * @param ripple 0..1 travelling wave added across the fingers; a small amount keeps an open + * hand alive, a large amount reads as fluttering fingers during a wave + * @param ripplePhase phase of that wave in radians + */ + void apply(MascotPose pose, boolean left, Shape shape, float blend, + float ripple, float ripplePhase) { + float[] current = left ? currentL : currentR; + float k = clamp01(blend); + current[0] += (shape.thumb - current[0]) * k; + for (int f = 1; f < 5; f++) { + current[f] += (shape.curls[f] - current[f]) * k; + } + if (left) spreadL += (shape.spread - spreadL) * k; + else spreadR += (shape.spread - spreadR) * k; + float spread = left ? spreadL : spreadR; + + int[] slots = left ? MascotPose.FINGERS_L : MascotPose.FINGERS_R; + float[] curlAxis = rig.fingerCurlAxis(left); + float[] thumbAxis = rig.thumbCurlAxis(left); + float[] spreadAxis = rig.fingerSpreadAxis(left); + + // Thumb: its own opposition axis. + rig.setWorldAxis(pose, slots[0], thumbAxis, current[0] * FULL_THUMB_CURL); + + for (int f = 1; f < 5; f++) { + // Each finger lags the previous one slightly, which is what makes a moving hand read + // as flesh rather than as one rigid paddle. + float wave = ripple <= 0f ? 0f + : (float) Math.sin(ripplePhase - f * 0.55f) * ripple * 12f; + float curl = current[f] * FULL_CURL + wave; + rig.setWorldAxis(pose, slots[f], curlAxis, curl); + // Fan the fingers apart: index one way, little finger the other. + float fan = (f - 2.5f) / 1.5f * spread * FULL_SPREAD; + if (fan != 0f) rig.addWorldAxis(pose, slots[f], spreadAxis, fan); + } + } + + /** Applies the same shape to both hands. */ + void apply(MascotPose pose, Shape shape, float blend) { + apply(pose, true, shape, blend, 0f, 0f); + apply(pose, false, shape, blend, 0f, 0f); + } + + private static float clamp01(float value) { + return value < 0f ? 0f : (value > 1f ? 1f : value); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotInteractionMapper.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotInteractionMapper.java new file mode 100644 index 0000000..7495417 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotInteractionMapper.java @@ -0,0 +1,132 @@ +package com.seeker.n7cockpit; + +/** + * Shared, host-independent interpretation of mascot touches. + * + *

The in-app and system-overlay hosts deliberately use the same normalized hit map and drag + * hysteresis. That keeps interaction behavior identical at every display density and prevents a + * near-diagonal drag from repeatedly restarting animation cross-fades.

+ */ +final class MascotInteractionMapper { + private static final float HEAD_END = 0.36f; + private static final float FEET_START = 0.72f; + private static final float LEFT_HAND_END = 0.38f; + private static final float RIGHT_HAND_START = 0.62f; + + /** Ratio required before a drag is considered clearly aligned to one axis. */ + private static final float INITIAL_AXIS_RATIO = 1.18f; + /** A larger ratio is required to leave an established axis. */ + private static final float AXIS_SWITCH_RATIO = 1.34f; + private static final float REVERSAL_RATIO = 1.05f; + private static final float MOTION_EPSILON = 0.0001f; + + enum TapRegion { + HEAD, + BODY, + LEFT_HAND, + RIGHT_HAND, + FEET + } + + enum DragDirection { + NONE, + LEFT, + RIGHT, + UP, + DOWN + } + + private MascotInteractionMapper() {} + + /** Maps local view coordinates to a stable body region, clamping coordinates safely. */ + static TapRegion mapTap(float x, float y, float width, float height) { + if (!Float.isFinite(width) || !Float.isFinite(height) || width <= 0f || height <= 0f) { + return TapRegion.BODY; + } + float normalizedX = unit(Float.isFinite(x) ? x / width : 0.5f); + float normalizedY = unit(Float.isFinite(y) ? y / height : 0.5f); + + if (normalizedY < HEAD_END) return TapRegion.HEAD; + if (normalizedY > FEET_START) return TapRegion.FEET; + if (normalizedX < LEFT_HAND_END) return TapRegion.LEFT_HAND; + if (normalizedX > RIGHT_HAND_START) return TapRegion.RIGHT_HAND; + return TapRegion.BODY; + } + + /** + * Resolves a cumulative drag vector while retaining the previous direction near diagonals. + * A clear reversal on the established axis is still accepted immediately. + */ + static DragDirection resolveDrag( + float deltaX, float deltaY, DragDirection previousDirection) { + DragDirection previous = previousDirection == null + ? DragDirection.NONE : previousDirection; + if (!Float.isFinite(deltaX) || !Float.isFinite(deltaY)) return previous; + + float absX = Math.abs(deltaX); + float absY = Math.abs(deltaY); + if (absX <= MOTION_EPSILON && absY <= MOTION_EPSILON) return previous; + + DragDirection horizontal = deltaX < 0f ? DragDirection.LEFT : DragDirection.RIGHT; + DragDirection vertical = deltaY < 0f ? DragDirection.UP : DragDirection.DOWN; + if (previous == DragDirection.NONE) { + if (absX > absY * INITIAL_AXIS_RATIO) return horizontal; + if (absY > absX * INITIAL_AXIS_RATIO) return vertical; + return absX >= absY ? horizontal : vertical; + } + + if (isHorizontal(previous)) { + if (absY > absX * AXIS_SWITCH_RATIO) return vertical; + if (horizontal != previous && absX > absY * REVERSAL_RATIO) return horizontal; + return previous; + } + + if (absX > absY * AXIS_SWITCH_RATIO) return horizontal; + if (vertical != previous && absY > absX * REVERSAL_RATIO) return vertical; + return previous; + } + + static MascotActionBus.Action actionForTap(TapRegion region) { + TapRegion safe = region == null ? TapRegion.BODY : region; + switch (safe) { + case HEAD: return MascotActionBus.Action.TAP_HEAD; + case LEFT_HAND: return MascotActionBus.Action.TAP_LEFT_HAND; + case RIGHT_HAND: return MascotActionBus.Action.TAP_RIGHT_HAND; + case FEET: return MascotActionBus.Action.TAP_FEET; + case BODY: + default: return MascotActionBus.Action.TAP_BODY; + } + } + + static long durationForTap(TapRegion region) { + return MascotActionTiming.directTouchMs(region); + } + + static MascotActionBus.Action actionForDrag(DragDirection direction) { + DragDirection safe = direction == null ? DragDirection.NONE : direction; + switch (safe) { + case LEFT: return MascotActionBus.Action.DRAG_LEFT; + case RIGHT: return MascotActionBus.Action.DRAG_RIGHT; + case UP: return MascotActionBus.Action.DRAG_UP; + case DOWN: return MascotActionBus.Action.DRAG_DOWN; + case NONE: + default: return MascotActionBus.Action.DRAG; + } + } + + static boolean isDragAction(MascotActionBus.Action action) { + return action == MascotActionBus.Action.DRAG + || action == MascotActionBus.Action.DRAG_LEFT + || action == MascotActionBus.Action.DRAG_RIGHT + || action == MascotActionBus.Action.DRAG_UP + || action == MascotActionBus.Action.DRAG_DOWN; + } + + private static boolean isHorizontal(DragDirection direction) { + return direction == DragDirection.LEFT || direction == DragDirection.RIGHT; + } + + private static float unit(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotMusicService.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotMusicService.java new file mode 100644 index 0000000..6d03a43 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotMusicService.java @@ -0,0 +1,267 @@ +package com.seeker.n7cockpit; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; + +/** + * Owns the generated test melody and its PCM analyzer independently of {@link MainActivity}. + * + *

This service intentionally analyzes only the PCM generated by {@link AudioReactivePlayer}. + * It does not capture another application's audio output.

+ */ +public final class MascotMusicService extends Service + implements AudioReactivePlayer.PlaybackListener { + static final String ACTION_STATE_CHANGED = + "com.seeker.n7cockpit.action.MASCOT_MUSIC_STATE_CHANGED"; + static final String EXTRA_PLAYING = "playing"; + static final String EXTRA_REASON = "reason"; + static final String REASON_PLAYING = "playing"; + static final String REASON_USER_STOP = "user_stop"; + static final String REASON_AUDIO_FOCUS = "audio_focus_loss"; + static final String REASON_START_FAILED = "start_failed"; + static final String REASON_SERVICE_DESTROYED = "service_destroyed"; + + private static final String ACTION_PLAY = + "com.seeker.n7cockpit.action.PLAY_MASCOT_TEST_MUSIC"; + private static final String ACTION_STOP = + "com.seeker.n7cockpit.action.STOP_MASCOT_TEST_MUSIC"; + private static final String CHANNEL_ID = "mascot_test_music"; + private static final int NOTIFICATION_ID = 0x4E37; + private static final int STATE_STOPPED = 0; + private static final int STATE_STARTING = 1; + private static final int STATE_PLAYING = 2; + + private static volatile int playbackState = STATE_STOPPED; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private AudioReactivePlayer audioPlayer; + private boolean stopping; + private int latestStartId; + + /** Sends an idempotent play command. False means Android rejected service startup. */ + public static boolean requestPlay(Context context) { + if (playbackState != STATE_STOPPED) return true; + playbackState = STATE_STARTING; + try { + startCommand(context, ACTION_PLAY, true); + return true; + } catch (RuntimeException startupFailure) { + playbackState = STATE_STOPPED; + return false; + } + } + + /** Sends an idempotent stop command. False means Android rejected command delivery. */ + public static boolean requestStop(Context context) { + if (playbackState == STATE_STOPPED) return true; + try { + startCommand(context, ACTION_STOP, false); + return true; + } catch (RuntimeException startupFailure) { + return false; + } + } + + public static boolean isPlaying() { + return playbackState == STATE_PLAYING; + } + + public static boolean isActive() { + return playbackState != STATE_STOPPED; + } + + private static void startCommand(Context context, String action, boolean foreground) { + Context applicationContext = context.getApplicationContext(); + Intent intent = new Intent(applicationContext, MascotMusicService.class) + .setAction(action); + if (foreground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + applicationContext.startForegroundService(intent); + } else { + applicationContext.startService(intent); + } + } + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + audioPlayer = new AudioReactivePlayer(this, this); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + latestStartId = startId; + String action = intent == null ? null : intent.getAction(); + if (ACTION_STOP.equals(action)) { + stopPlaybackAndService(REASON_USER_STOP); + return START_NOT_STICKY; + } + if (ACTION_PLAY.equals(action)) { + startPlayback(); + return START_NOT_STICKY; + } + + // The service is non-sticky. A malformed or system-recreated start must not leave an + // ownerless foreground notification behind. + if (audioPlayer == null || !audioPlayer.isPlaying()) { + stopPlaybackAndService(REASON_SERVICE_DESTROYED); + } + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onPlaybackStopped(AudioReactivePlayer.StopReason reason) { + Runnable stopTask = () -> stopPlaybackAndService( + reason == AudioReactivePlayer.StopReason.AUDIO_FOCUS_LOSS + ? REASON_AUDIO_FOCUS : REASON_SERVICE_DESTROYED); + if (Looper.myLooper() == Looper.getMainLooper()) stopTask.run(); + else mainHandler.post(stopTask); + } + + @Override + public void onDestroy() { + mainHandler.removeCallbacksAndMessages(null); + AudioReactivePlayer player = audioPlayer; + audioPlayer = null; + if (player != null) player.close(); + if (!stopping && playbackState != STATE_STOPPED) { + playbackState = STATE_STOPPED; + publishState(false, REASON_SERVICE_DESTROYED); + } else { + playbackState = STATE_STOPPED; + } + super.onDestroy(); + } + + private void startPlayback() { + if (audioPlayer == null) { + playbackState = STATE_STOPPED; + publishState(false, REASON_START_FAILED); + stopCurrentStart(); + return; + } + // A new explicit PLAY start can arrive after STOP was queued but before this service + // instance's onDestroy. The newer start id keeps the service alive and is safe to honor. + stopping = false; + if (audioPlayer.isPlaying()) { + playbackState = STATE_PLAYING; + MascotActionBus.setAction(MascotActionBus.Action.MUSIC); + updateNotification("实时 PCM 频谱正在驱动派蒙律动"); + publishState(true, REASON_PLAYING); + return; + } + + playbackState = STATE_STARTING; + try { + startForeground(NOTIFICATION_ID, buildNotification("正在准备测试音乐…")); + } catch (RuntimeException notificationFailure) { + stopPlaybackAndService(REASON_START_FAILED); + return; + } + + if (!audioPlayer.start()) { + stopPlaybackAndService(REASON_START_FAILED); + return; + } + + playbackState = STATE_PLAYING; + updateNotification("实时 PCM 频谱正在驱动派蒙律动"); + publishState(true, REASON_PLAYING); + } + + private void stopPlaybackAndService(String reason) { + if (stopping) { + // A later STOP start id may have been queued while the first stop was running. + // Consume that id as well so START_NOT_STICKY cannot leave an idle service resident. + stopCurrentStart(); + return; + } + stopping = true; + AudioReactivePlayer player = audioPlayer; + if (player != null) player.stop(); + playbackState = STATE_STOPPED; + publishState(false, reason); + try { + stopForeground(true); + } catch (RuntimeException ignored) { + // Vendor service managers can race process teardown. + } + stopCurrentStart(); + } + + private void stopCurrentStart() { + if (latestStartId > 0) stopSelfResult(latestStartId); + else stopSelf(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationManager manager = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null) return; + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "桌宠测试音乐", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("保持本项目测试音乐与实时 PCM 桌宠律动"); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + + private void updateNotification(String text) { + NotificationManager manager = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager != null) manager.notify(NOTIFICATION_ID, buildNotification(text)); + } + + private Notification buildNotification(String text) { + int pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + pendingFlags |= PendingIntent.FLAG_IMMUTABLE; + } + + Intent openApp = new Intent(this, MainActivity.class) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent contentIntent = PendingIntent.getActivity( + this, 0, openApp, pendingFlags); + Intent stop = new Intent(this, MascotMusicService.class).setAction(ACTION_STOP); + PendingIntent stopIntent = PendingIntent.getService( + this, 1, stop, pendingFlags); + + Notification.Builder builder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + ? new Notification.Builder(this, CHANNEL_ID) + : new Notification.Builder(this); + return builder + .setSmallIcon(R.drawable.ic_notification_music) + .setContentTitle("派蒙测试音乐正在播放") + .setContentText(text) + .setContentIntent(contentIntent) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setShowWhen(false) + .addAction(new Notification.Action.Builder( + R.drawable.ic_notification_music, "停止", stopIntent).build()) + .build(); + } + + private void publishState(boolean playing, String reason) { + Intent state = new Intent(ACTION_STATE_CHANGED) + .setPackage(getPackageName()) + .putExtra(EXTRA_PLAYING, playing) + .putExtra(EXTRA_REASON, reason == null ? "" : reason); + sendBroadcast(state); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotOverlayService.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotOverlayService.java new file mode 100644 index 0000000..bcbbebc --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotOverlayService.java @@ -0,0 +1,732 @@ +package com.seeker.n7cockpit; + +import android.annotation.SuppressLint; +import android.animation.ValueAnimator; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.provider.Settings; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.WindowManager; +import android.view.animation.DecelerateInterpolator; +import android.widget.FrameLayout; +import android.widget.TextView; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Foreground service hosting the draggable, resizable and rotatable 3D mascot overlay. */ +public final class MascotOverlayService extends Service implements MascotActionBus.Listener { + public static final String ACTION_START = + "com.seeker.n7cockpit.mascot.START"; + public static final String ACTION_STOP = + "com.seeker.n7cockpit.mascot.STOP"; + public static final String ACTION_SET_ACTION = + "com.seeker.n7cockpit.mascot.SET_ACTION"; + public static final String ACTION_SET_SIZE = + "com.seeker.n7cockpit.mascot.SET_SIZE"; + public static final String EXTRA_ACTION = "mascot_action"; + public static final String EXTRA_SIZE_DP = "mascot_size_dp"; + + private static final String CHANNEL_ID = "lumi_overlay"; + private static final int NOTIFICATION_ID = 7301; + private static final String PREFS = "lumi_overlay_state"; + private static final String PREF_X = "x"; + private static final String PREF_Y = "y"; + private static final float EDIT_RESIZE_DP_PER_DRAG_DP = 0.72f; + private static final float EDIT_ROTATION_DEGREES_PER_DRAG_DP = 0.62f; + private static final AtomicBoolean RUNNING = new AtomicBoolean(false); + private static final MascotActionBus.Action[] DOUBLE_TAP_ACTIONS = { + MascotActionBus.Action.SUCCESS, + MascotActionBus.Action.CLIMATE, + MascotActionBus.Action.WINDOW, + MascotActionBus.Action.LIGHT, + MascotActionBus.Action.SUNSHADE, + MascotActionBus.Action.DEVICE, + MascotActionBus.Action.ERROR + }; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Point screenSize = new Point(); + private WindowManager windowManager; + private WindowManager.LayoutParams windowParams; + private OverlayPanel overlayPanel; + private SharedPreferences preferences; + private GestureDetector gestureDetector; + private ValueAnimator snapAnimator; + private int sizeDp = MascotActionBus.DEFAULT_SIZE_DP; + private int touchSlop; + private int startWindowX; + private int startWindowY; + private int resizeStartDp; + private float downRawX; + private float downRawY; + private float currentRawX; + private float currentRawY; + private float editStartRawY; + private float editLastRawX; + private boolean dragging; + private boolean editing; + private boolean foregroundReady; + private int doubleTapIndex; + private MascotActionBus.Action dragRestoreAction; + private MascotInteractionMapper.DragDirection dragDirection = + MascotInteractionMapper.DragDirection.NONE; + private float pendingTapX = Float.NaN; + private float pendingTapY = Float.NaN; + + private final Runnable hideControls = () -> { + if (overlayPanel != null && !editing) overlayPanel.setControlsVisible(false); + }; + + public static boolean start(Context context) { + Context app = context.getApplicationContext(); + if (!Settings.canDrawOverlays(app)) return false; + try { + app.startForegroundService(createStartIntent(app)); + return true; + } catch (RuntimeException ignored) { + return false; + } + } + + public static void stop(Context context) { + try { + context.getApplicationContext().stopService( + new Intent(context.getApplicationContext(), MascotOverlayService.class)); + } catch (RuntimeException ignored) { + // Already stopped or the process is being torn down. + } + } + + public static boolean isRunning() { + return RUNNING.get(); + } + + public static boolean hasOverlayPermission(Context context) { + return Settings.canDrawOverlays(context.getApplicationContext()); + } + + public static Intent createPermissionIntent(Context context) { + return new Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + context.getPackageName())); + } + + public static Intent createStartIntent(Context context) { + return new Intent(context, MascotOverlayService.class) + .setAction(ACTION_START); + } + + public static Intent createStopIntent(Context context) { + return new Intent(context, MascotOverlayService.class).setAction(ACTION_STOP); + } + + public static Intent createActionIntent( + Context context, MascotActionBus.Action action) { + return new Intent(context, MascotOverlayService.class) + .setAction(ACTION_SET_ACTION) + .putExtra(EXTRA_ACTION, + (action == null ? MascotActionBus.Action.IDLE : action).name()); + } + + public static Intent createSizeIntent(Context context, int sizeDp) { + return new Intent(context, MascotOverlayService.class) + .setAction(ACTION_SET_SIZE) + .putExtra(EXTRA_SIZE_DP, sizeDp); + } + + @Override + public void onCreate() { + super.onCreate(); + setRunningState(false); + windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); + preferences = getSharedPreferences(PREFS, MODE_PRIVATE); + sizeDp = MascotPreferences.readSizeDp(this, MascotActionBus.getSizeDp()); + touchSlop = ViewConfiguration.get(this).getScaledTouchSlop(); + createNotificationChannel(); + try { + startForeground(NOTIFICATION_ID, buildNotification()); + foregroundReady = true; + } catch (RuntimeException failure) { + foregroundReady = false; + stopSelf(); + return; + } + MascotActionBus.setSizeDp(sizeDp); + MascotActionBus.addListener(this); + gestureDetector = new GestureDetector(this, new OverlayGestures()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (!foregroundReady) return START_NOT_STICKY; + String command = intent == null ? ACTION_START : intent.getAction(); + if (ACTION_STOP.equals(command)) { + setRunningState(false); + stopSelf(); + return START_NOT_STICKY; + } + + if (!Settings.canDrawOverlays(this)) { + setRunningState(false); + stopSelf(); + return START_NOT_STICKY; + } + + if (ACTION_SET_ACTION.equals(command)) { + MascotActionBus.setAction(parseAction(intent.getStringExtra(EXTRA_ACTION))); + } else if (ACTION_SET_SIZE.equals(command)) { + MascotActionBus.setSizeDp(intent.getIntExtra(EXTRA_SIZE_DP, sizeDp)); + } + + ensureOverlay(); + return START_STICKY; + } + + @Override + public void onMascotSizeChanged(int requestedSizeDp) { + applySize(requestedSizeDp, false); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mainHandler.post(() -> { + cancelSnap(); + clampWindowPosition(); + updateWindow(); + persistLayout(); + }); + } + + @Override + public void onDestroy() { + setRunningState(false); + foregroundReady = false; + mainHandler.removeCallbacksAndMessages(null); + cancelSnap(); + restoreActionAfterDrag(); + MascotActionBus.removeListener(this); + if (overlayPanel != null && windowManager != null) { + try { + windowManager.removeViewImmediate(overlayPanel); + } catch (RuntimeException ignored) { + // View was already removed by the window manager. + } + } + overlayPanel = null; + windowParams = null; + stopForeground(true); + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void ensureOverlay() { + if (overlayPanel != null) return; + if (windowManager == null) { + failOverlay(); + return; + } + overlayPanel = new OverlayPanel(this); + int pixels = dp(sizeDp); + windowParams = new WindowManager.LayoutParams( + pixels, + pixels, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + PixelFormat.TRANSLUCENT); + windowParams.gravity = Gravity.TOP | Gravity.START; + + refreshScreenSize(); + int defaultX = Math.max(0, screenSize.x - pixels - dp(22)); + int defaultY = Math.max(0, screenSize.y - pixels - dp(72)); + windowParams.x = preferences.getInt(PREF_X, defaultX); + windowParams.y = preferences.getInt(PREF_Y, defaultY); + clampWindowPosition(); + + try { + windowManager.addView(overlayPanel, windowParams); + setRunningState(true); + } catch (RuntimeException failure) { + setRunningState(false); + overlayPanel = null; + windowParams = null; + stopSelf(); + } + } + + @SuppressLint("ClickableViewAccessibility") + private boolean onMascotTouch(View view, MotionEvent event) { + if (windowParams == null) return false; + currentRawX = event.getRawX(); + currentRawY = event.getRawY(); + gestureDetector.onTouchEvent(event); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + cancelSnap(); + downRawX = currentRawX; + downRawY = currentRawY; + startWindowX = windowParams.x; + startWindowY = windowParams.y; + resizeStartDp = sizeDp; + dragging = false; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + editing = false; + return true; + case MotionEvent.ACTION_MOVE: + float deltaX = currentRawX - downRawX; + float deltaY = currentRawY - downRawY; + if (editing) { + float density = getResources().getDisplayMetrics().density; + int deltaDp = Math.round((editStartRawY - currentRawY) + / density * EDIT_RESIZE_DP_PER_DRAG_DP); + applySize(resizeStartDp + deltaDp, true); + float horizontalDeltaDp = (currentRawX - editLastRawX) / density; + if (overlayPanel != null && Math.abs(horizontalDeltaDp) > 0.01f) { + overlayPanel.mascotView.addUserRotation( + horizontalDeltaDp * EDIT_ROTATION_DEGREES_PER_DRAG_DP); + } + editLastRawX = currentRawX; + showControlsTemporarily(); + } else if (dragging + || Math.hypot(deltaX, deltaY) > touchSlop) { + if (!dragging) updateDragAction(deltaX, deltaY); + dragging = true; + updateDragAction(deltaX, deltaY); + windowParams.x = startWindowX + Math.round(deltaX); + windowParams.y = startWindowY + Math.round(deltaY); + clampWindowPosition(); + updateWindow(); + } + return true; + case MotionEvent.ACTION_UP: + if (editing) { + editing = false; + persistLayout(); + showControlsTemporarily(); + } else if (dragging) { + snapToNearestEdge(); + } else { + restoreActionAfterDrag(); + } + dragging = false; + return true; + case MotionEvent.ACTION_CANCEL: + editing = false; + dragging = false; + persistLayout(); + restoreActionAfterDrag(); + return true; + default: + return true; + } + } + + private void applySize(int requestedSizeDp, boolean publish) { + int safeSize = clampSize(requestedSizeDp); + MascotPreferences.writeSizeDp(this, safeSize); + if (safeSize == sizeDp) { + if (publish) MascotActionBus.setSizeDp(safeSize); + return; + } + sizeDp = safeSize; + if (windowParams != null) { + refreshScreenSize(); + int previousMaxX = Math.max(0, screenSize.x - windowParams.width); + boolean attachedRight = Math.abs(windowParams.x - previousMaxX) <= dp(8); + int pixels = dp(sizeDp); + windowParams.width = pixels; + windowParams.height = pixels; + if (attachedRight) windowParams.x = Math.max(0, screenSize.x - pixels); + clampWindowPosition(); + updateWindow(); + } + if (publish) MascotActionBus.setSizeDp(sizeDp); + } + + private void resizeBy(int deltaDp) { + applySize(sizeDp + deltaDp, true); + showControlsTemporarily(); + persistLayout(); + } + + private void snapToNearestEdge() { + if (windowParams == null) { + restoreActionAfterDrag(); + return; + } + refreshScreenSize(); + int maxX = Math.max(0, screenSize.x - windowParams.width); + int start = windowParams.x; + int target = start + windowParams.width / 2 < screenSize.x / 2 ? 0 : maxX; + if (start == target) { + persistLayout(); + restoreActionAfterDrag(); + return; + } + cancelSnap(false); + snapAnimator = ValueAnimator.ofInt(start, target); + snapAnimator.setDuration(190L); + snapAnimator.setInterpolator(new DecelerateInterpolator()); + snapAnimator.addUpdateListener(animation -> { + if (windowParams == null) return; + windowParams.x = (int) animation.getAnimatedValue(); + updateWindow(); + if (animation.getAnimatedFraction() >= 1f) { + persistLayout(); + restoreActionAfterDrag(); + snapAnimator = null; + } + }); + snapAnimator.start(); + } + + private void cancelSnap() { + cancelSnap(true); + } + + private void cancelSnap(boolean restoreDragAction) { + if (snapAnimator != null) { + snapAnimator.cancel(); + snapAnimator = null; + } + if (restoreDragAction) restoreActionAfterDrag(); + } + + private void updateDragAction(float deltaX, float deltaY) { + if (overlayPanel == null) return; + MascotInteractionMapper.DragDirection resolved = + MascotInteractionMapper.resolveDrag(deltaX, deltaY, dragDirection); + if (resolved == MascotInteractionMapper.DragDirection.NONE + || resolved == dragDirection) { + return; + } + if (dragRestoreAction == null) { + MascotActionBus.Action underlying = + overlayPanel.mascotView.takeUnderlyingActionForInteraction(); + dragRestoreAction = stableActionAfterDrag(underlying); + } + dragDirection = resolved; + MascotActionBus.perform(MascotInteractionMapper.actionForDrag(resolved)); + } + + private void restoreActionAfterDrag() { + MascotActionBus.Action restore = dragRestoreAction; + dragRestoreAction = null; + dragDirection = MascotInteractionMapper.DragDirection.NONE; + if (restore != null + && MascotInteractionMapper.isDragAction(MascotActionBus.getAction())) { + MascotActionBus.perform(restore); + } + } + + private static MascotActionBus.Action stableActionAfterDrag( + MascotActionBus.Action current) { + if (current == MascotActionBus.Action.MUSIC + || current == MascotActionBus.Action.DRIVE + || current == MascotActionBus.Action.SLEEP) { + return current; + } + return MascotActionBus.Action.IDLE; + } + + private void clampWindowPosition() { + if (windowParams == null) return; + refreshScreenSize(); + windowParams.x = Math.max(0, + Math.min(windowParams.x, Math.max(0, screenSize.x - windowParams.width))); + windowParams.y = Math.max(0, + Math.min(windowParams.y, Math.max(0, screenSize.y - windowParams.height))); + } + + private void updateWindow() { + if (windowManager == null || overlayPanel == null || windowParams == null) return; + try { + windowManager.updateViewLayout(overlayPanel, windowParams); + } catch (RuntimeException failure) { + // Permission revocation, a removed token, or vendor WindowManager failures must not + // crash the process or leave isRunning() reporting a window that no longer exists. + failOverlay(); + } + } + + private void failOverlay() { + setRunningState(false); + stopSelf(); + } + + private void setRunningState(boolean running) { + RUNNING.set(running); + MascotActionBus.setOverlayRunning(running); + } + + private void persistLayout() { + if (preferences == null || windowParams == null) return; + preferences.edit() + .putInt(PREF_X, windowParams.x) + .putInt(PREF_Y, windowParams.y) + .apply(); + MascotPreferences.writeSizeDp(this, sizeDp); + } + + private void refreshScreenSize() { + if (windowManager == null) return; + windowManager.getDefaultDisplay().getSize(screenSize); + } + + private void showControlsTemporarily() { + if (overlayPanel == null) return; + overlayPanel.setControlsVisible(true); + mainHandler.removeCallbacks(hideControls); + mainHandler.postDelayed(hideControls, 3_200L); + } + + private void triggerSingleTapAction(View touchedView) { + if (overlayPanel == null) return; + MascotInteractionMapper.TapRegion region; + if (Float.isFinite(pendingTapX) && Float.isFinite(pendingTapY)) { + region = MascotInteractionMapper.mapTap( + pendingTapX, pendingTapY, touchedView.getWidth(), touchedView.getHeight()); + } else { + region = MascotInteractionMapper.TapRegion.HEAD; + } + pendingTapX = Float.NaN; + pendingTapY = Float.NaN; + overlayPanel.mascotView.playAction( + MascotInteractionMapper.actionForTap(region), + MascotInteractionMapper.durationForTap(region)); + touchedView.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + } + + private void triggerDoubleTapAction(View touchedView) { + if (overlayPanel == null) return; + MascotActionBus.Action temporary = + DOUBLE_TAP_ACTIONS[doubleTapIndex++ % DOUBLE_TAP_ACTIONS.length]; + overlayPanel.mascotView.playAction(temporary, MascotActionTiming.demoMs(temporary)); + touchedView.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK); + } + + private void createNotificationChannel() { + NotificationManager manager = + (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + if (manager == null) return; + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, "Lumi 桌宠", NotificationManager.IMPORTANCE_LOW); + channel.setDescription("保持可拖动、缩放和旋转的 3D 车载桌宠悬浮显示"); + channel.setShowBadge(false); + manager.createNotificationChannel(channel); + } + + private Notification buildNotification() { + Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + PendingIntent contentIntent = launchIntent == null ? null : PendingIntent.getActivity( + this, 0, launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + PendingIntent stopIntent = PendingIntent.getService( + this, + 1, + createStopIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle("派蒙 3D 桌宠正在陪伴你") + .setContentText("拖动移动;长按后上下缩放、左右旋转") + .setCategory(Notification.CATEGORY_SERVICE) + .setOngoing(true) + .setShowWhen(false) + .setOnlyAlertOnce(true) + .addAction(new Notification.Action.Builder( + R.drawable.ic_launcher, "关闭桌宠", stopIntent).build()); + if (contentIntent != null) builder.setContentIntent(contentIntent); + return builder.build(); + } + + private GradientDrawable controlBackground() { + GradientDrawable drawable = new GradientDrawable(); + drawable.setShape(GradientDrawable.OVAL); + drawable.setColor(Color.argb(218, 24, 32, 51)); + drawable.setStroke(dp(1), Color.argb(205, 255, 202, 85)); + return drawable; + } + + private int dp(float value) { + return Math.round(value * getResources().getDisplayMetrics().density); + } + + private static int clampSize(int value) { + return Math.max(MascotActionBus.MIN_SIZE_DP, + Math.min(MascotActionBus.MAX_SIZE_DP, value)); + } + + private static MascotActionBus.Action parseAction(String name) { + if (name == null) return MascotActionBus.Action.IDLE; + try { + return MascotActionBus.Action.valueOf(name); + } catch (IllegalArgumentException ignored) { + return MascotActionBus.Action.IDLE; + } + } + + private final class OverlayGestures extends GestureDetector.SimpleOnGestureListener { + @Override + public boolean onDown(MotionEvent event) { + return true; + } + + @Override + public boolean onSingleTapConfirmed(MotionEvent event) { + // Waiting for confirmation prevents the first tap of a double-tap from also firing + // the localized petting interaction. performClick() is shared with accessibility. + pendingTapX = event.getX(); + pendingTapY = event.getY(); + return overlayPanel != null && overlayPanel.mascotView.performClick(); + } + + @Override + public boolean onDoubleTap(MotionEvent event) { + if (overlayPanel != null) triggerDoubleTapAction(overlayPanel.mascotView); + return true; + } + + @Override + public void onLongPress(MotionEvent event) { + editing = true; + dragging = false; + resizeStartDp = sizeDp; + editStartRawY = currentRawY; + editLastRawX = currentRawX; + if (overlayPanel != null) { + overlayPanel.mascotView.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS); + overlayPanel.announceForAccessibility( + "进入桌宠编辑模式,上下滑动调整大小,左右滑动旋转模型"); + } + showControlsTemporarily(); + } + } + + private final class OverlayPanel extends FrameLayout { + private final MascotView mascotView; + private final TextView minusButton; + private final TextView plusButton; + private boolean controlsVisible; + private boolean panelTouchAccepted; + + OverlayPanel(Context context) { + super(context); + setClipChildren(false); + setClipToPadding(false); + setBackgroundColor(Color.TRANSPARENT); + setContentDescription("3D 桌宠;单击摸头,拖动移动,双击切换动作,长按后缩放旋转"); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + + mascotView = new MascotView(context); + mascotView.setContentDescription( + "派蒙 3D 桌宠;单击摸头,拖动移动,双击互动,长按后缩放旋转"); + mascotView.setOnClickListener(MascotOverlayService.this::triggerSingleTapAction); + mascotView.setOnTouchListener(MascotOverlayService.this::onMascotTouch); + addView(mascotView, new FrameLayout.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + + minusButton = createControl("−", "缩小桌宠", Gravity.TOP | Gravity.START); + plusButton = createControl("+", "放大桌宠", Gravity.TOP | Gravity.END); + minusButton.setOnClickListener(view -> resizeBy(-16)); + plusButton.setOnClickListener(view -> resizeBy(16)); + setControlsVisible(false); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + panelTouchAccepted = isInsideMascotCircle(event.getX(), event.getY()) + || isInsideVisibleControl(event.getX(), event.getY()); + if (!panelTouchAccepted) return false; + } else if (!panelTouchAccepted) { + return false; + } + boolean handled = super.dispatchTouchEvent(event); + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) { + panelTouchAccepted = false; + } + return handled; + } + + private boolean isInsideMascotCircle(float x, float y) { + float width = getWidth(); + float height = getHeight(); + if (width <= 0f || height <= 0f) return true; + float normalizedX = (x - width * 0.5f) / (width * 0.5f); + float normalizedY = (y - height * 0.5f) / (height * 0.5f); + return normalizedX * normalizedX + normalizedY * normalizedY <= 1f; + } + + private boolean isInsideVisibleControl(float x, float y) { + if (!controlsVisible) return false; + return isInside(minusButton, x, y) || isInside(plusButton, x, y); + } + + private boolean isInside(View child, float x, float y) { + return x >= child.getLeft() && x < child.getRight() + && y >= child.getTop() && y < child.getBottom(); + } + + private TextView createControl(String label, String description, int gravity) { + TextView control = new TextView(MascotOverlayService.this); + control.setText(label); + control.setTextColor(Color.WHITE); + control.setTextSize(18f); + control.setGravity(Gravity.CENTER); + control.setContentDescription(description); + control.setBackground(controlBackground()); + control.setElevation(dp(7)); + control.setMinWidth(0); + control.setMinHeight(0); + int buttonSize = dp(31); + FrameLayout.LayoutParams params = + new FrameLayout.LayoutParams(buttonSize, buttonSize, gravity); + int margin = dp(3); + params.setMargins(margin, margin, margin, margin); + addView(control, params); + return control; + } + + void setControlsVisible(boolean visible) { + controlsVisible = visible; + int visibility = visible ? View.VISIBLE : View.GONE; + minusButton.setVisibility(visibility); + plusButton.setVisibility(visibility); + if (visible) { + minusButton.bringToFront(); + plusButton.bringToFront(); + } + // Schedule a traversal so WindowManager receives the revised touchable region. + requestLayout(); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPalette.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPalette.java new file mode 100644 index 0000000..126cbee --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPalette.java @@ -0,0 +1,15 @@ +package com.seeker.n7cockpit; + +/** Accent colours shared by the mascot choreography, shading and particle layers. */ +final class MascotPalette { + private MascotPalette() { + } + + static final float[] COOL = {0.28f, 0.82f, 1.00f}; + static final float[] WARM = {1.00f, 0.55f, 0.30f}; + static final float[] GOLD = {1.00f, 0.72f, 0.22f}; + static final float[] VIOLET = {0.55f, 0.39f, 1.00f}; + static final float[] RED = {1.00f, 0.18f, 0.24f}; + static final float[] TEAL = {0.16f, 0.89f, 0.70f}; + static final float[] STAR_WHITE = {1.00f, 0.96f, 0.80f}; +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPose.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPose.java new file mode 100644 index 0000000..de49fcf --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPose.java @@ -0,0 +1,213 @@ +package com.seeker.n7cockpit; + +/** + * One fully evaluated mascot pose: a local rotation delta per joint plus root transform, + * morph weights and lighting accents. + * + *

Every joint rotation is stored as a delta from the model's rest local transform, so the + * renderer always evaluates {@code local = restLocal * delta}. Baked dance clips are converted to + * the same convention at pack time, which lets clip playback, procedural choreography and + * additive secondary motion all blend against each other with plain quaternion interpolation.

+ */ +final class MascotPose { + /** + * Stable semantic control names, index-aligned with the slot constants and + * {@code scripts/paimon_rig.py}. These are control slots, not necessarily the GLB skin order. + */ + static final String[] JOINT_NAMES = { + "center", "hips", "spine", "chest", "neck", "head", "eye_L", "eye_R", + "shoulder_L", "shoulder_R", "arm_L", "arm_R", "elbow_L", "elbow_R", + "forearm_L", "forearm_R", "wrist_L", "wrist_R", + "thumb_L", "thumb_R", "index_L", "index_R", "mid_L", "mid_R", + "ring_L", "ring_R", "pinky_L", "pinky_R", + "leg_L", "leg_R", "knee_L", "knee_R", "ankle_L", "ankle_R", + "hair_back", "hair_L1", "hair_L2", "hair_R1", "hair_R2", + "cape0_a", "cape0_b", "cape1_a", "cape1_b", "cape2_a", "cape2_b", + "cape3_a", "cape3_b", "cape4_a", "cape4_b", "cape5_a", "cape5_b", + }; + + /** + * Original MMD names for the same semantic controls. The compact compatibility asset renames + * these bones to {@link #JOINT_NAMES}; the full deformation asset deliberately retains the + * source names so every weighted finger, twist and foot bone stays untouched. The rig accepts + * either name at each slot. + */ + static final String[] SOURCE_JOINT_NAMES = { + "センター", "下半身", "上半身", "上半身2", "首", "頭", "目.L", "目.R", + "肩.L", "肩.R", "腕.L", "腕.R", "ひじ.L", "ひじ.R", + "手捩.L", "手捩.R", "手首.L", "手首.R", + "親指0.L", "親指0.R", "人指1.L", "人指1.R", "中指1.L", "中指1.R", + "薬指1.L", "薬指1.R", "小指1.L", "小指1.R", + "足D.L", "足D.R", "ひざD.L", "ひざD.R", "足首D.L", "足首D.R", + "+EarB CF AF01", "+HairS L A01", "+HairS L A02", "+HairS R A01", + "+HairS R A02", + "PF_2_0", "PF_6_0", "PF_2_1", "PF_6_1", "PF_2_2", "PF_6_2", + "PF_2_3", "PF_6_3", "PF_2_4", "PF_6_4", "PF_2_5", "PF_6_5", + }; + + static final int JOINT_COUNT = JOINT_NAMES.length; + + static final int CENTER = 0; + static final int HIPS = 1; + static final int SPINE = 2; + static final int CHEST = 3; + static final int NECK = 4; + static final int HEAD = 5; + static final int EYE_L = 6; + static final int EYE_R = 7; + static final int SHOULDER_L = 8; + static final int SHOULDER_R = 9; + static final int ARM_L = 10; + static final int ARM_R = 11; + static final int ELBOW_L = 12; + static final int ELBOW_R = 13; + static final int FOREARM_L = 14; + static final int FOREARM_R = 15; + static final int WRIST_L = 16; + static final int WRIST_R = 17; + static final int THUMB_L = 18; + static final int THUMB_R = 19; + static final int INDEX_L = 20; + static final int INDEX_R = 21; + static final int MID_L = 22; + static final int MID_R = 23; + static final int RING_L = 24; + static final int RING_R = 25; + static final int PINKY_L = 26; + static final int PINKY_R = 27; + static final int LEG_L = 28; + static final int LEG_R = 29; + static final int KNEE_L = 30; + static final int KNEE_R = 31; + static final int ANKLE_L = 32; + static final int ANKLE_R = 33; + static final int HAIR_BACK = 34; + static final int HAIR_L1 = 35; + static final int HAIR_L2 = 36; + static final int HAIR_R1 = 37; + static final int HAIR_R2 = 38; + /** First cape joint; six chains of two joints follow contiguously. */ + static final int CAPE_BASE = 39; + static final int CAPE_CHAINS = 6; + + /** Finger roots in thumb-to-pinky order, left hand then right hand. */ + static final int[] FINGERS_L = {THUMB_L, INDEX_L, MID_L, RING_L, PINKY_L}; + static final int[] FINGERS_R = {THUMB_R, INDEX_R, MID_R, RING_R, PINKY_R}; + + /** + * Local rotation delta per model joint, packed as (x, y, z, w) in GLB skin order. + * It is intentionally dynamic: the production-quality asset has 136 deformation joints while + * the compatibility asset has 51. Semantic authoring always goes through {@link MascotRig}, + * which maps the stable slot constants above into this model-aligned array. + */ + final float[] rot; + + float rootX; + float rootY; + float rootZ; + float rootPitch; + float rootYaw; + float rootRoll; + float scaleX = 1f; + float scaleY = 1f; + float scaleZ = 1f; + + float blink; + float smile; + float trouble; + + float emission; + float rimStrength = 0.30f; + float[] rimColor = MascotPalette.GOLD; + /** 0..1 sparkle density for the foreground particle layer. */ + float sparkle; + + MascotPose(int modelJointCount) { + if (modelJointCount <= 0 || modelJointCount > 256) { + throw new IllegalArgumentException("Invalid mascot joint count: " + modelJointCount); + } + rot = new float[modelJointCount * 4]; + reset(); + } + + int jointCount() { + return rot.length / 4; + } + + void reset() { + for (int j = 0; j < jointCount(); j++) Quat.setIdentity(rot, j * 4); + rootX = 0f; + rootY = 0f; + rootZ = 0f; + rootPitch = 0f; + rootYaw = 0f; + rootRoll = 0f; + scaleX = 1f; + scaleY = 1f; + scaleZ = 1f; + blink = 0f; + smile = 0f; + trouble = 0f; + emission = 0f; + rimStrength = 0.30f; + rimColor = MascotPalette.GOLD; + sparkle = 0f; + } + + void copyFrom(MascotPose other) { + requireSameRig(other); + System.arraycopy(other.rot, 0, rot, 0, rot.length); + rootX = other.rootX; + rootY = other.rootY; + rootZ = other.rootZ; + rootPitch = other.rootPitch; + rootYaw = other.rootYaw; + rootRoll = other.rootRoll; + scaleX = other.scaleX; + scaleY = other.scaleY; + scaleZ = other.scaleZ; + blink = other.blink; + smile = other.smile; + trouble = other.trouble; + emission = other.emission; + rimStrength = other.rimStrength; + rimColor = other.rimColor; + sparkle = other.sparkle; + } + + /** Writes {@code lerp(from, to, t)} into this pose; rotations use shortest-arc slerp. */ + void blend(MascotPose from, MascotPose to, float t) { + requireSameRig(from); + requireSameRig(to); + float k = t <= 0f ? 0f : (t >= 1f ? 1f : t); + for (int j = 0; j < jointCount(); j++) { + Quat.slerp(from.rot, j * 4, to.rot, j * 4, k, rot, j * 4); + } + rootX = mix(from.rootX, to.rootX, k); + rootY = mix(from.rootY, to.rootY, k); + rootZ = mix(from.rootZ, to.rootZ, k); + rootPitch = mix(from.rootPitch, to.rootPitch, k); + rootYaw = mix(from.rootYaw, to.rootYaw, k); + rootRoll = mix(from.rootRoll, to.rootRoll, k); + scaleX = mix(from.scaleX, to.scaleX, k); + scaleY = mix(from.scaleY, to.scaleY, k); + scaleZ = mix(from.scaleZ, to.scaleZ, k); + blink = mix(from.blink, to.blink, k); + smile = mix(from.smile, to.smile, k); + trouble = mix(from.trouble, to.trouble, k); + emission = mix(from.emission, to.emission, k); + rimStrength = mix(from.rimStrength, to.rimStrength, k); + sparkle = mix(from.sparkle, to.sparkle, k); + rimColor = k < 0.5f ? from.rimColor : to.rimColor; + } + + private void requireSameRig(MascotPose other) { + if (other == null || other.rot.length != rot.length) { + throw new IllegalArgumentException("Cannot combine mascot poses from different rigs"); + } + } + + private static float mix(float from, float to, float t) { + return from + (to - from) * t; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPreferences.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPreferences.java new file mode 100644 index 0000000..fd436bc --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotPreferences.java @@ -0,0 +1,66 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.content.SharedPreferences; + +/** One authoritative store for state shared by the in-app and system-overlay mascots. */ +final class MascotPreferences { + private static final String SHARED_PREFS = "n7_mascot_shared_state"; + private static final String KEY_SIZE_DP = "size_dp"; + + // Read-only migration sources used by builds before the mascot shared one size store. + private static final String LEGACY_IN_APP_PREFS = "n7_in_app_mascot_layer"; + private static final String LEGACY_OVERLAY_PREFS = "lumi_overlay_state"; + + private MascotPreferences() {} + + static int readSizeDp(Context context, int fallbackDp) { + Context app = context.getApplicationContext(); + SharedPreferences shared = app.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE); + if (shared.contains(KEY_SIZE_DP)) { + return clampSize(shared.getInt(KEY_SIZE_DP, fallbackDp)); + } + + // Prefer the overlay value because it is the only legacy store that could be changed + // while the dashboard Activity was not visible. Fall back to the in-app value. + SharedPreferences overlay = app.getSharedPreferences( + LEGACY_OVERLAY_PREFS, Context.MODE_PRIVATE); + SharedPreferences inApp = app.getSharedPreferences( + LEGACY_IN_APP_PREFS, Context.MODE_PRIVATE); + boolean hasOverlaySize = overlay.contains(KEY_SIZE_DP); + boolean hasInAppSize = inApp.contains(KEY_SIZE_DP); + int overlaySize = overlay.getInt(KEY_SIZE_DP, fallbackDp); + int inAppSize = inApp.getInt(KEY_SIZE_DP, fallbackDp); + int migrated; + if (hasOverlaySize && hasInAppSize) { + // If only one side differs from the old default, it is the only side with an explicit + // resize worth preserving. When both differ, prefer the cross-app value as above. + if (overlaySize == MascotActionBus.DEFAULT_SIZE_DP + && inAppSize != MascotActionBus.DEFAULT_SIZE_DP) { + migrated = inAppSize; + } else { + migrated = overlaySize; + } + } else if (hasOverlaySize) { + migrated = overlaySize; + } else { + migrated = inAppSize; + } + int safe = clampSize(migrated); + shared.edit().putInt(KEY_SIZE_DP, safe).apply(); + return safe; + } + + static void writeSizeDp(Context context, int sizeDp) { + context.getApplicationContext() + .getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE) + .edit() + .putInt(KEY_SIZE_DP, clampSize(sizeDp)) + .apply(); + } + + private static int clampSize(int sizeDp) { + return Math.max(MascotActionBus.MIN_SIZE_DP, + Math.min(MascotActionBus.MAX_SIZE_DP, sizeDp)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotRig.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotRig.java new file mode 100644 index 0000000..7c9725d --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotRig.java @@ -0,0 +1,398 @@ +package com.seeker.n7cockpit; + +import android.opengl.Matrix; + +/** + * Rest-pose data for the mascot skeleton plus the authoring and skinning maths built on it. + * + *

Choreography code never touches bone axes. It asks for world-axis rotations — "yaw turns the + * head, roll lifts the arm" — and this class conjugates each request into the joint's rest frame + * with {@code delta = restWorldRotation⁻¹ · W · restWorldRotation}. Because the conjugation uses + * the rest world rotation, an authored axis stays attached to the parent chain: a finger + * curl keeps curling toward the palm after the whole arm swings up.

+ * + *

{@link #computePalette} produces the GPU skinning palette as one rotation quaternion plus one + * translation-and-uniform-scale vector per model joint. That is two vec4 per joint instead of the + * three a 4x3 matrix needs. The complete deformation asset preserves all 136 weighted/ancestor + * joints; a compact 51-joint asset remains available for GPUs with a smaller vertex-uniform + * budget.

+ */ +final class MascotRig { + private final MascotGlbModel model; + /** Stable semantic control slot -> GLB joint index. */ + private final int[] jointForSlot; + private final float[][] jointWorld; + /** Per-slot rest world rotation quaternion and its conjugate, for world-axis authoring. */ + private final float[] restWorldQuat; + private final float[] restWorldQuatInverse; + /** Per-slot rest world position, used to derive hand axes and spring swing planes. */ + private final float[] restWorldPosition; + + private final float[] normalizeMatrix = new float[16]; + private final float[] scratchA = new float[16]; + private final float[] scratchB = new float[16]; + private final float[] scratchLocal = new float[16]; + private final float[] scratchQuatA = new float[4]; + private final float[] scratchQuatB = new float[4]; + + /** Curl axes derived from the rest hand geometry: [0..2] fingers, [3..5] thumb, per side. */ + private final float[] fingerCurlAxisL = new float[3]; + private final float[] fingerCurlAxisR = new float[3]; + private final float[] thumbCurlAxisL = new float[3]; + private final float[] thumbCurlAxisR = new float[3]; + /** Palm-facing direction per side; drives the "fingers splay outward" relaxation. */ + private final float[] fingerSpreadAxisL = new float[3]; + private final float[] fingerSpreadAxisR = new float[3]; + + private final float modelScale; + private final float modelHeight; + + MascotRig(MascotGlbModel model) { + if (model == null) throw new IllegalArgumentException("model == null"); + this.model = model; + int jointCount = model.joints.length; + jointForSlot = new int[MascotPose.JOINT_COUNT]; + for (int slot = 0; slot < MascotPose.JOINT_COUNT; slot++) { + int joint = model.findJoint(MascotPose.JOINT_NAMES[slot]); + if (joint < 0) joint = model.findJoint(MascotPose.SOURCE_JOINT_NAMES[slot]); + if (joint < 0) { + throw new IllegalStateException("Mascot skeleton is missing semantic joint " + + MascotPose.JOINT_NAMES[slot] + " (source " + + MascotPose.SOURCE_JOINT_NAMES[slot] + ")"); + } + jointForSlot[slot] = joint; + } + jointWorld = new float[jointCount][16]; + restWorldQuat = new float[MascotPose.JOINT_COUNT * 4]; + restWorldQuatInverse = new float[MascotPose.JOINT_COUNT * 4]; + restWorldPosition = new float[MascotPose.JOINT_COUNT * 3]; + + float[] restWorld = new float[16]; + float[] inverse = new float[16]; + for (int slot = 0; slot < MascotPose.JOINT_COUNT; slot++) { + MascotGlbModel.Joint joint = model.joints[jointForSlot[slot]]; + Quat.fromMatrix(joint.restWorldRotation, 0, restWorldQuat, slot * 4); + Quat.conjugate(restWorldQuat, slot * 4, restWorldQuatInverse, slot * 4); + // The rest world position is the inverse bind matrix's translation, negated through + // its own rotation: restWorld = inverse(inverseBind). + if (!Matrix.invertM(inverse, 0, joint.inverseBind, 0)) { + throw new IllegalStateException( + "Mascot joint has a singular inverse bind: " + joint.name); + } + System.arraycopy(inverse, 0, restWorld, 0, 16); + restWorldPosition[slot * 3] = restWorld[12]; + restWorldPosition[slot * 3 + 1] = restWorld[13]; + restWorldPosition[slot * 3 + 2] = restWorld[14]; + } + + modelHeight = model.boundsMax[1] - model.boundsMin[1]; + if (!(modelHeight > 0f)) throw new IllegalStateException("Mascot model has no height"); + modelScale = MascotView.CHARACTER_HEIGHT / modelHeight; + float centerX = (model.boundsMin[0] + model.boundsMax[0]) * 0.5f; + float centerY = (model.boundsMin[1] + model.boundsMax[1]) * 0.5f; + float centerZ = (model.boundsMin[2] + model.boundsMax[2]) * 0.5f; + Matrix.setIdentityM(normalizeMatrix, 0); + Matrix.scaleM(normalizeMatrix, 0, modelScale, modelScale, modelScale); + Matrix.translateM(normalizeMatrix, 0, -centerX, -centerY, -centerZ); + + deriveHandAxes(true); + deriveHandAxes(false); + } + + float modelHeight() { + return modelHeight; + } + + int jointCount() { + return model.joints.length; + } + + int modelJointForSlot(int slot) { + if (slot < 0 || slot >= jointForSlot.length) { + throw new IllegalArgumentException("Invalid mascot control slot: " + slot); + } + return jointForSlot[slot]; + } + + /** Rest world position of a joint in raw model units. */ + void restPosition(int slot, float[] out) { + out[0] = restWorldPosition[slot * 3]; + out[1] = restWorldPosition[slot * 3 + 1]; + out[2] = restWorldPosition[slot * 3 + 2]; + } + + /** + * Unit direction from one joint to another in the rest pose. + * + *

Choreography uses this for axes that must follow a limb — twisting the forearm about its + * own length, for instance — instead of guessing which world axis happens to line up.

+ */ + float[] axis(int fromSlot, int toSlot) { + float[] direction = difference(toSlot, fromSlot); + normalize(direction); + return direction; + } + + // ------------------------------------------------------------------ + // Authoring + // ------------------------------------------------------------------ + + /** Replaces a joint's rotation with a world-axis Euler triple in degrees. */ + void setWorld(MascotPose pose, int slot, float pitch, float yaw, float roll) { + int offset = rotationOffset(pose, slot); + if (pitch == 0f && yaw == 0f && roll == 0f) { + Quat.setIdentity(pose.rot, offset); + return; + } + Quat.fromEuler(pitch, yaw, roll, scratchQuatA, 0); + conjugate(slot, scratchQuatA, pose.rot, offset); + } + + /** Composes a world-axis Euler triple on top of a joint's current rotation. */ + void addWorld(MascotPose pose, int slot, float pitch, float yaw, float roll) { + if (pitch == 0f && yaw == 0f && roll == 0f) return; + int offset = rotationOffset(pose, slot); + Quat.fromEuler(pitch, yaw, roll, scratchQuatA, 0); + conjugate(slot, scratchQuatA, scratchQuatB, 0); + Quat.multiply(pose.rot, offset, scratchQuatB, 0, pose.rot, offset); + } + + /** Composes a rotation about an explicit world-space axis (unit length) in degrees. */ + void addWorldAxis(MascotPose pose, int slot, float[] axis, float degrees) { + if (degrees == 0f) return; + int offset = rotationOffset(pose, slot); + Quat.fromAxisAngle(axis[0], axis[1], axis[2], degrees, scratchQuatA, 0); + conjugate(slot, scratchQuatA, scratchQuatB, 0); + Quat.multiply(pose.rot, offset, scratchQuatB, 0, pose.rot, offset); + } + + void setWorldAxis(MascotPose pose, int slot, float[] axis, float degrees) { + int offset = rotationOffset(pose, slot); + Quat.fromAxisAngle(axis[0], axis[1], axis[2], degrees, scratchQuatA, 0); + conjugate(slot, scratchQuatA, pose.rot, offset); + } + + /** Curl axis for the four fingers of one hand, derived from the rest hand geometry. */ + float[] fingerCurlAxis(boolean left) { + return left ? fingerCurlAxisL : fingerCurlAxisR; + } + + float[] thumbCurlAxis(boolean left) { + return left ? thumbCurlAxisL : thumbCurlAxisR; + } + + float[] fingerSpreadAxis(boolean left) { + return left ? fingerSpreadAxisL : fingerSpreadAxisR; + } + + private void conjugate(int slot, float[] world, float[] out, int outOffset) { + // delta = restWorldQuat⁻¹ · world · restWorldQuat + Quat.multiply(world, 0, restWorldQuat, slot * 4, scratchQuatB, 0); + Quat.multiply(restWorldQuatInverse, slot * 4, scratchQuatB, 0, out, outOffset); + } + + private int rotationOffset(MascotPose pose, int slot) { + if (pose == null || pose.jointCount() != model.joints.length) { + throw new IllegalArgumentException("Mascot pose does not match the active rig"); + } + return modelJointForSlot(slot) * 4; + } + + // ------------------------------------------------------------------ + // Skinning + // ------------------------------------------------------------------ + + /** + * Evaluates the skeleton and packs it as {@code boneQ} (rotation) and {@code boneT} + * (translation xyz + uniform scale w), indexed by GLB joint index to match JOINTS_0. + */ + void computePalette(MascotPose pose, float[] boneQ, float[] boneT) { + MascotGlbModel.Joint[] joints = model.joints; + if (pose == null || pose.jointCount() != joints.length + || boneQ == null || boneQ.length < joints.length * 4 + || boneT == null || boneT.length < joints.length * 4) { + throw new IllegalArgumentException("Mascot palette buffers do not match the rig"); + } + evaluateJointWorld(pose); + for (int j = 0; j < joints.length; j++) { + Matrix.multiplyMM(scratchA, 0, jointWorld[j], 0, joints[j].inverseBind, 0); + Quat.fromMatrix(scratchA, 0, boneQ, j * 4); + float scale = (float) Math.sqrt( + scratchA[0] * scratchA[0] + scratchA[1] * scratchA[1] + + scratchA[2] * scratchA[2]); + boneT[j * 4] = scratchA[12]; + boneT[j * 4 + 1] = scratchA[13]; + boneT[j * 4 + 2] = scratchA[14]; + boneT[j * 4 + 3] = scale; + } + } + + /** + * Packs exact affine skin matrices as three row vectors per joint. This is the path used by + * the complete deformation rig: it matches glTF/Blender linear blend skinning exactly, which + * is essential when adjacent finger and twist bones rotate through large dance poses. + */ + void computeMatrixPalette(MascotPose pose, float[] boneRows) { + MascotGlbModel.Joint[] joints = model.joints; + if (pose == null || pose.jointCount() != joints.length + || boneRows == null || boneRows.length < joints.length * 12) { + throw new IllegalArgumentException("Mascot matrix palette does not match the rig"); + } + evaluateJointWorld(pose); + for (int j = 0; j < joints.length; j++) { + Matrix.multiplyMM(scratchA, 0, jointWorld[j], 0, joints[j].inverseBind, 0); + int out = j * 12; + // Android matrices are column-major. Store three rows so GLSL can dot each with + // vec4(position, 1) without constructing a mat4 per influence. + boneRows[out] = scratchA[0]; + boneRows[out + 1] = scratchA[4]; + boneRows[out + 2] = scratchA[8]; + boneRows[out + 3] = scratchA[12]; + boneRows[out + 4] = scratchA[1]; + boneRows[out + 5] = scratchA[5]; + boneRows[out + 6] = scratchA[9]; + boneRows[out + 7] = scratchA[13]; + boneRows[out + 8] = scratchA[2]; + boneRows[out + 9] = scratchA[6]; + boneRows[out + 10] = scratchA[10]; + boneRows[out + 11] = scratchA[14]; + } + } + + private void evaluateJointWorld(MascotPose pose) { + MascotGlbModel.Joint[] joints = model.joints; + if (pose == null || pose.jointCount() != joints.length) { + throw new IllegalArgumentException("Mascot pose does not match the active rig"); + } + for (int j = 0; j < joints.length; j++) { + MascotGlbModel.Joint joint = joints[j]; + int offset = j * 4; + float[] local; + if (Quat.isIdentity(pose.rot, offset)) { + local = joint.restLocal; + } else { + Quat.toMatrix(pose.rot, offset, scratchB, 0); + Matrix.multiplyMM(scratchLocal, 0, joint.restLocal, 0, scratchB, 0); + local = scratchLocal; + } + float[] world = jointWorld[j]; + if (joint.parent < 0) { + System.arraycopy(local, 0, world, 0, 16); + } else { + Matrix.multiplyMM(world, 0, jointWorld[joint.parent], 0, local, 0); + } + } + } + + /** + * Builds the root matrix applied after skinning: hover offset, body yaw/pitch/roll, the + * squash-and-stretch scale and the model normalisation into view units. + */ + void buildRootMatrix(MascotPose pose, float bodyCenterY, float[] out) { + Matrix.setIdentityM(out, 0); + Matrix.translateM(out, 0, pose.rootX, bodyCenterY + pose.rootY, pose.rootZ); + if (pose.rootYaw != 0f) Matrix.rotateM(out, 0, pose.rootYaw, 0f, 1f, 0f); + if (pose.rootPitch != 0f) Matrix.rotateM(out, 0, pose.rootPitch, 1f, 0f, 0f); + if (pose.rootRoll != 0f) Matrix.rotateM(out, 0, pose.rootRoll, 0f, 0f, 1f); + Matrix.scaleM(out, 0, pose.scaleX, pose.scaleY, pose.scaleZ); + Matrix.multiplyMM(scratchA, 0, out, 0, normalizeMatrix, 0); + System.arraycopy(scratchA, 0, out, 0, 16); + } + + // ------------------------------------------------------------------ + // Hand geometry + // ------------------------------------------------------------------ + + /** + * Derives the finger and thumb curl axes from the rest pose instead of hard-coding them. + * + *

The four fingers of a flat hand are parallel and lie in the palm plane, so the axis that + * folds them is the index-to-pinky direction. The thumb opposes across that plane, so it folds + * about the palm normal. Signs are chosen so a positive angle always closes the hand.

+ */ + private void deriveHandAxes(boolean left) { + int wrist = left ? MascotPose.WRIST_L : MascotPose.WRIST_R; + int index = left ? MascotPose.INDEX_L : MascotPose.INDEX_R; + int pinky = left ? MascotPose.PINKY_L : MascotPose.PINKY_R; + int mid = left ? MascotPose.MID_L : MascotPose.MID_R; + int thumb = left ? MascotPose.THUMB_L : MascotPose.THUMB_R; + + float[] outward = difference(mid, wrist); + float[] spread = difference(pinky, index); + float[] thumbDir = difference(thumb, wrist); + normalize(outward); + normalize(spread); + normalize(thumbDir); + + float[] palmNormal = cross(outward, spread); + normalize(palmNormal); + + // Fingers fold about the spread axis. Choose the sign that moves the fingertip toward the + // thumb side of the palm, which is the direction a closing hand travels. + float[] towardThumb = reject(thumbDir, outward); + normalize(towardThumb); + float[] axis = {spread[0], spread[1], spread[2]}; + float[] velocity = cross(axis, outward); + if (dot(velocity, towardThumb) < 0f) negate(axis); + System.arraycopy(axis, 0, left ? fingerCurlAxisL : fingerCurlAxisR, 0, 3); + + // The thumb folds about the palm normal, toward the little finger. + float[] thumbAxis = {palmNormal[0], palmNormal[1], palmNormal[2]}; + float[] thumbVelocity = cross(thumbAxis, thumbDir); + if (dot(thumbVelocity, spread) < 0f) negate(thumbAxis); + System.arraycopy(thumbAxis, 0, left ? thumbCurlAxisL : thumbCurlAxisR, 0, 3); + + // Splaying fans the fingers apart within the palm plane, so it rotates about the normal. + float[] spreadAxis = {palmNormal[0], palmNormal[1], palmNormal[2]}; + System.arraycopy(spreadAxis, 0, left ? fingerSpreadAxisL : fingerSpreadAxisR, 0, 3); + } + + private float[] difference(int slotA, int slotB) { + return new float[]{ + restWorldPosition[slotA * 3] - restWorldPosition[slotB * 3], + restWorldPosition[slotA * 3 + 1] - restWorldPosition[slotB * 3 + 1], + restWorldPosition[slotA * 3 + 2] - restWorldPosition[slotB * 3 + 2], + }; + } + + private static void normalize(float[] v) { + float length = (float) Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + if (length <= 1e-8f) { + v[0] = 0f; + v[1] = 1f; + v[2] = 0f; + return; + } + v[0] /= length; + v[1] /= length; + v[2] /= length; + } + + private static float[] cross(float[] a, float[] b) { + return new float[]{ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + }; + } + + private static float dot(float[] a, float[] b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + } + + /** Component of {@code v} perpendicular to the unit vector {@code axis}. */ + private static float[] reject(float[] v, float[] axis) { + float projection = dot(v, axis); + return new float[]{ + v[0] - axis[0] * projection, + v[1] - axis[1] * projection, + v[2] - axis[2] * projection, + }; + } + + private static void negate(float[] v) { + v[0] = -v[0]; + v[1] = -v[1]; + v[2] = -v[2]; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotSprings.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotSprings.java new file mode 100644 index 0000000..1cdf435 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotSprings.java @@ -0,0 +1,237 @@ +package com.seeker.n7cockpit; + +/** + * Secondary motion for the mascot's hair and cape. + * + *

The previous implementation drove every strand with a fixed sine wave, so the cloth kept + * swinging at the same rate no matter what the body did — the main reason the character read as + * "not quite together". Each strand is now a critically-damped angular spring driven by the root's + * own acceleration and spin, so cloth lags into a turn, overshoots when a jump lands and settles + * naturally afterwards. A slow wind field keeps it alive while she hovers still.

+ * + *

Integration runs at a fixed 120 Hz sub-step and every state variable is clamped, so a paused + * surface, a long frame or a sudden action change can never blow the simulation up on screen.

+ */ +final class MascotSprings { + /** Fixed integration step; several run per rendered frame. */ + private static final float STEP = 1f / 120f; + private static final int MAX_STEPS = 8; + private static final float MAX_ANGLE = 46f; + private static final float MAX_RATE = 900f; + + private static final class Strand { + final int slot; + final int parent; + final float stiffness; + final float damping; + /** How strongly root acceleration and wind push this strand. */ + final float drive; + /** Baseline splay in degrees so hair keeps its silhouette at rest. */ + final float restPitch; + final float restRoll; + /** Fraction of the parent strand's angle inherited, producing chain follow-through. */ + final float inherit; + final float phase; + + float pitch; + float roll; + float pitchRate; + float rollRate; + + Strand(int slot, int parent, float stiffness, float damping, float drive, + float restPitch, float restRoll, float inherit, float phase) { + this.slot = slot; + this.parent = parent; + this.stiffness = stiffness; + this.damping = damping; + this.drive = drive; + this.restPitch = restPitch; + this.restRoll = restRoll; + this.inherit = inherit; + this.phase = phase; + this.pitch = restPitch; + this.roll = restRoll; + } + } + + private final MascotRig rig; + private final Strand[] strands; + + private float previousRootX; + private float previousRootY; + private float previousRootZ; + private float previousYaw; + private float velocityX; + private float velocityY; + private float velocityZ; + private float accelerationX; + private float accelerationY; + private float accelerationZ; + private float yawRate; + private boolean primed; + + private double windClock; + /** Extra directed wind, e.g. while the window action blows air past her. */ + private float gustX; + private float gustY; + private float energy; + + MascotSprings(MascotRig rig) { + this.rig = rig; + java.util.ArrayList list = new java.util.ArrayList<>(); + // Hair: the back braid is heavy and slow, the two side locks are light and quick. + list.add(new Strand(MascotPose.HAIR_BACK, -1, 46f, 9.2f, 0.85f, 2f, 0f, 0f, 0.0f)); + list.add(new Strand(MascotPose.HAIR_L1, -1, 58f, 10.4f, 1.00f, 0f, 6f, 0f, 0.7f)); + list.add(new Strand(MascotPose.HAIR_L2, MascotPose.HAIR_L1, 40f, 7.4f, 1.25f, + 0f, 5f, 0.55f, 1.3f)); + list.add(new Strand(MascotPose.HAIR_R1, -1, 58f, 10.4f, 1.00f, 0f, -6f, 0f, 1.9f)); + list.add(new Strand(MascotPose.HAIR_R2, MascotPose.HAIR_R1, 40f, 7.4f, 1.25f, + 0f, -5f, 0.55f, 2.5f)); + // Cape: six chains of two. Outer chains are lighter so the hem ripples across the body. + for (int chain = 0; chain < MascotPose.CAPE_CHAINS; chain++) { + int upper = MascotPose.CAPE_BASE + chain * 2; + int lower = upper + 1; + float outer = 0.85f + Math.abs(chain - 2.5f) * 0.12f; + list.add(new Strand(upper, -1, 50f * outer, 9.0f, 1.15f / outer, + 0f, 0f, 0f, chain * 0.9f)); + list.add(new Strand(lower, upper, 32f * outer, 6.4f, 1.55f / outer, + 0f, 0f, 0.6f, chain * 0.9f + 0.8f)); + } + strands = list.toArray(new Strand[0]); + } + + /** Injects a directional gust, in units of "root accelerations". */ + void gust(float x, float y) { + gustX = x; + gustY = y; + } + + /** 0..1 scale on the idle breeze; music energy raises it so cloth reacts to the beat. */ + void setEnergy(float value) { + energy = value < 0f ? 0f : (value > 1.6f ? 1.6f : value); + } + + void reset() { + primed = false; + velocityX = velocityY = velocityZ = 0f; + accelerationX = accelerationY = accelerationZ = 0f; + yawRate = 0f; + for (Strand strand : strands) { + strand.pitch = strand.restPitch; + strand.roll = strand.restRoll; + strand.pitchRate = 0f; + strand.rollRate = 0f; + } + } + + /** + * Advances the simulation and writes hair and cape rotations into the pose. + * + * @param deltaSeconds wall-clock frame time, clamped internally + */ + void update(MascotPose pose, float deltaSeconds) { + float dt = deltaSeconds; + if (!(dt > 0f)) dt = STEP; + if (dt > MAX_STEPS * STEP) dt = MAX_STEPS * STEP; + + trackRoot(pose, dt); + windClock += dt; + + int steps = Math.max(1, Math.min(MAX_STEPS, Math.round(dt / STEP))); + float h = dt / steps; + for (int s = 0; s < steps; s++) { + for (Strand strand : strands) { + // Cloth lags the body: an acceleration toward +X swings the tip toward -X, which + // is a negative roll for a strand hanging along -Y. + float breeze = 1.6f + energy * 5.5f; + float windPitch = (float) Math.sin(windClock * 1.15f + strand.phase) * breeze; + float windRoll = (float) Math.sin(windClock * 0.83f + strand.phase * 1.4f) + * breeze * 0.8f; + float targetPitch = strand.restPitch + windPitch + + (accelerationZ + gustY * 26f) * strand.drive * 2.4f; + float targetRoll = strand.restRoll + windRoll + - (accelerationX + gustX * 26f) * strand.drive * 2.4f + - yawRate * strand.drive * 0.05f; + if (strand.parent >= 0) { + Strand parent = find(strand.parent); + if (parent != null) { + targetPitch += parent.pitch * strand.inherit; + targetRoll += parent.roll * strand.inherit; + } + } + // A falling body lets cloth float up; a rising one presses it down. + targetPitch -= accelerationY * strand.drive * 1.1f; + + strand.pitchRate += (-strand.stiffness * (strand.pitch - targetPitch) + - strand.damping * strand.pitchRate) * h; + strand.rollRate += (-strand.stiffness * (strand.roll - targetRoll) + - strand.damping * strand.rollRate) * h; + strand.pitchRate = clamp(strand.pitchRate, -MAX_RATE, MAX_RATE); + strand.rollRate = clamp(strand.rollRate, -MAX_RATE, MAX_RATE); + strand.pitch = clamp(strand.pitch + strand.pitchRate * h, -MAX_ANGLE, MAX_ANGLE); + strand.roll = clamp(strand.roll + strand.rollRate * h, -MAX_ANGLE, MAX_ANGLE); + } + } + + for (Strand strand : strands) { + rig.addWorld(pose, strand.slot, strand.pitch, 0f, strand.roll); + } + gustX = 0f; + gustY = 0f; + } + + private Strand find(int slot) { + for (Strand strand : strands) { + if (strand.slot == slot) return strand; + } + return null; + } + + private void trackRoot(MascotPose pose, float dt) { + float x = pose.rootX; + float y = pose.rootY; + float z = pose.rootZ; + float yaw = pose.rootYaw; + if (!primed) { + primed = true; + previousRootX = x; + previousRootY = y; + previousRootZ = z; + previousYaw = yaw; + return; + } + float inverseDt = 1f / Math.max(1e-4f, dt); + float nextVelocityX = (x - previousRootX) * inverseDt; + float nextVelocityY = (y - previousRootY) * inverseDt; + float nextVelocityZ = (z - previousRootZ) * inverseDt; + // Smooth the derivative: a single dropped frame otherwise reads as a huge impulse. + accelerationX += ((nextVelocityX - velocityX) * inverseDt - accelerationX) * 0.45f; + accelerationY += ((nextVelocityY - velocityY) * inverseDt - accelerationY) * 0.45f; + accelerationZ += ((nextVelocityZ - velocityZ) * inverseDt - accelerationZ) * 0.45f; + velocityX = nextVelocityX; + velocityY = nextVelocityY; + velocityZ = nextVelocityZ; + float deltaYaw = wrapDegrees(yaw - previousYaw); + yawRate += (deltaYaw * inverseDt - yawRate) * 0.45f; + accelerationX = clamp(accelerationX, -60f, 60f); + accelerationY = clamp(accelerationY, -60f, 60f); + accelerationZ = clamp(accelerationZ, -60f, 60f); + yawRate = clamp(yawRate, -1400f, 1400f); + previousRootX = x; + previousRootY = y; + previousRootZ = z; + previousYaw = yaw; + } + + private static float wrapDegrees(float value) { + float wrapped = value % 360f; + if (wrapped > 180f) wrapped -= 360f; + if (wrapped < -180f) wrapped += 360f; + return wrapped; + } + + private static float clamp(float value, float minimum, float maximum) { + if (!Float.isFinite(value)) return 0f; + return value < minimum ? minimum : (value > maximum ? maximum : value); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotView.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotView.java new file mode 100644 index 0000000..ca1eae7 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MascotView.java @@ -0,0 +1,1444 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.opengl.GLES20; +import android.opengl.GLSurfaceView; +import android.opengl.GLUtils; +import android.opengl.Matrix; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.util.Log; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.ShortBuffer; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +/** + * Transparent real-time 3D presentation of the Paimon cockpit mascot. + * + *

The character is a genuine skinned 3D model (the full 136-joint deformation asset when the + * GPU supports it, with a compact 51-joint compatibility asset as fallback) driven every frame by + * {@link MascotAnimator}: baked dance clips, procedural choreography, articulated hands and spring + * cloth. Nothing is drawn behind the character — no halo, no rings, no ground shadow — so on the + * dashboard and in the system overlay she reads as a free-floating companion rather than a badge + * pinned to a disc. The only extra geometry is a restrained set of background light motes whose + * color and movement describe the active interaction.

+ * + *

The view intentionally owns no gesture recognizer: an external + * {@link android.view.View.OnTouchListener}, such as the system overlay drag/resize listener, + * always receives the normal View touch dispatch.

+ */ +public final class MascotView extends GLSurfaceView implements MascotActionBus.Listener { + private static final float MAX_USER_ROTATION_DEGREES = 55f; + + /** + * Normalized character height in view units. The camera frames roughly 3.8 units, so the + * mascot fills about four fifths of its square — the previous 2.76 left an obvious halo of + * empty pixels around her at every size. + */ + static final float CHARACTER_HEIGHT = 3.10f; + + private final float density; + private final MascotRenderer mascotRenderer; + + private MascotActionBus.Action action = MascotActionBus.Action.IDLE; + private long actionStartedMs = SystemClock.uptimeMillis(); + private boolean listening; + private boolean surfacePaused; + private MascotActionBus.Action transientAction; + private MascotActionBus.Action transientRestoreAction; + + private final Runnable transientReset = () -> { + MascotActionBus.Action expected = transientAction; + MascotActionBus.Action restore = transientRestoreAction; + transientAction = null; + transientRestoreAction = null; + if (expected != null && MascotActionBus.getAction() == expected) { + MascotActionBus.setAction(restore == null + ? MascotActionBus.Action.IDLE : restore); + } + }; + + public MascotView(Context context) { + this(context, null); + } + + public MascotView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public MascotView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs); + density = getResources().getDisplayMetrics().density; + + // An RGBA surface is required for both the in-app card and TYPE_APPLICATION_OVERLAY. + setEGLContextClientVersion(2); + setEGLConfigChooser(8, 8, 8, 8, 16, 0); + getHolder().setFormat(PixelFormat.TRANSLUCENT); + setZOrderOnTop(true); + setPreserveEGLContextOnPause(false); + + mascotRenderer = new MascotRenderer(context.getApplicationContext()); + setRenderer(mascotRenderer); + setRenderMode(RENDERMODE_CONTINUOUSLY); + + setBackgroundColor(Color.TRANSPARENT); + setContentDescription("派蒙车载精灵,悬浮待机"); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + setClickable(true); + } + + /** Shares this action with both the dashboard and system-overlay mascot. */ + public void setAction(MascotActionBus.Action nextAction) { + MascotActionBus.setAction(nextAction); + } + + /** Semantic alias for event-oriented call sites. */ + public void playAction(MascotActionBus.Action nextAction) { + setAction(nextAction); + } + + /** Plays a transient acknowledgement, then restores the exact underlying functional action. */ + public void playAction(MascotActionBus.Action nextAction, long durationMs) { + removeCallbacks(transientReset); + MascotActionBus.Action safeNext = nextAction == null + ? MascotActionBus.Action.IDLE : nextAction; + MascotActionBus.Action previous = MascotActionBus.getAction(); + // A second interaction during a transient should still return to the original base state. + if (transientAction != null && previous == transientAction) { + previous = transientRestoreAction; + } + transientAction = null; + transientRestoreAction = null; + if (durationMs > 0L && safeNext != MascotActionBus.Action.MUSIC) { + transientAction = safeNext; + transientRestoreAction = previous == null + ? MascotActionBus.Action.IDLE : previous; + } + setAction(safeNext); + if (transientAction != null) { + postDelayed(transientReset, Math.max(250L, durationMs)); + } + } + + /** + * Cancels a local one-shot acknowledgement and returns the functional state below it. + * Dragging can therefore interrupt a petting reaction without losing MUSIC, DRIVE or SLEEP. + */ + public MascotActionBus.Action takeUnderlyingActionForInteraction() { + removeCallbacks(transientReset); + MascotActionBus.Action underlying = MascotActionBus.getAction(); + if (transientAction != null && underlying == transientAction) { + underlying = transientRestoreAction; + } + transientAction = null; + transientRestoreAction = null; + return underlying == null ? MascotActionBus.Action.IDLE : underlying; + } + + public MascotActionBus.Action getCurrentAction() { + return action; + } + + /** + * Rotates the live model around its vertical axis without replacing the current action. + * Values are clamped so the mascot cannot be left facing completely away from the driver. + */ + public synchronized void setUserRotation(float degrees) { + float safeDegrees = Float.isFinite(degrees) ? degrees : 0f; + safeDegrees = Math.max(-MAX_USER_ROTATION_DEGREES, + Math.min(MAX_USER_ROTATION_DEGREES, safeDegrees)); + mascotRenderer.setUserRotation(safeDegrees); + requestRenderSafely(); + } + + /** Adds a bounded yaw delta to the current interactive model rotation. */ + public synchronized void addUserRotation(float deltaDegrees) { + float safeDelta = Float.isFinite(deltaDegrees) ? deltaDegrees : 0f; + setUserRotation(mascotRenderer.getUserRotation() + safeDelta); + } + + /** Lets an alternate audio source feed the same animation contract. */ + public void setAudioFrame(MascotActionBus.AudioFrame frame) { + MascotActionBus.publishAudioFrame(frame); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + if (!listening) { + listening = true; + MascotActionBus.addListener(this); + } + if (getWindowVisibility() == VISIBLE && surfacePaused) onResume(); + } + + @Override + protected void onDetachedFromWindow() { + removeCallbacks(transientReset); + MascotActionBus.Action expected = transientAction; + MascotActionBus.Action restore = transientRestoreAction; + transientAction = null; + transientRestoreAction = null; + if (expected != null && MascotActionBus.getAction() == expected) { + MascotActionBus.setAction(restore == null + ? MascotActionBus.Action.IDLE : restore); + } + if (listening) { + MascotActionBus.removeListener(this); + listening = false; + } + // Delete GPU objects while the context is still current. GLSurfaceView also destroys the + // EGL context on detach, so a missed queued callback still cannot retain driver resources. + if (!surfacePaused) { + try { + queueEvent(mascotRenderer::releaseGlObjects); + requestRender(); + } catch (RuntimeException ignored) { + mascotRenderer.markContextLost(); + } + } else { + mascotRenderer.markContextLost(); + } + super.onDetachedFromWindow(); + surfacePaused = false; + } + + @Override + protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + if (!isAttachedToWindow()) return; + if (visibility == VISIBLE) onResume(); + else onPause(); + } + + /** May also be called by an Activity/Fragment lifecycle owner. */ + @Override + public void onPause() { + if (surfacePaused) return; + super.onPause(); + surfacePaused = true; + } + + /** May also be called by an Activity/Fragment lifecycle owner. */ + @Override + public void onResume() { + if (!surfacePaused) return; + super.onResume(); + surfacePaused = false; + } + + @Override + public void onMascotActionChanged(MascotActionBus.Action nextAction) { + MascotActionBus.Action safe = nextAction == null + ? MascotActionBus.Action.IDLE : nextAction; + // Repeating the same semantic action is still a new operation acknowledgement and must + // restart its short clip. Snapshot delivery only targets a newly attached listener, so + // refreshing the epoch here cannot disturb an existing view. + action = safe; + actionStartedMs = SystemClock.uptimeMillis(); + setContentDescription("派蒙车载精灵," + accessibilityName(safe)); + mascotRenderer.setAction(safe, actionStartedMs); + requestRenderSafely(); + } + + @Override + public void onMascotAudioFrame(MascotActionBus.AudioFrame frame) { + mascotRenderer.setAudioFrame( + frame == null ? MascotActionBus.AudioFrame.SILENCE : frame); + if (action == MascotActionBus.Action.MUSIC) requestRenderSafely(); + } + + @Override + public void onMascotSizeChanged(int sizeDp) { + requestLayout(); + requestRenderSafely(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desired = Math.round(MascotActionBus.getSizeDp() * density); + setMeasuredDimension( + resolveSize(desired, widthMeasureSpec), + resolveSize(desired, heightMeasureSpec)); + } + + @Override + public boolean performClick() { + super.performClick(); + return true; + } + + private void requestRenderSafely() { + if (!surfacePaused) { + try { + requestRender(); + } catch (RuntimeException ignored) { + // A service teardown can race the final weak-listener callback. + } + } + } + + static String accessibilityName(MascotActionBus.Action current) { + switch (current) { + case GREET: return "挥手问候"; + case CLIMATE: return "空调送风动作"; + case WINDOW: return "推窗动作"; + case LIGHT: return "打响指点灯"; + case SUNSHADE: return "抬手遮阳动作"; + case DEVICE: return "环顾扫描动作"; + case MUSIC: return "随音乐跳舞"; + case SUCCESS: return "跳跃转圈庆祝"; + case ERROR: return "抱头懊恼提醒"; + case DRAG: return "移动中保持平衡"; + case DRAG_LEFT: return "向左移动时侧身平衡"; + case DRAG_RIGHT: return "向右移动时侧身平衡"; + case DRAG_UP: return "向上移动时收腿上升"; + case DRAG_DOWN: return "向下移动时伸腿减速"; + case TAP_HEAD: return "被轻轻摸头"; + case TAP_BODY: return "身体被碰后的回应"; + case TAP_LEFT_HAND: return "画面左手回应触碰"; + case TAP_RIGHT_HAND: return "画面右手回应触碰"; + case TAP_FEET: return "脚部怕痒回应"; + case DRIVE: return "行驶陪伴"; + case SLEEP: return "打盹休息"; + case IDLE: + default: return "悬浮待机"; + } + } + + /** GL-thread-only renderer: skinned character plus a minimal sparkle layer. */ + private static final class MascotRenderer implements Renderer { + private static final String TAG = "PaimonMascot"; + private static final int FLOAT_BYTES = 4; + private static final int VERTEX_STRIDE = 6 * FLOAT_BYTES; + private static final String FULL_MODEL_ASSET = "models/paimon_full.glb"; + private static final String FULL_CLIP_ASSET = "models/paimon_full_clips.pmc"; + private static final String COMPAT_MODEL_ASSET = "models/paimon.glb"; + private static final String COMPAT_CLIP_ASSET = "models/paimon_clips.pmc"; + private static final int UNIFORM_VECTOR_OVERHEAD = 12; + private static final float BODY_CENTER_Y = 0.02f; + /** Outline shell thickness as a fraction of model height, independent of export scale. */ + private static final float OUTLINE_HEIGHT_FRACTION = 0.00505f; + /** Expression-layer normal offset as a fraction of model height. */ + private static final float EXPRESSION_LIFT_HEIGHT_FRACTION = 0.00116f; + private static final float CAMERA_Z = 6.1f; + private static final String EFFECT_VERTEX_SHADER = + "uniform mat4 uModel;\n" + + "uniform mat4 uViewProjection;\n" + + "uniform mat3 uNormalMatrix;\n" + + "attribute vec3 aPosition;\n" + + "attribute vec3 aNormal;\n" + + "varying vec3 vWorldPosition;\n" + + "varying vec3 vWorldNormal;\n" + + "void main() {\n" + + " vec4 world = uModel * vec4(aPosition, 1.0);\n" + + " vWorldPosition = world.xyz;\n" + + " vWorldNormal = normalize(uNormalMatrix * aNormal);\n" + + " gl_Position = uViewProjection * world;\n" + + "}\n"; + + private static final String EFFECT_FRAGMENT_SHADER = + "precision mediump float;\n" + + "uniform vec3 uBaseColor;\n" + + "uniform vec3 uRimColor;\n" + + "uniform vec3 uCameraPosition;\n" + + "uniform float uEmission;\n" + + "uniform float uRimStrength;\n" + + "uniform float uAlpha;\n" + + "varying vec3 vWorldPosition;\n" + + "varying vec3 vWorldNormal;\n" + + "void main() {\n" + + " vec3 n = normalize(vWorldNormal);\n" + + " vec3 viewDir = normalize(uCameraPosition - vWorldPosition);\n" + + " float facing = clamp(dot(n, viewDir), 0.0, 1.0);\n" + + " float rim = pow(clamp(1.0 - facing, 0.0, 1.0), 2.2) * uRimStrength;\n" + + " vec3 lit = uBaseColor * (0.35 + uEmission) + uRimColor * rim;\n" + // Android SurfaceFlinger expects translucent surfaces to carry + // premultiplied RGB. Matching blend factors are configured below. + + " gl_FragColor = vec4(lit * uAlpha, uAlpha);\n" + + "}\n"; + + /** + * Rigid skinning palette: one rotation quaternion plus one translation-and-scale vector per + * joint. The shader is generated for the selected model so a capable GPU receives all 136 + * source joints, while the compatibility model still fits the GLES2 minimum budget. + */ + private static String characterQuaternionVertexShader(int jointCount) { + return + "uniform mat4 uViewProjection;\n" + + "uniform mat4 uRoot;\n" + + "uniform vec4 uBoneQ[" + jointCount + "];\n" + + "uniform vec4 uBoneT[" + jointCount + "];\n" + + "uniform vec3 uMorphWeights;\n" + + "uniform float uOutline;\n" + + "attribute vec3 aPosition;\n" + + "attribute vec3 aNormal;\n" + + "attribute vec2 aUv;\n" + + "attribute vec4 aWeights;\n" + + "attribute vec4 aJoints;\n" + + "attribute vec3 aMorphBlink;\n" + + "attribute vec3 aMorphSmile;\n" + + "attribute vec3 aMorphTrouble;\n" + + "varying vec3 vWorldPosition;\n" + + "varying vec3 vWorldNormal;\n" + + "varying vec2 vUv;\n" + + "vec3 qrot(vec4 q, vec3 v) {\n" + + " return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v);\n" + + "}\n" + + "void main() {\n" + + " vec3 morphed = aPosition\n" + + " + uMorphWeights.x * aMorphBlink\n" + + " + uMorphWeights.y * aMorphSmile\n" + + " + uMorphWeights.z * aMorphTrouble\n" + + " + aNormal * uOutline;\n" + + " int j0 = int(aJoints.x);\n" + + " int j1 = int(aJoints.y);\n" + + " int j2 = int(aJoints.z);\n" + + " int j3 = int(aJoints.w);\n" + + " vec4 q0 = uBoneQ[j0];\n" + + " vec4 q1 = uBoneQ[j1];\n" + + " vec4 q2 = uBoneQ[j2];\n" + + " vec4 q3 = uBoneQ[j3];\n" + // Align every influence to the dominant one, otherwise antipodal + // quaternions cancel and the vertex collapses to the origin. + + " q1 *= (dot(q0, q1) < 0.0) ? -1.0 : 1.0;\n" + + " q2 *= (dot(q0, q2) < 0.0) ? -1.0 : 1.0;\n" + + " q3 *= (dot(q0, q3) < 0.0) ? -1.0 : 1.0;\n" + + " vec4 q = q0 * aWeights.x + q1 * aWeights.y\n" + + " + q2 * aWeights.z + q3 * aWeights.w;\n" + + " float qLength = length(q);\n" + + " q = (qLength > 0.0001) ? q / qLength : vec4(0.0, 0.0, 0.0, 1.0);\n" + + " vec4 t = uBoneT[j0] * aWeights.x + uBoneT[j1] * aWeights.y\n" + + " + uBoneT[j2] * aWeights.z + uBoneT[j3] * aWeights.w;\n" + + " vec3 skinned = qrot(q, morphed * t.w) + t.xyz;\n" + + " vec3 skinnedNormal = qrot(q, aNormal);\n" + + " vec4 world = uRoot * vec4(skinned, 1.0);\n" + + " vWorldPosition = world.xyz;\n" + + " vWorldNormal = mat3(uRoot) * skinnedNormal;\n" + + " vUv = aUv;\n" + + " gl_Position = uViewProjection * world;\n" + + "}\n"; + } + + /** Exact glTF linear-blend skinning for the complete deformation skeleton. */ + private static String characterMatrixVertexShader(int jointCount) { + return + "uniform mat4 uViewProjection;\n" + + "uniform mat4 uRoot;\n" + + "uniform vec4 uBoneM[" + (jointCount * 3) + "];\n" + + "uniform vec3 uMorphWeights;\n" + + "uniform float uOutline;\n" + + "attribute vec3 aPosition;\n" + + "attribute vec3 aNormal;\n" + + "attribute vec2 aUv;\n" + + "attribute vec4 aWeights;\n" + + "attribute vec4 aJoints;\n" + + "attribute vec3 aMorphBlink;\n" + + "attribute vec3 aMorphSmile;\n" + + "attribute vec3 aMorphTrouble;\n" + + "varying vec3 vWorldPosition;\n" + + "varying vec3 vWorldNormal;\n" + + "varying vec2 vUv;\n" + + "vec3 skinPoint(int joint, vec3 point) {\n" + + " int base = joint * 3;\n" + + " vec4 value = vec4(point, 1.0);\n" + + " return vec3(dot(uBoneM[base], value),\n" + + " dot(uBoneM[base + 1], value),\n" + + " dot(uBoneM[base + 2], value));\n" + + "}\n" + + "vec3 skinVector(int joint, vec3 vector) {\n" + + " int base = joint * 3;\n" + + " vec4 value = vec4(vector, 0.0);\n" + + " return vec3(dot(uBoneM[base], value),\n" + + " dot(uBoneM[base + 1], value),\n" + + " dot(uBoneM[base + 2], value));\n" + + "}\n" + + "void main() {\n" + + " vec3 morphed = aPosition\n" + + " + uMorphWeights.x * aMorphBlink\n" + + " + uMorphWeights.y * aMorphSmile\n" + + " + uMorphWeights.z * aMorphTrouble\n" + + " + aNormal * uOutline;\n" + + " int j0 = int(aJoints.x);\n" + + " int j1 = int(aJoints.y);\n" + + " int j2 = int(aJoints.z);\n" + + " int j3 = int(aJoints.w);\n" + + " vec3 skinned = skinPoint(j0, morphed) * aWeights.x\n" + + " + skinPoint(j1, morphed) * aWeights.y\n" + + " + skinPoint(j2, morphed) * aWeights.z\n" + + " + skinPoint(j3, morphed) * aWeights.w;\n" + + " vec3 skinnedNormal = skinVector(j0, aNormal) * aWeights.x\n" + + " + skinVector(j1, aNormal) * aWeights.y\n" + + " + skinVector(j2, aNormal) * aWeights.z\n" + + " + skinVector(j3, aNormal) * aWeights.w;\n" + + " vec4 world = uRoot * vec4(skinned, 1.0);\n" + + " vWorldPosition = world.xyz;\n" + + " vWorldNormal = mat3(uRoot) * skinnedNormal;\n" + + " vUv = aUv;\n" + + " gl_Position = uViewProjection * world;\n" + + "}\n"; + } + + private static final String CHARACTER_FRAGMENT_SHADER = + "precision mediump float;\n" + + "uniform sampler2D uTexture;\n" + + "uniform vec3 uCameraPosition;\n" + + "uniform vec3 uRimColor;\n" + + "uniform float uRimStrength;\n" + + "uniform float uEmission;\n" + + "uniform float uAlpha;\n" + + "uniform float uLightFloor;\n" + + "uniform float uFlat;\n" + + "varying vec3 vWorldPosition;\n" + + "varying vec3 vWorldNormal;\n" + + "varying vec2 vUv;\n" + + "void main() {\n" + + " vec4 tex = texture2D(uTexture, vUv);\n" + + " float alpha = tex.a * uAlpha;\n" + + " if (uFlat > 0.5) {\n" + // Inverted-hull outline pass: a flat ink color sells the anime look. + + " gl_FragColor = vec4(vec3(0.15, 0.11, 0.14) * alpha, alpha);\n" + + " return;\n" + + " }\n" + + " vec3 n = normalize(vWorldNormal);\n" + + " vec3 viewDir = normalize(uCameraPosition - vWorldPosition);\n" + + " vec3 keyDir = normalize(vec3(-0.42, 0.72, 1.15));\n" + + " float lambert = dot(n, keyDir);\n" + // Three-band toon ramp: a cool core shadow, a wide midtone and a soft + // highlight. Two bands read as plastic on the large cape panels. + + " float core = smoothstep(-0.32, -0.02, lambert);\n" + + " float mid = smoothstep(0.02, 0.34, lambert);\n" + + " float band = max(0.34 * core + 0.66 * mid, uLightFloor);\n" + + " float highlight = smoothstep(0.62, 0.86, lambert) * 0.09;\n" + + " vec3 shadowTint = vec3(0.72, 0.71, 0.86);\n" + + " vec3 color = tex.rgb * mix(shadowTint, vec3(1.04, 1.03, 1.0), band)\n" + + " + vec3(highlight);\n" + + " float facing = clamp(dot(n, viewDir), 0.0, 1.0);\n" + + " float rim = pow(1.0 - facing, 2.6) * uRimStrength;\n" + + " color += uRimColor * rim * 0.55 + tex.rgb * uEmission;\n" + + " gl_FragColor = vec4(color * alpha, alpha);\n" + + "}\n"; + + private final Context appContext; + private final float[] projection = new float[16]; + private final float[] view = new float[16]; + private final float[] viewProjection = new float[16]; + private final float[] inverseModel = new float[16]; + private final float[] normalMatrix = new float[9]; + private final float[][] matrixPool = new float[32][16]; + private int matrixCursor; + + private volatile ActionState actionState = new ActionState( + MascotActionBus.Action.IDLE, SystemClock.uptimeMillis()); + private volatile MascotActionBus.AudioFrame audioFrame = + MascotActionBus.AudioFrame.SILENCE; + private volatile float userRotation; + + private long animationEpochMs; + private float cameraFollowY; + + // Effect pipeline (sparkle motes only). + private int effectProgram; + private int effectPositionHandle; + private int effectNormalHandle; + private int effectModelHandle; + private int effectViewProjectionHandle; + private int effectNormalMatrixHandle; + private int effectBaseColorHandle; + private int effectRimColorHandle; + private int effectCameraHandle; + private int effectEmissionHandle; + private int effectRimStrengthHandle; + private int effectAlphaHandle; + private Mesh sphere; + + // Skinned character pipeline. + private int characterProgram; + private int charPositionHandle; + private int charNormalHandle; + private int charUvHandle; + private int charWeightsHandle; + private int charJointsHandle; + private int charMorphBlinkHandle; + private int charMorphSmileHandle; + private int charMorphTroubleHandle; + private int charViewProjectionHandle; + private int charRootHandle; + private int charBoneMHandle; + private int charBoneQHandle; + private int charBoneTHandle; + private int charMorphWeightsHandle; + private int charOutlineHandle; + private int charTextureHandle; + private int charCameraHandle; + private int charRimColorHandle; + private int charRimStrengthHandle; + private int charEmissionHandle; + private int charAlphaHandle; + private int charLightFloorHandle; + private int charFlatHandle; + + private MascotGlbModel model; + private MascotRig rig; + private MascotAnimator animator; + private MascotClipLibrary clips; + private boolean modelBroken; + private boolean fullRig; + private String selectedClipAsset; + private float outlineRaw; + private float expressionLiftRaw; + private int[] textureIds = new int[0]; + private float[] boneQ = new float[0]; + private float[] boneT = new float[0]; + private float[] boneRows = new float[0]; + private final float[] rootMatrix = new float[16]; + + MascotRenderer(Context appContext) { + this.appContext = appContext; + } + + void setAction(MascotActionBus.Action next, long startedMs) { + // One immutable volatile snapshot keeps action and epoch coherent on the GL thread. + actionState = new ActionState( + next == null ? MascotActionBus.Action.IDLE : next, startedMs); + } + + void setAudioFrame(MascotActionBus.AudioFrame frame) { + audioFrame = frame == null ? MascotActionBus.AudioFrame.SILENCE : frame; + } + + void setUserRotation(float degrees) { + userRotation = degrees; + } + + float getUserRotation() { + return userRotation; + } + + @Override + public void onSurfaceCreated(GL10 ignored, EGLConfig config) { + markContextLost(); + effectProgram = createProgram(EFFECT_VERTEX_SHADER, EFFECT_FRAGMENT_SHADER); + effectPositionHandle = GLES20.glGetAttribLocation(effectProgram, "aPosition"); + effectNormalHandle = GLES20.glGetAttribLocation(effectProgram, "aNormal"); + effectModelHandle = GLES20.glGetUniformLocation(effectProgram, "uModel"); + effectViewProjectionHandle = + GLES20.glGetUniformLocation(effectProgram, "uViewProjection"); + effectNormalMatrixHandle = + GLES20.glGetUniformLocation(effectProgram, "uNormalMatrix"); + effectBaseColorHandle = GLES20.glGetUniformLocation(effectProgram, "uBaseColor"); + effectRimColorHandle = GLES20.glGetUniformLocation(effectProgram, "uRimColor"); + effectCameraHandle = GLES20.glGetUniformLocation(effectProgram, "uCameraPosition"); + effectEmissionHandle = GLES20.glGetUniformLocation(effectProgram, "uEmission"); + effectRimStrengthHandle = GLES20.glGetUniformLocation(effectProgram, "uRimStrength"); + effectAlphaHandle = GLES20.glGetUniformLocation(effectProgram, "uAlpha"); + + sphere = Mesh.uvSphere(20, 14); + sphere.upload(); + + setUpCharacterPipeline(); + + GLES20.glClearColor(0f, 0f, 0f, 0f); + GLES20.glEnable(GLES20.GL_DEPTH_TEST); + GLES20.glDepthFunc(GLES20.GL_LEQUAL); + GLES20.glEnable(GLES20.GL_BLEND); + restorePremultipliedBlend(); + GLES20.glDisable(GLES20.GL_CULL_FACE); + + animationEpochMs = SystemClock.uptimeMillis(); + if (animator != null) animator.reset(); + } + + private void setUpCharacterPipeline() { + try { + int[] maxVectors = new int[1]; + GLES20.glGetIntegerv(GLES20.GL_MAX_VERTEX_UNIFORM_VECTORS, maxVectors, 0); + if (model == null) { + selectMascotAssets(maxVectors[0]); + rig = new MascotRig(model); + } + int jointCount = model.joints.length; + float modelHeight = model.boundsMax[1] - model.boundsMin[1]; + outlineRaw = modelHeight * OUTLINE_HEIGHT_FRACTION; + expressionLiftRaw = modelHeight * EXPRESSION_LIFT_HEIGHT_FRACTION; + int requiredVectors = requiredVertexVectors(jointCount, fullRig); + Log.i(TAG, "GL_MAX_VERTEX_UNIFORM_VECTORS=" + maxVectors[0] + + " required=" + requiredVectors + " joints=" + jointCount + + " tier=" + (fullRig ? "full-lbs" : "compat-quaternion")); + if (maxVectors[0] > 0 && maxVectors[0] < requiredVectors) { + throw new IllegalStateException("Vertex uniform budget too small: " + + maxVectors[0] + " < " + requiredVectors); + } + if (boneQ.length != jointCount * 4) { + boneQ = new float[jointCount * 4]; + boneT = new float[jointCount * 4]; + } + if (boneRows.length != jointCount * 12) { + boneRows = new float[jointCount * 12]; + } + if (clips == null) { + clips = MascotClipLibrary.loadOrEmpty( + appContext, selectedClipAsset, model); + } + if (animator == null) { + animator = new MascotAnimator(rig, clips); + } + String vertexShader = fullRig + ? characterMatrixVertexShader(jointCount) + : characterQuaternionVertexShader(jointCount); + characterProgram = createProgram(vertexShader, CHARACTER_FRAGMENT_SHADER); + charPositionHandle = GLES20.glGetAttribLocation(characterProgram, "aPosition"); + charNormalHandle = GLES20.glGetAttribLocation(characterProgram, "aNormal"); + charUvHandle = GLES20.glGetAttribLocation(characterProgram, "aUv"); + charWeightsHandle = GLES20.glGetAttribLocation(characterProgram, "aWeights"); + charJointsHandle = GLES20.glGetAttribLocation(characterProgram, "aJoints"); + charMorphBlinkHandle = + GLES20.glGetAttribLocation(characterProgram, "aMorphBlink"); + charMorphSmileHandle = + GLES20.glGetAttribLocation(characterProgram, "aMorphSmile"); + charMorphTroubleHandle = + GLES20.glGetAttribLocation(characterProgram, "aMorphTrouble"); + charViewProjectionHandle = + GLES20.glGetUniformLocation(characterProgram, "uViewProjection"); + charRootHandle = GLES20.glGetUniformLocation(characterProgram, "uRoot"); + charBoneMHandle = GLES20.glGetUniformLocation(characterProgram, "uBoneM"); + charBoneQHandle = GLES20.glGetUniformLocation(characterProgram, "uBoneQ"); + charBoneTHandle = GLES20.glGetUniformLocation(characterProgram, "uBoneT"); + charMorphWeightsHandle = + GLES20.glGetUniformLocation(characterProgram, "uMorphWeights"); + charOutlineHandle = GLES20.glGetUniformLocation(characterProgram, "uOutline"); + charTextureHandle = GLES20.glGetUniformLocation(characterProgram, "uTexture"); + charCameraHandle = + GLES20.glGetUniformLocation(characterProgram, "uCameraPosition"); + charRimColorHandle = GLES20.glGetUniformLocation(characterProgram, "uRimColor"); + charRimStrengthHandle = + GLES20.glGetUniformLocation(characterProgram, "uRimStrength"); + charEmissionHandle = GLES20.glGetUniformLocation(characterProgram, "uEmission"); + charAlphaHandle = GLES20.glGetUniformLocation(characterProgram, "uAlpha"); + charLightFloorHandle = + GLES20.glGetUniformLocation(characterProgram, "uLightFloor"); + charFlatHandle = GLES20.glGetUniformLocation(characterProgram, "uFlat"); + + model.upload(); + uploadTextures(); + modelBroken = false; + } catch (Exception failure) { + // The mascot must never take the cockpit down: fall back to effects-only. + Log.e(TAG, "Paimon model pipeline unavailable", failure); + modelBroken = true; + characterProgram = 0; + } + } + + private void selectMascotAssets(int maxVertexVectors) throws Exception { + try { + MascotGlbModel candidate = MascotGlbModel.load(appContext, FULL_MODEL_ASSET); + if (candidate.joints.length <= MascotPose.JOINT_COUNT) { + throw new IllegalStateException("Full mascot asset lost its deformation bones"); + } + int required = requiredVertexVectors(candidate.joints.length, true); + if (maxVertexVectors <= 0 || maxVertexVectors >= required) { + model = candidate; + selectedClipAsset = FULL_CLIP_ASSET; + fullRig = true; + Log.i(TAG, "Selected full mascot deformation rig: " + + candidate.joints.length + " joints"); + return; + } + Log.w(TAG, "Full mascot rig needs " + required + " vertex vectors; GPU reports " + + maxVertexVectors + ", selecting compatibility rig"); + } catch (Exception fullFailure) { + Log.w(TAG, "Full mascot asset unavailable; selecting compatibility rig", + fullFailure); + } + + model = MascotGlbModel.load(appContext, COMPAT_MODEL_ASSET); + selectedClipAsset = COMPAT_CLIP_ASSET; + fullRig = false; + } + + private static int requiredVertexVectors(int jointCount, boolean matrixPalette) { + return jointCount * (matrixPalette ? 3 : 2) + UNIFORM_VECTOR_OVERHEAD; + } + + private void uploadTextures() { + deleteTextures(); + textureIds = new int[model.images.length]; + GLES20.glGenTextures(textureIds.length, textureIds, 0); + BitmapFactory.Options options = new BitmapFactory.Options(); + // Straight (non-premultiplied) alpha: the fragment shader premultiplies itself. + options.inPremultiplied = false; + for (int i = 0; i < textureIds.length; i++) { + byte[] encoded = model.images[i]; + Bitmap bitmap = BitmapFactory.decodeByteArray( + encoded, 0, encoded.length, options); + if (bitmap == null) { + throw new IllegalStateException("Mascot texture " + i + " failed to decode"); + } + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIds[i]); + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); + GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR_MIPMAP_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, + GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + bitmap.recycle(); + } + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); + } + + private void deleteTextures() { + if (textureIds.length > 0) { + GLES20.glDeleteTextures(textureIds.length, textureIds, 0); + } + textureIds = new int[0]; + } + + @Override + public void onSurfaceChanged(GL10 ignored, int width, int height) { + int safeWidth = Math.max(1, width); + int safeHeight = Math.max(1, height); + GLES20.glViewport(0, 0, safeWidth, safeHeight); + float aspect = safeWidth / (float) safeHeight; + Matrix.perspectiveM(projection, 0, 34.5f, aspect, 1f, 18f); + } + + @Override + public void onDrawFrame(GL10 ignored) { + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); + if (effectProgram == 0 || sphere == null) return; + + final long nowMs = SystemClock.uptimeMillis(); + final ActionState actionSnapshot = actionState; + // Keep the shared clock in double precision so animation frequencies never jump. + final double nowSeconds = (nowMs - animationEpochMs) * 0.001d; + + MascotPose pose = null; + if (animator != null && !modelBroken) { + animator.setAction(actionSnapshot.action, actionSnapshot.startedMs); + animator.setAudioFrame(audioFrame); + animator.setContext(MascotActionBus.getContext()); + pose = animator.evaluate(nowMs, nowSeconds); + pose.rootYaw += userRotation; + } + + // The camera follows a fraction of the vertical motion, so a full celebratory jump + // stays inside an overlay square without shrinking the character the rest of the time. + float targetFollow = pose == null ? 0f : pose.rootY * 0.55f; + cameraFollowY += (targetFollow - cameraFollowY) * 0.25f; + Matrix.setLookAtM(view, 0, + 0f, cameraFollowY, CAMERA_Z, + 0f, cameraFollowY, 0f, + 0f, 1f, 0f); + Matrix.multiplyMM(viewProjection, 0, projection, 0, view, 0); + + if (pose != null && pose.sparkle > 0.02f) { + drawSparkles(pose, actionSnapshot, nowSeconds, nowMs); + } + if (pose != null) drawCharacter(pose); + } + + // ------------------------------------------------------------------ + // Skinned character + // ------------------------------------------------------------------ + + private void drawCharacter(MascotPose pose) { + if (modelBroken || characterProgram == 0 || model == null) return; + + if (fullRig) rig.computeMatrixPalette(pose, boneRows); + else rig.computePalette(pose, boneQ, boneT); + rig.buildRootMatrix(pose, BODY_CENTER_Y, rootMatrix); + + GLES20.glUseProgram(characterProgram); + GLES20.glUniformMatrix4fv(charViewProjectionHandle, 1, false, viewProjection, 0); + GLES20.glUniformMatrix4fv(charRootHandle, 1, false, rootMatrix, 0); + if (fullRig) { + GLES20.glUniform4fv(charBoneMHandle, model.joints.length * 3, boneRows, 0); + } else { + GLES20.glUniform4fv(charBoneQHandle, model.joints.length, boneQ, 0); + GLES20.glUniform4fv(charBoneTHandle, model.joints.length, boneT, 0); + } + GLES20.glUniform3f(charMorphWeightsHandle, + clamp01(pose.blink), clamp01(pose.smile), clamp01(pose.trouble)); + GLES20.glUniform3f(charCameraHandle, 0f, cameraFollowY, CAMERA_Z); + GLES20.glUniform3fv(charRimColorHandle, 1, pose.rimColor, 0); + GLES20.glUniform1f(charRimStrengthHandle, pose.rimStrength); + GLES20.glUniform1f(charEmissionHandle, pose.emission); + GLES20.glUniform1i(charTextureHandle, 0); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + + MascotGlbModel.Primitive[] primitives = model.primitives; + + // Pass 1: opaque body parts in the authored material order (eye white under iris). + for (MascotGlbModel.Primitive primitive : primitives) { + if ("expression".equals(primitive.materialName)) continue; + drawPrimitive(primitive, 0f, false); + } + + // Pass 2: inverted-hull ink outline around body, clothes, hair and cape. + GLES20.glEnable(GLES20.GL_CULL_FACE); + GLES20.glCullFace(GLES20.GL_FRONT); + for (MascotGlbModel.Primitive primitive : primitives) { + String name = primitive.materialName; + if ("face".equals(name) || "skin".equals(name) + || "clothes".equals(name) || "hair".equals(name) + || "cape".equals(name)) { + drawPrimitive(primitive, outlineRaw, true); + } + } + GLES20.glDisable(GLES20.GL_CULL_FACE); + + // Pass 3: expression overlay floats just above the face and never writes depth. + GLES20.glDepthMask(false); + for (MascotGlbModel.Primitive primitive : primitives) { + if ("expression".equals(primitive.materialName)) { + drawPrimitive(primitive, expressionLiftRaw, false); + } + } + GLES20.glDepthMask(true); + + GLES20.glDisableVertexAttribArray(charPositionHandle); + GLES20.glDisableVertexAttribArray(charNormalHandle); + GLES20.glDisableVertexAttribArray(charUvHandle); + GLES20.glDisableVertexAttribArray(charWeightsHandle); + GLES20.glDisableVertexAttribArray(charJointsHandle); + GLES20.glDisableVertexAttribArray(charMorphBlinkHandle); + GLES20.glDisableVertexAttribArray(charMorphSmileHandle); + GLES20.glDisableVertexAttribArray(charMorphTroubleHandle); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } + + private void drawPrimitive( + MascotGlbModel.Primitive primitive, float outline, boolean flat) { + GLES20.glUniform1f(charOutlineHandle, outline); + GLES20.glUniform1f(charFlatHandle, flat ? 1f : 0f); + GLES20.glUniform1f(charAlphaHandle, primitive.baseAlpha); + GLES20.glUniform1f(charLightFloorHandle, lightFloor(primitive.materialName)); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureIds[primitive.imageIndex]); + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, primitive.staticVbo()); + GLES20.glEnableVertexAttribArray(charPositionHandle); + GLES20.glVertexAttribPointer(charPositionHandle, 3, GLES20.GL_FLOAT, false, + MascotGlbModel.STATIC_STRIDE_BYTES, 0); + GLES20.glEnableVertexAttribArray(charNormalHandle); + GLES20.glVertexAttribPointer(charNormalHandle, 3, GLES20.GL_FLOAT, false, + MascotGlbModel.STATIC_STRIDE_BYTES, 3 * FLOAT_BYTES); + GLES20.glEnableVertexAttribArray(charUvHandle); + GLES20.glVertexAttribPointer(charUvHandle, 2, GLES20.GL_FLOAT, false, + MascotGlbModel.STATIC_STRIDE_BYTES, 6 * FLOAT_BYTES); + GLES20.glEnableVertexAttribArray(charWeightsHandle); + GLES20.glVertexAttribPointer(charWeightsHandle, 4, GLES20.GL_FLOAT, false, + MascotGlbModel.STATIC_STRIDE_BYTES, 8 * FLOAT_BYTES); + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, primitive.jointVbo()); + GLES20.glEnableVertexAttribArray(charJointsHandle); + GLES20.glVertexAttribPointer(charJointsHandle, 4, GLES20.GL_UNSIGNED_BYTE, false, + 4, 0); + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, primitive.morphVbo()); + GLES20.glEnableVertexAttribArray(charMorphBlinkHandle); + GLES20.glVertexAttribPointer(charMorphBlinkHandle, 3, GLES20.GL_FLOAT, false, + MascotGlbModel.MORPH_STRIDE_BYTES, 0); + GLES20.glEnableVertexAttribArray(charMorphSmileHandle); + GLES20.glVertexAttribPointer(charMorphSmileHandle, 3, GLES20.GL_FLOAT, false, + MascotGlbModel.MORPH_STRIDE_BYTES, 3 * FLOAT_BYTES); + GLES20.glEnableVertexAttribArray(charMorphTroubleHandle); + GLES20.glVertexAttribPointer(charMorphTroubleHandle, 3, GLES20.GL_FLOAT, false, + MascotGlbModel.MORPH_STRIDE_BYTES, 6 * FLOAT_BYTES); + + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, primitive.ibo()); + GLES20.glDrawElements(GLES20.GL_TRIANGLES, primitive.indexCount(), + GLES20.GL_UNSIGNED_SHORT, 0); + } + + /** Anime faces read best when their shading stays bright and flat. */ + private static float lightFloor(String materialName) { + switch (materialName) { + case "face": return 0.80f; + case "expression": return 1.0f; + case "eye_white": return 0.92f; + case "eye_iris": return 0.95f; + case "skin": return 0.46f; + case "hair": return 0.26f; + case "clothes": return 0.22f; + // The cape hangs behind her and used to fall almost black in the shadow band. + case "cape": return 0.34f; + default: return 0.10f; + } + } + + // ------------------------------------------------------------------ + // Sparkles + // ------------------------------------------------------------------ + + /** + * Small background motes with action-specific motion language. They remain discrete points + * rather than a permanent disc: climate rises, windows stream sideways, device inspection + * scans a grid, errors fall, and success expands in a compact burst. + */ + private void drawSparkles(MascotPose pose, ActionState state, double seconds, long nowMs) { + GLES20.glUseProgram(effectProgram); + GLES20.glUniformMatrix4fv(effectViewProjectionHandle, 1, false, viewProjection, 0); + GLES20.glUniform3f(effectCameraHandle, 0f, cameraFollowY, CAMERA_Z); + GLES20.glDepthMask(false); + // Add light in RGB while alpha still follows source-over composition. Additive alpha + // would quickly make the whole transparent overlay rectangle opaque. + GLES20.glBlendFuncSeparate( + GLES20.GL_ONE, GLES20.GL_ONE, + GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA); + + matrixCursor = 0; + float[] world = identity(); + float density = clamp01(pose.sparkle); + MascotActionBus.Action action = state.action == null + ? MascotActionBus.Action.IDLE : state.action; + int count = Math.max(3, Math.round(3f + density * 10f)); + if (action == MascotActionBus.Action.IDLE) count = Math.min(4, count); + else if (action == MascotActionBus.Action.DEVICE) count = Math.max(9, count); + else count = Math.max(7, count); + float elapsed = Math.max(0f, (nowMs - state.startedMs) * 0.001f); + for (int i = 0; i < count; i++) { + double phase = seconds * (1.05d + i * 0.11d) + i * 1.37d; + float radius = 0.62f + 0.42f * (float) Math.sin(phase * 0.7d + i); + float x = (float) Math.cos(phase) * radius; + float y = pose.rootY + 0.15f + (float) Math.sin(phase * 1.23d) * 0.86f; + float twinkle = 0.45f + 0.55f * (float) Math.sin(seconds * 5.2d + i * 2.1d); + float size = (0.044f + 0.050f * density) * (0.68f + twinkle * 0.74f); + float[] color = i % 3 == 0 + ? MascotPalette.STAR_WHITE + : (i % 3 == 1 ? pose.rimColor : MascotPalette.GOLD); + float fade = 1f; + float scaleX = size; + float scaleY = size; + + switch (action) { + case SUCCESS: { + float burst = clamp01(elapsed / 1.55f); + float angle = i * 2.39996f; + float burstRadius = 0.20f + burst * (0.70f + (i % 3) * 0.10f); + x = (float) Math.cos(angle) * burstRadius; + y = pose.rootY + 0.12f + (float) Math.sin(angle) * burstRadius; + color = i % 2 == 0 ? MascotPalette.GOLD : MascotPalette.STAR_WHITE; + fade = 1f - burst * 0.82f; + break; + } + case ERROR: { + float fall = repeat(seconds * 0.22d + i / (double) count); + x = -0.78f + (1.56f * i) / Math.max(1, count - 1) + + (float) Math.sin(phase) * 0.06f; + y = pose.rootY + 0.82f - fall * 1.64f; + color = i % 2 == 0 ? MascotPalette.RED : MascotPalette.VIOLET; + scaleY *= 1.65f; + fade = 0.35f + (1f - fall) * 0.65f; + break; + } + case CLIMATE: { + float rise = repeat(seconds * 0.16d + i / (double) count); + x = (float) Math.sin(rise * Math.PI * 2d + i * 0.8d) + * (0.38f + (i % 3) * 0.14f); + y = pose.rootY - 0.82f + rise * 1.64f; + color = i % 2 == 0 ? MascotPalette.COOL : MascotPalette.STAR_WHITE; + scaleY *= 1.25f; + fade = 0.25f + (1f - Math.abs(rise - 0.5f) * 1.2f) * 0.75f; + break; + } + case WINDOW: { + float stream = repeat(seconds * 0.32d + i / (double) count); + x = -0.95f + stream * 1.90f; + y = pose.rootY - 0.55f + (i % 5) * 0.27f + + (float) Math.sin(phase) * 0.05f; + color = i % 2 == 0 ? MascotPalette.TEAL : MascotPalette.COOL; + scaleX *= 2.4f; + scaleY *= 0.62f; + fade = 0.35f + (1f - Math.abs(stream - 0.5f) * 1.3f) * 0.65f; + break; + } + case LIGHT: { + float angle = i * 2.39996f + (float) seconds * 0.35f; + float lightPulse = 0.5f + 0.5f + * (float) Math.sin(seconds * 3.3d + i * 0.9d); + float lightRadius = 0.32f + lightPulse * 0.52f; + x = (float) Math.cos(angle) * lightRadius; + y = pose.rootY + 0.10f + (float) Math.sin(angle) * lightRadius; + color = i % 3 == 0 ? MascotPalette.STAR_WHITE : MascotPalette.GOLD; + size *= 0.82f + lightPulse * 0.52f; + scaleX = size; + scaleY = size; + fade = 0.48f + lightPulse * 0.52f; + break; + } + case SUNSHADE: { + float unit = i / (float) Math.max(1, count - 1); + float arc = (float) Math.PI * (0.12f + unit * 0.76f) + + (float) Math.sin(seconds * 0.45d) * 0.04f; + x = (float) Math.cos(arc) * 0.88f; + y = pose.rootY + 0.24f + (float) Math.sin(arc) * 0.67f; + color = i % 2 == 0 ? MascotPalette.VIOLET : MascotPalette.GOLD; + fade = 0.52f + twinkle * 0.48f; + break; + } + case DEVICE: { + float unit = i / (float) Math.max(1, count - 1); + float scan = repeat(elapsed / 2.15d); + float gridY = -0.66f + repeat(i * 0.382d) * 1.32f; + x = -0.72f + unit * 1.44f; + y = pose.rootY + gridY; + float scanDistance = Math.abs((gridY + 0.66f) / 1.32f - scan); + fade = 0.24f + 0.76f + * (float) Math.exp(-scanDistance * 12f); + color = i % 3 == 0 ? MascotPalette.STAR_WHITE : MascotPalette.TEAL; + scaleX *= 0.72f; + scaleY *= 0.72f; + break; + } + case TAP_HEAD: { + float angle = i * 2.39996f + (float) seconds * 0.45f; + x = (float) Math.cos(angle) * 0.42f; + y = pose.rootY + 0.62f + (float) Math.sin(angle) * 0.30f; + color = i % 2 == 0 ? MascotPalette.GOLD : MascotPalette.STAR_WHITE; + fade = 0.45f + twinkle * 0.55f; + break; + } + case GREET: { + float wave = repeat(seconds * 0.30d + i / (double) count); + x = -0.55f + (float) Math.sin(phase) * (0.15f + wave * 0.16f); + y = pose.rootY + 0.16f + wave * 0.72f; + color = i % 2 == 0 ? MascotPalette.TEAL : MascotPalette.STAR_WHITE; + fade = 1f - wave * 0.62f; + break; + } + case TAP_BODY: { + float pulse = repeat(elapsed * 0.72d + i / (double) count); + float angle = i * 2.39996f; + float pulseRadius = 0.18f + pulse * 0.52f; + x = (float) Math.cos(angle) * pulseRadius; + y = pose.rootY + 0.03f + (float) Math.sin(angle) * pulseRadius * 0.72f; + color = i % 2 == 0 ? MascotPalette.VIOLET : MascotPalette.STAR_WHITE; + fade = 1f - pulse * 0.68f; + break; + } + case TAP_LEFT_HAND: + case TAP_RIGHT_HAND: { + float handSide = action == MascotActionBus.Action.TAP_LEFT_HAND ? -1f : 1f; + float angle = i * 2.39996f + (float) seconds * 0.6f; + x = handSide * 0.58f + (float) Math.cos(angle) * 0.25f; + y = pose.rootY + 0.08f + (float) Math.sin(angle) * 0.34f; + color = i % 2 == 0 ? MascotPalette.TEAL : MascotPalette.STAR_WHITE; + break; + } + case TAP_FEET: { + float angle = i * 2.39996f + (float) seconds * 0.7f; + x = (float) Math.cos(angle) * 0.50f; + y = pose.rootY - 0.64f + (float) Math.sin(angle) * 0.22f; + color = i % 2 == 0 ? MascotPalette.GOLD : MascotPalette.STAR_WHITE; + break; + } + case DRAG: + case DRAG_LEFT: + case DRAG_RIGHT: + case DRAG_UP: + case DRAG_DOWN: { + float trail = repeat(seconds * 0.42d + i / (double) count); + float directionX = action == MascotActionBus.Action.DRAG_LEFT ? 1f + : action == MascotActionBus.Action.DRAG_RIGHT ? -1f : 0f; + float directionY = action == MascotActionBus.Action.DRAG_UP ? -1f + : action == MascotActionBus.Action.DRAG_DOWN ? 1f : 0f; + x = directionX * (0.22f + trail * 0.70f) + + (float) Math.sin(phase) * 0.22f; + y = pose.rootY + directionY * (0.18f + trail * 0.60f) + - 0.34f + (i % 4) * 0.23f; + color = i % 2 == 0 ? MascotPalette.TEAL : MascotPalette.COOL; + fade = 1f - trail * 0.70f; + break; + } + case SLEEP: { + float rise = repeat(seconds * 0.11d + i / (double) count); + x = 0.24f + (float) Math.sin(phase) * 0.36f; + y = pose.rootY - 0.20f + rise * 1.10f; + color = i % 2 == 0 ? MascotPalette.VIOLET : MascotPalette.STAR_WHITE; + fade = 1f - rise * 0.78f; + break; + } + case DRIVE: { + float stream = repeat(seconds * 0.40d + i / (double) count); + x = 0.88f - stream * 1.76f; + y = pose.rootY - 0.52f + (i % 5) * 0.25f; + color = i % 2 == 0 ? MascotPalette.COOL : MascotPalette.STAR_WHITE; + scaleX *= 2f; + scaleY *= 0.55f; + fade = 0.28f + (1f - stream) * 0.72f; + break; + } + case MUSIC: + case IDLE: + default: + // The quiet orbit is reserved for social/ambient states; functional states + // above each have a distinct direction, topology and color signature. + fade = action == MascotActionBus.Action.IDLE + ? 0.34f + twinkle * 0.36f : 0.52f + twinkle * 0.48f; + break; + } + + float visibility = action == MascotActionBus.Action.IDLE + ? density * 1.55f : 0.32f + density * 0.68f; + float particleAlpha = visibility * (0.42f + twinkle * 0.58f) * fade; + float glowX = scaleX * 1.65f; + float glowY = scaleY * 1.65f; + draw(sphere, transform(world, x, y, -0.49f, + glowX, glowY, Math.min(glowX, glowY)), + color, color, 1.25f, 0.75f, particleAlpha * 0.16f); + draw(sphere, transform(world, x, y, -0.48f, + scaleX, scaleY, Math.min(scaleX, scaleY)), + color, color, 1.6f, 1f, + particleAlpha); + } + + restorePremultipliedBlend(); + GLES20.glDepthMask(true); + GLES20.glDisableVertexAttribArray(effectPositionHandle); + GLES20.glDisableVertexAttribArray(effectNormalHandle); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } + + private static void restorePremultipliedBlend() { + GLES20.glBlendFuncSeparate( + GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA, + GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA); + } + + private void draw( + Mesh mesh, + float[] model, + float[] base, + float[] rim, + float emission, + float rimStrength, + float alpha) { + if (mesh == null || mesh.vertexBufferId == 0 || alpha <= 0f) return; + if (!Matrix.invertM(inverseModel, 0, model, 0)) return; + normalMatrix[0] = inverseModel[0]; + normalMatrix[1] = inverseModel[4]; + normalMatrix[2] = inverseModel[8]; + normalMatrix[3] = inverseModel[1]; + normalMatrix[4] = inverseModel[5]; + normalMatrix[5] = inverseModel[9]; + normalMatrix[6] = inverseModel[2]; + normalMatrix[7] = inverseModel[6]; + normalMatrix[8] = inverseModel[10]; + + GLES20.glUniformMatrix4fv(effectModelHandle, 1, false, model, 0); + GLES20.glUniformMatrix3fv(effectNormalMatrixHandle, 1, false, normalMatrix, 0); + GLES20.glUniform3fv(effectBaseColorHandle, 1, base, 0); + GLES20.glUniform3fv(effectRimColorHandle, 1, rim, 0); + GLES20.glUniform1f(effectEmissionHandle, Math.max(0f, emission)); + GLES20.glUniform1f(effectRimStrengthHandle, Math.max(0f, rimStrength)); + GLES20.glUniform1f(effectAlphaHandle, clamp01(alpha)); + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, mesh.vertexBufferId); + GLES20.glEnableVertexAttribArray(effectPositionHandle); + GLES20.glVertexAttribPointer(effectPositionHandle, 3, GLES20.GL_FLOAT, + false, VERTEX_STRIDE, 0); + GLES20.glEnableVertexAttribArray(effectNormalHandle); + GLES20.glVertexAttribPointer(effectNormalHandle, 3, GLES20.GL_FLOAT, + false, VERTEX_STRIDE, 3 * FLOAT_BYTES); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, mesh.indexBufferId); + GLES20.glDrawElements(GLES20.GL_TRIANGLES, mesh.indexCount, + GLES20.GL_UNSIGNED_SHORT, 0); + } + + private float[] identity() { + float[] result = nextMatrix(); + Matrix.setIdentityM(result, 0); + return result; + } + + private float[] transform( + float[] parent, float tx, float ty, float tz, float sx, float sy, float sz) { + float[] result = nextMatrix(); + System.arraycopy(parent, 0, result, 0, 16); + Matrix.translateM(result, 0, tx, ty, tz); + Matrix.scaleM(result, 0, sx, sy, sz); + return result; + } + + private float[] nextMatrix() { + if (matrixCursor >= matrixPool.length) { + // Reusing the last slot corrupts matrices already referenced by the current draw. + matrixCursor = 1; + } + return matrixPool[matrixCursor++]; + } + + void releaseGlObjects() { + if (sphere != null) sphere.release(); + if (model != null) model.release(); + deleteTextures(); + if (effectProgram != 0) GLES20.glDeleteProgram(effectProgram); + if (characterProgram != 0) GLES20.glDeleteProgram(characterProgram); + markContextLost(); + } + + void markContextLost() { + effectProgram = 0; + characterProgram = 0; + sphere = null; + textureIds = new int[0]; + if (model != null) model.markContextLost(); + } + + private static int createProgram(String vertexSource, String fragmentSource) { + int vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource); + int fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + int result = GLES20.glCreateProgram(); + GLES20.glAttachShader(result, vertex); + GLES20.glAttachShader(result, fragment); + GLES20.glLinkProgram(result); + int[] status = new int[1]; + GLES20.glGetProgramiv(result, GLES20.GL_LINK_STATUS, status, 0); + String log = GLES20.glGetProgramInfoLog(result); + GLES20.glDeleteShader(vertex); + GLES20.glDeleteShader(fragment); + if (status[0] == 0) { + GLES20.glDeleteProgram(result); + throw new IllegalStateException("Mascot GL program link failed: " + log); + } + return result; + } + + private static int compileShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] status = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0); + if (status[0] == 0) { + String log = GLES20.glGetShaderInfoLog(shader); + GLES20.glDeleteShader(shader); + throw new IllegalStateException("Mascot GL shader compile failed: " + log); + } + return shader; + } + + private static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } + + private static float repeat(double value) { + return (float) (value - Math.floor(value)); + } + + private static final class ActionState { + final MascotActionBus.Action action; + final long startedMs; + + ActionState(MascotActionBus.Action action, long startedMs) { + this.action = action; + this.startedMs = startedMs; + } + } + } + + /** Interleaved position/normal indexed mesh stored in GPU buffers (sparkles only). */ + private static final class Mesh { + private final float[] vertices; + private final short[] indices; + private int vertexBufferId; + private int indexBufferId; + private int indexCount; + + Mesh(float[] vertices, short[] indices) { + this.vertices = vertices; + this.indices = indices; + } + + void upload() { + release(); + FloatBuffer vertexData = ByteBuffer + .allocateDirect(vertices.length * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer(); + vertexData.put(vertices).position(0); + ShortBuffer indexData = ByteBuffer + .allocateDirect(indices.length * 2) + .order(ByteOrder.nativeOrder()) + .asShortBuffer(); + indexData.put(indices).position(0); + + int[] ids = new int[2]; + GLES20.glGenBuffers(2, ids, 0); + vertexBufferId = ids[0]; + indexBufferId = ids[1]; + indexCount = indices.length; + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vertexBufferId); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, vertices.length * 4, + vertexData, GLES20.GL_STATIC_DRAW); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBufferId); + GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, indices.length * 2, + indexData, GLES20.GL_STATIC_DRAW); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } + + void release() { + if (vertexBufferId != 0 || indexBufferId != 0) { + int[] ids = {vertexBufferId, indexBufferId}; + GLES20.glDeleteBuffers(2, ids, 0); + } + vertexBufferId = 0; + indexBufferId = 0; + indexCount = 0; + } + + static Mesh uvSphere(int slices, int stacks) { + int vertexCount = (slices + 1) * (stacks + 1); + float[] vertices = new float[vertexCount * 6]; + short[] indices = new short[slices * stacks * 6]; + int v = 0; + for (int stack = 0; stack <= stacks; stack++) { + float phi = (float) Math.PI * stack / stacks; + float y = (float) Math.cos(phi); + float ring = (float) Math.sin(phi); + for (int slice = 0; slice <= slices; slice++) { + float theta = (float) (Math.PI * 2.0) * slice / slices; + float x = ring * (float) Math.cos(theta); + float z = ring * (float) Math.sin(theta); + vertices[v++] = x; + vertices[v++] = y; + vertices[v++] = z; + vertices[v++] = x; + vertices[v++] = y; + vertices[v++] = z; + } + } + int index = 0; + for (int stack = 0; stack < stacks; stack++) { + for (int slice = 0; slice < slices; slice++) { + short a = (short) (stack * (slices + 1) + slice); + short b = (short) (a + slices + 1); + indices[index++] = a; + indices[index++] = b; + indices[index++] = (short) (a + 1); + indices[index++] = (short) (a + 1); + indices[index++] = b; + indices[index++] = (short) (b + 1); + } + } + return new Mesh(vertices, indices); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockUsbCanGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockUsbCanGateway.java new file mode 100644 index 0000000..7649ec3 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockUsbCanGateway.java @@ -0,0 +1,84 @@ +package com.seeker.n7cockpit; + +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** Visible, delayed USB-CAN simulator; it never opens a USB device or transmits a real frame. */ +final class MockUsbCanGateway implements UsbCanGateway { + private final Listener listener; + private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(); + private volatile boolean selfTestPassed; + private volatile boolean acEnabled; + private volatile boolean closed; + private volatile int framesSent; + + MockUsbCanGateway(Listener listener) { + this.listener = listener == null ? detail -> { } : listener; + } + + @Override + public String describe() { + return "【模拟环境】CAN 盒:虚拟设备已连接" + + "\n型号:N7 Virtual USBCANFD-200U" + + "\nUSB:3068:0009 · 接口 1 · 授权 已模拟允许" + + "\n端点:5 个(0x01、0x81、0x02、0x82、0x83)" + + "\n自检:" + (selfTestPassed ? "已通过" : "待执行") + + " · 虚拟发送:" + framesSent + " 帧" + + " · 空调:" + (acEnabled ? "开" : "关") + + "\n模式:纯模拟,不访问 USB,不向车辆总线发送报文"; + } + + @Override + public void refresh() { + delayed(() -> listener.onStatus(describe()), 55L); + } + + @Override + public void selfTest() { + listener.onStatus("【模拟环境】正在检查虚拟 CAN 盒…"); + delayed(() -> { + selfTestPassed = true; + listener.onStatus("【模拟环境】USB CAN 自检通过:虚拟接口可打开并独占;未发送实车报文。\n" + + describe()); + }, 180L); + } + + @Override + public void sendAc(boolean enabled) { + listener.onStatus("【模拟环境】正在模拟 CAN 空调" + (enabled ? "开" : "关") + "三帧发送…"); + delayed(() -> { + selfTestPassed = true; + acEnabled = enabled; + framesSent += 3; + listener.onStatus("【模拟环境】CAN 空调" + (enabled ? "开" : "关") + + "模拟成功:ID 0x1DE,3 帧;未触达真实总线。\n" + describe()); + }, 240L); + } + + @Override + public void close() { + closed = true; + worker.shutdownNow(); + } + + @Override + public boolean isSimulation() { + return true; + } + + private void delayed(Runnable runnable, long milliseconds) { + if (closed) { + listener.onStatus("【模拟环境】CAN 网关已关闭"); + return; + } + try { + worker.schedule(() -> { + if (!closed) runnable.run(); + }, milliseconds, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException error) { + listener.onStatus("【模拟环境】CAN 网关已关闭"); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockVehicleGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockVehicleGateway.java new file mode 100644 index 0000000..f3b68ff --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/MockVehicleGateway.java @@ -0,0 +1,255 @@ +package com.seeker.n7cockpit; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** Deterministic emulator implementation with short asynchronous delays and visible mutations. */ +final class MockVehicleGateway implements VehicleGateway { + static final String ENVIRONMENT = "模拟环境 · Android Emulator · 不连接实车"; + private static final long PREPARE_DELAY_MS = 140L; + private static final long COMMAND_DELAY_MS = 90L; + private static final long READ_DELAY_MS = 65L; + + private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(); + private final Map vehicle = new LinkedHashMap<>(); + private final Map climate = new LinkedHashMap<>(); + private boolean prepared; + private boolean closed; + private long revision; + private String lastAction = "模拟器已启动"; + + MockVehicleGateway() { + seedState(); + } + + @Override + public void prepare(Callback callback) { + Callback safe = callbackOrNoOp(callback); + schedule(() -> { + prepared = true; + safe.onResult(true, "【模拟环境】车辆网关已就绪;所有操作仅改变本地演示状态"); + }, PREPARE_DELAY_MS, safe); + } + + @Override + public void sendCommand(String command, Callback callback) { + Callback safe = callbackOrNoOp(callback); + schedule(() -> { + final VehicleCommand parsed; + try { + parsed = VehicleCommand.parse(command); + } catch (IllegalArgumentException error) { + safe.onResult(false, "【模拟环境】" + error.getMessage()); + return; + } + + if (parsed.kind == VehicleCommand.Kind.VEHICLE_STATUS + || parsed.kind == VehicleCommand.Kind.CLIMATE_STATUS) { + safe.onResult(true, "【模拟环境】状态可通过 readSnapshot() 获取"); + return; + } + + apply(parsed); + revision++; + lastAction = actionName(parsed); + safe.onResult(true, "【模拟环境】" + lastAction + " · 状态版本 " + revision); + }, COMMAND_DELAY_MS, safe); + } + + @Override + public void readSnapshot(SnapshotCallback callback) { + SnapshotCallback safe = snapshotCallbackOrNoOp(callback); + try { + worker.schedule(() -> { + if (closed) { + safe.onResult(false, null, "模拟网关已关闭"); + return; + } + // A tiny heartbeat makes it apparent that telemetry is live without being noisy. + vehicle.put("v12", revision % 2 == 0 ? "12.6" : "12.5"); + VehicleSnapshot snapshot = VehicleSnapshot.create(vehicle, climate, true, + ENVIRONMENT, revision, lastAction); + safe.onResult(true, snapshot, "【模拟环境】状态已刷新"); + }, READ_DELAY_MS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException error) { + safe.onResult(false, null, "模拟网关已关闭"); + } + } + + @Override + public void shutdown() { + closed = true; + worker.shutdownNow(); + } + + @Override + public boolean isSimulation() { + return true; + } + + @Override + public String environmentLabel() { + return ENVIRONMENT; + } + + private void apply(VehicleCommand command) { + switch (command.kind) { + case AC_POWER: + case CLIMATE_POWER: + climate.put("power", bit(command.value)); + climate.put("fan", command.value == 1 ? nonZeroFan() : "0"); + break; + case CLIMATE_AUTO: + // DiLink's readback uses 0=automatic and 1=manual. + climate.put("auto", command.value == 1 ? "0" : "1"); + break; + case CLIMATE_CYCLE: + climate.put("cycle", bit(command.value)); + break; + case DEFROST_FRONT: + climate.put("front", bit(command.value)); + break; + case DEFROST_REAR: + climate.put("rear", bit(command.value)); + break; + case CLIMATE_TEMP: + climate.put("temp", Integer.toString(command.value)); + break; + case ALL_WINDOWS: + setAllWindows(command.value); + break; + case WINDOW: + vehicle.put(windowKey(command.target), Integer.toString(command.value)); + break; + case SUNSHADE: + vehicle.put("sunshade", command.value == 1 ? "100" : "0"); + break; + case LIGHT: + if ("DRL".equals(command.target)) { + vehicle.put("drlight", command.value == 1 ? "1" : "2"); + } else { + vehicle.put(command.target.toLowerCase(), bit(command.value)); + } + break; + default: + throw new IllegalStateException("unhandled simulated command " + command.kind); + } + } + + private String nonZeroFan() { + String current = climate.get("fan"); + return current == null || "0".equals(current) || "?".equals(current) ? "3" : current; + } + + private void setAllWindows(int percent) { + String value = Integer.toString(percent); + vehicle.put("wfl", value); + vehicle.put("wfr", value); + vehicle.put("wrl", value); + vehicle.put("wrr", value); + } + + private static String windowKey(String target) { + switch (target) { + case "FL": return "wfl"; + case "FR": return "wfr"; + case "RL": return "wrl"; + case "RR": return "wrr"; + default: throw new IllegalArgumentException("unknown window " + target); + } + } + + private static String actionName(VehicleCommand command) { + switch (command.kind) { + case AC_POWER: + case CLIMATE_POWER: return "空调已" + (command.value == 1 ? "开启" : "关闭"); + case CLIMATE_AUTO: return "空调自动模式已" + (command.value == 1 ? "开启" : "关闭"); + case CLIMATE_CYCLE: return command.value == 1 ? "已切换内循环" : "已切换外循环"; + case DEFROST_FRONT: return "前挡除雾已" + (command.value == 1 ? "开启" : "关闭"); + case DEFROST_REAR: return "后挡除雾已" + (command.value == 1 ? "开启" : "关闭"); + case CLIMATE_TEMP: return "温度已设为 " + command.value + "℃"; + case ALL_WINDOWS: return "四窗已调整到 " + command.value + "%"; + case WINDOW: return command.target + " 车窗已调整到 " + command.value + "%"; + case SUNSHADE: return "遮阳帘已" + (command.value == 1 ? "打开" : "关闭"); + case LIGHT: return command.target + " 灯已" + (command.value == 1 ? "开启" : "关闭"); + default: return command.wireCommand; + } + } + + private void seedState() { + vehicle.put("speed", "0.0"); + vehicle.put("soc", "78.0"); + vehicle.put("mileage", "12684.6"); + vehicle.put("power", "0"); + vehicle.put("v12", "12.6"); + vehicle.put("gear", "1"); + vehicle.put("gun", "1"); + vehicle.put("bms", "0"); + vehicle.put("tfl", "245"); + vehicle.put("tfr", "246"); + vehicle.put("trl", "244"); + vehicle.put("trr", "245"); + vehicle.put("dfl", "0"); + vehicle.put("dfr", "0"); + vehicle.put("drl", "0"); + vehicle.put("drr", "0"); + vehicle.put("wfl", "0"); + vehicle.put("wfr", "0"); + vehicle.put("wrl", "0"); + vehicle.put("wrr", "0"); + vehicle.put("trunk", "0"); + vehicle.put("hood", "0"); + vehicle.put("lock", "2"); + vehicle.put("low", "0"); + vehicle.put("drlight", "1"); + vehicle.put("interior", "0"); + vehicle.put("ambient", "1"); + vehicle.put("sunshade", "0"); + vehicle.put("lightlevel", "3"); + vehicle.put("wiper", "0"); + vehicle.put("autowiper", "1"); + vehicle.put("beltfl", "1"); + vehicle.put("beltfr", "1"); + vehicle.put("batmax", "31"); + vehicle.put("batmin", "29"); + vehicle.put("keybat", "0"); + vehicle.put("can", "ready-mock"); + + climate.put("power", "0"); + climate.put("temp", "24"); + climate.put("fan", "0"); + climate.put("cycle", "1"); + climate.put("inside", "25"); + climate.put("outside", "31"); + climate.put("front", "0"); + climate.put("rear", "0"); + climate.put("auto", "0"); + } + + private void schedule(Runnable action, long delayMillis, Callback callback) { + try { + worker.schedule(() -> { + if (closed) callback.onResult(false, "模拟网关已关闭"); + else action.run(); + }, delayMillis, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException error) { + callback.onResult(false, "模拟网关已关闭"); + } + } + + private static String bit(int value) { + return value == 0 ? "0" : "1"; + } + + private static Callback callbackOrNoOp(Callback callback) { + return callback == null ? (success, detail) -> { } : callback; + } + + private static SnapshotCallback snapshotCallbackOrNoOp(SnapshotCallback callback) { + return callback == null ? (success, snapshot, detail) -> { } : callback; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ModeApiClient.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ModeApiClient.java new file mode 100644 index 0000000..2451d9e --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ModeApiClient.java @@ -0,0 +1,413 @@ +package com.seeker.n7cockpit; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Fixed-purpose REST client for Cognitive_Agent's repository-backed mode API. */ +final class ModeApiClient { + interface CatalogCallback { + void onResult(boolean success, List modes, String detail); + } + + interface ExecutionCallback { + void onResult(boolean success, Execution execution, String detail); + } + + interface EventsCallback { + void onResult(boolean success, Events events, String detail); + } + + interface HardwareAccessCallback { + void onResult(boolean success, HardwareAccess access, String detail); + } + + static final class Mode { + final String id; + final String name; + final String description; + final List domains; + final boolean preset; + + Mode(String id, String name, String description, List domains, + boolean preset) { + this.id = id; + this.name = name; + this.description = description; + this.domains = Collections.unmodifiableList(new ArrayList<>(domains)); + this.preset = preset; + } + + static Mode fromJson(JSONObject value) throws JSONException { + List domains = new ArrayList<>(); + JSONArray rawDomains = value.optJSONArray("domains"); + if (rawDomains != null) { + for (int index = 0; index < rawDomains.length(); index++) { + domains.add(rawDomains.getString(index)); + } + } + return new Mode( + value.getString("id"), + value.optString("name", value.getString("id")), + value.optString("description", ""), + domains, + "preset".equals(value.optString("origin", "preset"))); + } + } + + static final class Execution { + final String executionId; + final String modeId; + final String modeName; + final String status; + final String message; + final String source; + final int progress; + final String vehicleStateSummary; + + Execution(String executionId, String modeId, String modeName, String status, + String message, String source, int progress, String vehicleStateSummary) { + this.executionId = executionId; + this.modeId = modeId; + this.modeName = modeName; + this.status = status; + this.message = message; + this.source = source; + this.progress = progress; + this.vehicleStateSummary = vehicleStateSummary; + } + + static Execution fromJson(JSONObject value) throws JSONException { + JSONObject vehicleState = value.optJSONObject("vehicle_state"); + if (vehicleState == null) vehicleState = value.optJSONObject("result"); + return new Execution( + value.getString("execution_id"), + value.getString("mode_id"), + value.optString("mode_name", value.getString("mode_id")), + value.optString("status", "failed"), + value.optString("message", ""), + value.optString("source", "unknown"), + value.optInt("progress", 0), + vehicleStateSummary(vehicleState)); + } + + private static String vehicleStateSummary(JSONObject result) { + if (result == null) return ""; + List items = new ArrayList<>(); + JSONObject actuator = result.optJSONObject("actuator_state"); + JSONObject passenger = actuator == null ? null : actuator.optJSONObject("passenger"); + JSONObject position = passenger == null ? null : passenger.optJSONObject("position"); + if (position != null) { + appendPosition(items, position, "longitudinal", "前后"); + appendPosition(items, position, "cushion_tilt", "坐垫"); + appendPosition(items, position, "backrest_recline", "靠背"); + appendPosition(items, position, "legrest_height", "腿托"); + appendPosition(items, position, "legrest_extension", "腿托伸缩"); + } + JSONObject domains = result.optJSONObject("domain_state"); + if (domains != null) { + JSONObject climate = domains.optJSONObject("climate"); + if (climate != null) { + items.add("空调 " + climate.optString("power", "--") + + " / 风量 " + climate.optInt("fan_level", 0)); + } + JSONObject roof = domains.optJSONObject("roof"); + if (roof != null) { + items.add("天幕 " + roof.optInt("transmittance_pct", 0) + "%"); + } + JSONObject light = domains.optJSONObject("ambient_light"); + if (light != null) { + items.add("氛围灯 " + light.optString("power", "--") + + " / " + light.optInt("brightness_pct", 0) + "%"); + } + } + return String.join(" · ", items); + } + + private static void appendPosition(List items, JSONObject position, + String key, String label) { + JSONObject axis = position.optJSONObject(key); + if (axis != null) items.add(label + " " + axis.optInt("position_pct", 0) + "%"); + } + + boolean isTerminal() { + return "succeeded".equals(status) + || "partially_succeeded".equals(status) + || "failed".equals(status) + || "cancelled".equals(status) + || "timed_out".equals(status); + } + } + + static final class Events { + final long nextSequence; + final List executions; + final List activeExecutions; + + Events(long nextSequence, List executions, + List activeExecutions) { + this.nextSequence = nextSequence; + this.executions = Collections.unmodifiableList(new ArrayList<>(executions)); + this.activeExecutions = Collections.unmodifiableList( + new ArrayList<>(activeExecutions)); + } + } + + static final class HardwareAccess { + final String state; + final String message; + + HardwareAccess(String state, String message) { + this.state = state; + this.message = message; + } + + static HardwareAccess fromJson(JSONObject value) { + return new HardwareAccess( + value.optString("state", "unknown"), + value.optString("message", "")); + } + } + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_MS = 10_000; + + private final String baseUrl; + private final String token; + private final String vehicleId; + private final String hardwareClientId; + private final ExecutorService worker = Executors.newSingleThreadExecutor(); + + ModeApiClient(String baseUrl, String token, String vehicleId, String hardwareClientId) { + this.baseUrl = trimTrailingSlash(baseUrl); + this.token = token == null ? "" : token.trim(); + this.vehicleId = vehicleId; + this.hardwareClientId = hardwareClientId; + } + + void loadModes(CatalogCallback callback) { + worker.execute(() -> { + try { + Response response = request("GET", vehiclePath("modes"), null, null); + JSONObject body = response.json(); + if (!response.success()) throw new IOException(errorMessage(body, response.code)); + JSONArray values = body.getJSONArray("modes"); + List modes = new ArrayList<>(); + for (int index = 0; index < values.length(); index++) { + Mode mode = Mode.fromJson(values.getJSONObject(index)); + if (mode.preset) modes.add(mode); + } + callback.onResult(true, modes, "已同步 " + modes.size() + " 个功能模式"); + } catch (Exception error) { + callback.onResult(false, Collections.emptyList(), message(error)); + } + }); + } + + void executeMode(String modeId, String executionTarget, ExecutionCallback callback) { + worker.execute(() -> { + try { + JSONObject body = new JSONObject() + .put("mode_id", modeId) + .put("execution_target", executionTarget) + .put("source", "android") + .put("source_device_id", "n7-cockpit-android") + .put("hardware_client_id", hardwareClientId); + Response response = request( + "POST", + vehiclePath("mode-executions"), + body, + UUID.randomUUID().toString()); + JSONObject result = response.json(); + if (!response.success()) throw new IOException(errorMessage(result, response.code)); + Execution execution = Execution.fromJson(result); + callback.onResult(true, execution, execution.message); + } catch (Exception error) { + callback.onResult(false, null, message(error)); + } + }); + } + + void loadEvents(long afterSequence, EventsCallback callback) { + worker.execute(() -> { + try { + Response response = request( + "GET", vehiclePath("events") + "?after=" + afterSequence, null, null); + JSONObject body = response.json(); + if (!response.success()) throw new IOException(errorMessage(body, response.code)); + List executions = new ArrayList<>(); + JSONArray events = body.optJSONArray("events"); + if (events != null) { + for (int index = 0; index < events.length(); index++) { + JSONObject event = events.getJSONObject(index); + if ("mode.execution.updated".equals(event.optString("type"))) { + executions.add(Execution.fromJson(event.getJSONObject("execution"))); + } + } + } + List activeExecutions = new ArrayList<>(); + JSONArray active = body.optJSONArray("active_executions"); + if (active != null) { + for (int index = 0; index < active.length(); index++) { + activeExecutions.add(Execution.fromJson(active.getJSONObject(index))); + } + } + callback.onResult(true, + new Events(body.optLong("next_sequence", afterSequence), executions, + activeExecutions), ""); + } catch (Exception error) { + callback.onResult(false, null, message(error)); + } + }); + } + + void loadExecution(String executionId, ExecutionCallback callback) { + worker.execute(() -> { + try { + String cleanId = executionId == null ? "" : executionId.trim(); + if (!cleanId.matches("mode-exec-[a-z0-9-]+")) { + throw new IOException("execution_id 非法"); + } + Response response = request( + "GET", "/api/v1/mode-executions/" + cleanId, null, null); + JSONObject body = response.json(); + if (!response.success()) throw new IOException(errorMessage(body, response.code)); + Execution execution = Execution.fromJson(body); + callback.onResult(true, execution, execution.message); + } catch (Exception error) { + callback.onResult(false, null, message(error)); + } + }); + } + + void requestHardwareAccess(HardwareAccessCallback callback) { + worker.execute(() -> { + try { + JSONObject body = new JSONObject() + .put("client_id", hardwareClientId) + .put("label", "N7Cockpit Android"); + Response response = request("POST", "/api/hardware/access/request", body, null); + JSONObject result = response.json(); + if (!response.success()) throw new IOException(errorMessage(result, response.code)); + HardwareAccess access = HardwareAccess.fromJson(result); + callback.onResult(true, access, access.message); + } catch (Exception error) { + callback.onResult(false, null, message(error)); + } + }); + } + + void loadHardwareAccess(HardwareAccessCallback callback) { + worker.execute(() -> { + try { + String query = URLEncoder.encode(hardwareClientId, StandardCharsets.UTF_8.name()); + Response response = request( + "GET", "/api/hardware/access/status?client_id=" + query, null, null); + JSONObject result = response.json(); + if (!response.success()) throw new IOException(errorMessage(result, response.code)); + HardwareAccess access = HardwareAccess.fromJson(result); + callback.onResult(true, access, access.message); + } catch (Exception error) { + callback.onResult(false, null, message(error)); + } + }); + } + + void close() { + worker.shutdownNow(); + } + + private String vehiclePath(String resource) { + return "/api/v1/vehicles/" + vehicleId + "/" + resource; + } + + private Response request(String method, String path, JSONObject body, String idempotencyKey) + throws IOException { + HttpURLConnection connection = (HttpURLConnection) new URL(baseUrl + path).openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setUseCaches(false); + connection.setRequestProperty("Accept", "application/json"); + if (!token.isEmpty()) connection.setRequestProperty("Authorization", "Bearer " + token); + if (idempotencyKey != null) { + connection.setRequestProperty("Idempotency-Key", idempotencyKey); + } + if (body != null) { + byte[] payload = body.toString().getBytes(StandardCharsets.UTF_8); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + connection.setFixedLengthStreamingMode(payload.length); + try (OutputStream output = connection.getOutputStream()) { + output.write(payload); + } + } + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 400 + ? connection.getInputStream() : connection.getErrorStream(); + String responseBody = readAll(stream); + connection.disconnect(); + return new Response(code, responseBody); + } + + private static String readAll(InputStream stream) throws IOException { + if (stream == null) return "{}"; + StringBuilder result = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(stream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) result.append(line); + } + return result.toString(); + } + + private static String errorMessage(JSONObject body, int code) { + return body.optString("message", body.optString("error", "HTTP " + code)); + } + + private static String message(Exception error) { + String value = error.getMessage(); + return value == null || value.trim().isEmpty() + ? error.getClass().getSimpleName() : value; + } + + private static String trimTrailingSlash(String value) { + String result = value == null ? "" : value.trim(); + while (result.endsWith("/")) result = result.substring(0, result.length() - 1); + return result; + } + + private static final class Response { + final int code; + final String body; + + Response(int code, String body) { + this.code = code; + this.body = body; + } + + boolean success() { + return code >= 200 && code < 300; + } + + JSONObject json() throws JSONException { + return new JSONObject(body == null || body.isEmpty() ? "{}" : body); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/PaimonMotion.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/PaimonMotion.java new file mode 100644 index 0000000..1e885fd --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/PaimonMotion.java @@ -0,0 +1,1054 @@ +package com.seeker.n7cockpit; + +/** + * Procedural choreography for the Paimon mascot. + * + *

Each cockpit function gets a deliberately different performance authored here as a full + * body pose per frame: torso, head, both arms with forearm twist, dangling legs and — new in this + * revision — five articulated fingers per hand. The character floats, so legs never need ground + * contact and the root is free to bob, lean and spin.

+ * + *

Choreography speaks in world-axis degrees ("yaw turns the head, positive roll lifts the left + * arm") and {@link MascotRig} conjugates every request into the joint's rest frame. Hair and cape + * are deliberately not authored here: {@link MascotSprings} owns them so cloth reacts to + * whatever the body actually did.

+ * + *

The MUSIC fallback uses the frequency bands to remain reactive before baked clips are ready. + * During normal full-rig playback {@link MascotAnimator} keeps clip tempo stable, locks landings to + * the audio playback frame, and uses bass/mid/treble for accents and presentation energy.

+ */ +final class PaimonMotion { + private static final float TWO_PI = (float) (Math.PI * 2.0); + + private final MascotRig rig; + private final MascotHands hands; + + /** Limb axes measured from the rest pose, so twists follow the bone instead of a world axis. */ + private final float[] forearmAxisL; + private final float[] forearmAxisR; + private final float[] upperArmAxisL; + private final float[] upperArmAxisR; + + /** Smoothed look-at target in degrees, so the head never snaps between actions. */ + private float lookYaw; + private float lookPitch; + + PaimonMotion(MascotRig rig, MascotHands hands) { + this.rig = rig; + this.hands = hands; + forearmAxisL = rig.axis(MascotPose.FOREARM_L, MascotPose.WRIST_L); + forearmAxisR = rig.axis(MascotPose.FOREARM_R, MascotPose.WRIST_R); + upperArmAxisL = rig.axis(MascotPose.ARM_L, MascotPose.ELBOW_L); + upperArmAxisR = rig.axis(MascotPose.ARM_R, MascotPose.ELBOW_R); + } + + /** + * Authors one complete pose. + * + * @param elapsed seconds since the current action started, for one-shot timing + * @param handBlend per-frame smoothing rate for finger shapes + */ + void author( + MascotPose pose, + MascotActionBus.Action action, + double seconds, + float elapsed, + float rms, + float bass, + float mid, + float treble, + float dominantHz, + MascotActionBus.Context context, + float handBlend) { + pose.reset(); + + // Natural double blink roughly every five seconds; actions may override the weight. + float blinkPhase = fract(seconds / 5.2f) * 5.2f; + if (blinkPhase < 0.10f) pose.blink = 1f; + else if (blinkPhase < 0.18f) pose.blink = 1f - (blinkPhase - 0.10f) / 0.08f; + else if (blinkPhase > 0.30f && blinkPhase < 0.40f) pose.blink = 0.9f; + + floatingBase(pose, seconds); + + switch (action) { + case GREET: + poseGreet(pose, seconds, elapsed, handBlend); + break; + case CLIMATE: + poseClimate(pose, seconds, context, handBlend); + break; + case WINDOW: + poseWindow(pose, seconds, handBlend); + break; + case LIGHT: + poseLight(pose, seconds, handBlend); + break; + case SUNSHADE: + poseSunshade(pose, seconds, handBlend); + break; + case DEVICE: + poseDevice(pose, seconds, elapsed, handBlend); + break; + case MUSIC: + poseMusic(pose, seconds, rms, bass, mid, treble, dominantHz, handBlend); + break; + case SUCCESS: + poseSuccess(pose, elapsed, handBlend); + break; + case ERROR: + poseError(pose, seconds, elapsed, handBlend); + break; + case DRAG: + poseDrag(pose, seconds, elapsed, handBlend); + break; + case DRAG_LEFT: + poseDragHorizontal(pose, seconds, elapsed, -1f, handBlend); + break; + case DRAG_RIGHT: + poseDragHorizontal(pose, seconds, elapsed, 1f, handBlend); + break; + case DRAG_UP: + poseDragVertical(pose, seconds, elapsed, true, handBlend); + break; + case DRAG_DOWN: + poseDragVertical(pose, seconds, elapsed, false, handBlend); + break; + case TAP_HEAD: + poseTapHead(pose, elapsed, handBlend); + break; + case TAP_BODY: + poseTapBody(pose, elapsed, handBlend); + break; + case TAP_LEFT_HAND: + poseTapHand(pose, elapsed, true, handBlend); + break; + case TAP_RIGHT_HAND: + poseTapHand(pose, elapsed, false, handBlend); + break; + case TAP_FEET: + poseTapFeet(pose, elapsed, handBlend); + break; + case DRIVE: + poseDrive(pose, seconds, context, handBlend); + break; + case SLEEP: + poseSleep(pose, seconds, handBlend); + break; + case IDLE: + default: + poseIdle(pose, seconds, elapsed, handBlend); + break; + } + } + + /** Hover bob, anatomical breathing and dangling legs shared by every action. */ + private void floatingBase(MascotPose pose, double seconds) { + float hover = sin(seconds * 1.35f); + pose.rootY = 0.02f + hover * 0.045f; + float breath = sin(seconds * 1.65f); + // Breathing belongs in the rib cage, not in the whole character. The former 1.6 degree + // chest swing plus global Y squash made the rounded costume shorten and tip around the + // waist; at cockpit size that read as a pelvis thrust and a growing abdomen. Keep the + // exhale slightly closed, lift the root by only a few pixels on inhale, and leave all body + // proportions unchanged. + rig.setWorld(pose, MascotPose.CHEST, -0.20f + breath * 0.35f, 0f, 0f); + pose.rootY += breath * 0.0025f; + rig.setWorld(pose, MascotPose.LEG_L, -6f + sin(seconds * 1.15f) * 4f, 0f, 3f); + rig.setWorld(pose, MascotPose.LEG_R, -6f - sin(seconds * 1.15f + 0.8f) * 4f, 0f, -3f); + rig.setWorld(pose, MascotPose.KNEE_L, 10f + sin(seconds * 1.15f + 1.9f) * 5f, 0f, 0f); + rig.setWorld(pose, MascotPose.KNEE_R, 10f - sin(seconds * 1.15f + 1.1f) * 5f, 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_L, 14f, 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_R, 14f, 0f, 0f); + arms(pose, -36f, 8f, -22f, 0f, -36f, 8f, -22f, 0f); + } + + // ------------------------------------------------------------------ + // Actions + // ------------------------------------------------------------------ + + private void poseIdle( + MascotPose pose, double seconds, float elapsed, float handBlend) { + // Eight restrained phrases form one continuous 80 s performance. Each phrase eases in and + // out over several seconds, while breathing and hover remain continuous underneath, so the + // character feels observant rather than like a collection of randomly triggered clips. + // Idle starts at phrase one whenever it is restored, then runs all eight phrases without + // random phase jumps. Adjacent envelopes overlap slightly so there is always readable life. + float idleCycle = fract(elapsed / 80f) * 80f; + float attentive = smoothStep01(smoothPulse(idleCycle, 0f, 1.2f, 8.4f, 10f)); + float smallWave = smoothStep01(smoothPulse(idleCycle, 9f, 10.6f, 18.4f, 20f)); + float palmStudy = smoothStep01(smoothPulse(idleCycle, 19f, 20.6f, 28.4f, 30f)); + float shoulderRelease = smoothStep01(smoothPulse(idleCycle, 29f, 30.6f, 37.6f, 39.5f)); + float thinking = smoothStep01(smoothPulse(idleCycle, 38.5f, 40.2f, 47.6f, 49.5f)); + float tidyClothes = smoothStep01(smoothPulse(idleCycle, 48.5f, 50.2f, 57.6f, 59.5f)); + float miniStretch = smoothStep01(smoothPulse(idleCycle, 58.5f, 60.2f, 66.8f, 68.5f)); + float floatAdjust = smoothStep01(smoothPulse(idleCycle, 67.5f, 69.2f, 77.6f, 79.8f)); + + float quietSway = sin(seconds * 0.42f); + float wave = sin(seconds * 2.8f); + float fingerDrift = sin(seconds * 1.15f); + look(pose, + sin(seconds * 0.31f) * 5.5f - attentive * 11f - palmStudy * 10f + + thinking * 7f, + sin(seconds * 0.54f) * 2.2f - attentive * 3f + palmStudy * 7f + + thinking * 6f - miniStretch * 4f, + 0.055f); + + // Neutral floating stance: a very small whole-body forward inclination, a posterior pelvic + // tuck and an opposing lumbar rotation keep the rib cage vertically over the hips. Using + // the same pitch sign on root, hips and spine previously accumulated into a backward arch, + // which exposed the front of the rounded costume and looked like a protruding abdomen. + pose.rootPitch = 0.35f; + rig.setWorld(pose, MascotPose.SPINE, + -1.4f + shoulderRelease * 1.2f - miniStretch * 0.6f, + quietSway * 2.2f + palmStudy * 3f - thinking * 2f, + sin(seconds * 0.47f) * 1.1f + tidyClothes * quietSway * 1.8f); + rig.setWorld(pose, MascotPose.HIPS, + 1.6f - floatAdjust * 0.4f, 0f, + sin(seconds * 0.27f) * 3f + shoulderRelease * quietSway * 1.5f); + pose.rootX = sin(seconds * 0.27f) * 0.010f; + pose.rootY += miniStretch * 0.018f - floatAdjust * 0.010f; + + // A relaxed elbow stays beside the costume instead of folding a large hand over the lower + // abdomen. Phrase gestures still have enough range to read clearly from the driver seat. + float raiseL = -38f + sin(seconds * 0.55f) * 2.5f + + palmStudy * 36f + miniStretch * 30f; + float forwardL = -15f + palmStudy * 45f - miniStretch * 10f; + float elbowL = -6f + sin(seconds * 0.82f) * 2f + - palmStudy * 55f + miniStretch * 12f; + float twistL = 4f + palmStudy * 16f; + + float raiseR = -38f - sin(seconds * 0.55f + 0.7f) * 2.5f + + attentive * 30f + smallWave * 52f + thinking * 40f + tidyClothes * 38f + + miniStretch * 30f; + float forwardR = -15f + attentive * 29f + smallWave * 2f + thinking * 31f + + tidyClothes * 31f - miniStretch * 10f; + float elbowR = -6f - sin(seconds * 0.82f + 0.5f) * 2f + - attentive * 52f - smallWave * 56f - thinking * 72f - tidyClothes * 58f + + miniStretch * 12f; + float twistR = -4f - attentive * 12f + smallWave * 18f + - thinking * 8f - tidyClothes * 10f; + arms(pose, raiseL, forwardL, elbowL, twistL, + raiseR, forwardR, elbowR, twistR); + + // Character detail within the phrases: listening tilt, a low wave, studying the palm, + // thinking at the chin, tidying the scarf clasp, shoulder release and a floating reset. + rig.addWorld(pose, MascotPose.HEAD, + -2f * attentive, -4f * attentive, 6f * attentive + - 4.5f * thinking + palmStudy * fingerDrift * 1.8f); + rig.addWorld(pose, MascotPose.SHOULDER_L, + shoulderRelease * (1.5f + quietSway), 0f, + -shoulderRelease * 3f + miniStretch * 2f); + rig.addWorld(pose, MascotPose.SHOULDER_R, + shoulderRelease * (1.5f - quietSway), 0f, + shoulderRelease * 3f - miniStretch * 2f); + rig.addWorld(pose, MascotPose.WRIST_L, + -palmStudy * 10f, palmStudy * (7f + fingerDrift * 3f), + -miniStretch * 4f); + rig.addWorld(pose, MascotPose.WRIST_R, + -thinking * 10f - attentive * 5f, thinking * 6f, + attentive * 5f + smallWave * wave * 20f + - tidyClothes * 9f + miniStretch * 5f); + + rig.addWorld(pose, MascotPose.LEG_L, + floatAdjust * (-7f + quietSway * 3f), 0f, floatAdjust * 4f); + rig.addWorld(pose, MascotPose.LEG_R, + floatAdjust * (6f - quietSway * 3f), 0f, -floatAdjust * 4f); + rig.addWorld(pose, MascotPose.KNEE_L, + floatAdjust * (4f + quietSway), 0f, 0f); + rig.addWorld(pose, MascotPose.KNEE_R, + floatAdjust * (4f - quietSway), 0f, 0f); + + MascotHands.Shape leftShape = palmStudy > 0.06f || miniStretch > 0.10f + ? MascotHands.Shape.OPEN : MascotHands.Shape.RELAX; + MascotHands.Shape rightShape = smallWave > 0.06f || miniStretch > 0.10f + ? MascotHands.Shape.OPEN + : thinking > 0.06f ? MascotHands.Shape.PINCH + : attentive > 0.06f ? MascotHands.Shape.CUP + : tidyClothes > 0.06f ? MascotHands.Shape.CUP : MascotHands.Shape.RELAX; + hands.apply(pose, true, leftShape, handBlend, + 0.08f + palmStudy * 0.14f, (float) seconds * 1.05f); + hands.apply(pose, false, rightShape, handBlend, + 0.08f + smallWave * 0.24f, (float) seconds * 1.05f + 1.7f); + pose.smile = 0.16f + smallWave * 0.18f + palmStudy * 0.08f + + tidyClothes * 0.06f; + pose.rimColor = thinking > 0.12f ? MascotPalette.VIOLET + : smallWave > 0.12f ? MascotPalette.TEAL : MascotPalette.GOLD; + float phraseAccent = Math.max(smallWave, Math.max(palmStudy, + Math.max(thinking, Math.max(tidyClothes, miniStretch)))); + pose.rimStrength = 0.34f + phraseAccent * 0.12f; + pose.sparkle = 0.045f + phraseAccent * 0.10f + attentive * 0.04f; + } + + /** + * A compact balance-recovery pose shown only while the mascot window is physically moved. + * Limbs trail the root and hands open for balance; amplitudes stay below the music motion. + */ + private void poseDrag(MascotPose pose, double seconds, float elapsed, float handBlend) { + float enter = smoothStep01(clamp01(elapsed / 0.22f)); + float balance = sin(seconds * 4.1f); + float counter = sin(seconds * 3.2f + 0.8f); + pose.rootY += (0.035f + Math.abs(balance) * 0.012f) * enter; + pose.rootPitch = (-5f + balance * 1.6f) * enter; + pose.rootRoll = counter * 3.2f * enter; + rig.setWorld(pose, MascotPose.HIPS, + 2.5f * enter, -balance * 2.2f * enter, -counter * 4f * enter); + rig.setWorld(pose, MascotPose.SPINE, + -4f * enter, balance * 3f * enter, counter * 3.2f * enter); + rig.setWorld(pose, MascotPose.CHEST, + -2f * enter, -balance * 2f * enter, -counter * 2.2f * enter); + look(pose, -balance * 7f * enter, -5f * enter, 0.32f); + + armSide(pose, true, + mix(-36f, -10f + counter * 3f, enter), + mix(8f, 28f, enter), + mix(-22f, -48f - balance * 4f, enter), + 14f * enter); + armSide(pose, false, + mix(-36f, -10f - counter * 3f, enter), + mix(8f, 28f, enter), + mix(-22f, -48f + balance * 4f, enter), + -14f * enter); + rig.setWorld(pose, MascotPose.WRIST_L, -8f * enter, 0f, -8f * enter); + rig.setWorld(pose, MascotPose.WRIST_R, -8f * enter, 0f, 8f * enter); + + rig.setWorld(pose, MascotPose.LEG_L, + (-12f + balance * 3f) * enter, 0f, 7f * enter); + rig.setWorld(pose, MascotPose.LEG_R, + (-9f - balance * 3f) * enter, 0f, -7f * enter); + rig.setWorld(pose, MascotPose.KNEE_L, + (24f - balance * 4f) * enter, 0f, 0f); + rig.setWorld(pose, MascotPose.KNEE_R, + (21f + balance * 4f) * enter, 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_L, 10f * enter, 0f, -2f * enter); + rig.setWorld(pose, MascotPose.ANKLE_R, 10f * enter, 0f, 2f * enter); + hands.apply(pose, MascotHands.Shape.OPEN, Math.min(1f, handBlend * 1.6f)); + pose.smile = 0.28f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.58f; + pose.sparkle = 0.14f * enter; + } + + /** Sideways movement: lean into travel while the opposite limbs open to counterbalance. */ + private void poseDragHorizontal(MascotPose pose, double seconds, float elapsed, + float screenDirection, float handBlend) { + float enter = smoothStep01(clamp01(elapsed / 0.24f)); + float balance = sin(seconds * 3.1f) * enter; + float direction = screenDirection < 0f ? -1f : 1f; + + pose.rootX = direction * 0.018f * enter; + pose.rootY += (0.018f + Math.abs(balance) * 0.008f) * enter; + pose.rootPitch = (-2.5f + balance * 0.8f) * enter; + pose.rootRoll = (-direction * 7.2f + balance * 1.3f) * enter; + pose.rootYaw = direction * 3f * enter; + rig.setWorld(pose, MascotPose.HIPS, + 1.5f * enter, -direction * 2f * enter, + (-direction * 6f + balance) * enter); + rig.setWorld(pose, MascotPose.SPINE, + -2.5f * enter, direction * 2.6f * enter, + (-direction * 4.5f - balance * 0.7f) * enter); + rig.setWorld(pose, MascotPose.CHEST, + -1f * enter, -direction * 1.8f * enter, + direction * 2.2f * enter); + look(pose, direction * 8f * enter, -3f * enter, 0.28f); + rig.addWorld(pose, MascotPose.HEAD, 0f, 0f, direction * 2.2f * enter); + + // Model-left is visible on screen-right. Moving screen-left therefore trails model-left. + boolean trailingModelLeft = direction < 0f; + float trailRaise = 6f + balance * 2f; + float leadRaise = -19f - balance * 1.5f; + if (trailingModelLeft) { + armSide(pose, true, trailRaise * enter + -36f * (1f - enter), + mix(8f, 26f, enter), mix(-22f, -42f, enter), 18f * enter); + armSide(pose, false, leadRaise * enter + -36f * (1f - enter), + mix(8f, 30f, enter), mix(-22f, -55f, enter), -10f * enter); + } else { + armSide(pose, true, leadRaise * enter + -36f * (1f - enter), + mix(8f, 30f, enter), mix(-22f, -55f, enter), 10f * enter); + armSide(pose, false, trailRaise * enter + -36f * (1f - enter), + mix(8f, 26f, enter), mix(-22f, -42f, enter), -18f * enter); + } + rig.setWorld(pose, MascotPose.WRIST_L, + -6f * enter, 0f, (-direction * 5f - balance * 3f) * enter); + rig.setWorld(pose, MascotPose.WRIST_R, + -6f * enter, 0f, (-direction * 5f + balance * 3f) * enter); + + float trailLeg = 9f * enter; + float leadLeg = -4f * enter; + if (trailingModelLeft) { + rig.addWorld(pose, MascotPose.LEG_L, -3f * enter, 0f, trailLeg); + rig.addWorld(pose, MascotPose.KNEE_L, 11f * enter, 0f, 0f); + rig.addWorld(pose, MascotPose.LEG_R, 3f * enter, 0f, leadLeg); + rig.addWorld(pose, MascotPose.KNEE_R, 5f * enter, 0f, 0f); + } else { + rig.addWorld(pose, MascotPose.LEG_L, 3f * enter, 0f, -leadLeg); + rig.addWorld(pose, MascotPose.KNEE_L, 5f * enter, 0f, 0f); + rig.addWorld(pose, MascotPose.LEG_R, -3f * enter, 0f, -trailLeg); + rig.addWorld(pose, MascotPose.KNEE_R, 11f * enter, 0f, 0f); + } + hands.apply(pose, MascotHands.Shape.OPEN, Math.min(1f, handBlend * 1.5f)); + pose.smile = 0.24f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.55f; + pose.sparkle = 0.14f * enter; + } + + /** Upward travel gathers the legs; downward travel extends them and raises the arms to brake. */ + private void poseDragVertical(MascotPose pose, double seconds, float elapsed, + boolean upward, float handBlend) { + float enter = smoothStep01(clamp01(elapsed / 0.24f)); + float balance = sin(seconds * 3.3f) * enter; + if (upward) { + pose.rootY += (0.030f + Math.abs(balance) * 0.006f) * enter; + pose.rootPitch = (-4f + balance * 0.6f) * enter; + rig.setWorld(pose, MascotPose.HIPS, -2f * enter, 0f, balance * 1.4f); + rig.setWorld(pose, MascotPose.SPINE, -4f * enter, 0f, -balance * 1.2f); + look(pose, balance * 2f, -9f * enter, 0.28f); + armSide(pose, true, mix(-36f, -7f + balance, enter), + mix(8f, 20f, enter), mix(-22f, -38f, enter), 14f * enter); + armSide(pose, false, mix(-36f, -7f - balance, enter), + mix(8f, 20f, enter), mix(-22f, -38f, enter), -14f * enter); + rig.setWorld(pose, MascotPose.LEG_L, mix(-6f, 7f, enter), 0f, 4f * enter); + rig.setWorld(pose, MascotPose.LEG_R, mix(-6f, 7f, enter), 0f, -4f * enter); + rig.setWorld(pose, MascotPose.KNEE_L, mix(10f, 31f, enter), 0f, 0f); + rig.setWorld(pose, MascotPose.KNEE_R, mix(10f, 31f, enter), 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_L, mix(14f, 8f, enter), 0f, -2f * enter); + rig.setWorld(pose, MascotPose.ANKLE_R, mix(14f, 8f, enter), 0f, 2f * enter); + } else { + pose.rootY -= (0.020f + Math.abs(balance) * 0.004f) * enter; + pose.rootPitch = (5f + balance * 0.6f) * enter; + rig.setWorld(pose, MascotPose.HIPS, 3f * enter, 0f, balance * 1.2f); + rig.setWorld(pose, MascotPose.SPINE, 5f * enter, 0f, -balance * 1.1f); + look(pose, balance * 2f, 11f * enter, 0.28f); + armSide(pose, true, mix(-36f, 18f + balance, enter), + mix(8f, 12f, enter), mix(-22f, -42f, enter), 12f * enter); + armSide(pose, false, mix(-36f, 18f - balance, enter), + mix(8f, 12f, enter), mix(-22f, -42f, enter), -12f * enter); + rig.setWorld(pose, MascotPose.LEG_L, mix(-6f, -13f, enter), 0f, 3f * enter); + rig.setWorld(pose, MascotPose.LEG_R, mix(-6f, -13f, enter), 0f, -3f * enter); + rig.setWorld(pose, MascotPose.KNEE_L, mix(10f, 6f, enter), 0f, 0f); + rig.setWorld(pose, MascotPose.KNEE_R, mix(10f, 6f, enter), 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_L, mix(14f, 20f, enter), 0f, 0f); + rig.setWorld(pose, MascotPose.ANKLE_R, mix(14f, 20f, enter), 0f, 0f); + } + hands.apply(pose, MascotHands.Shape.OPEN, Math.min(1f, handBlend * 1.5f)); + pose.smile = upward ? 0.32f : 0.18f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.55f; + pose.sparkle = 0.14f * enter; + } + + /** A soft petting response: eyes close, shoulders relax and the head settles into the touch. */ + private void poseTapHead(MascotPose pose, float elapsed, float handBlend) { + float response = actionEnvelope(elapsed, 0.30f, 1.85f, 2.82f); + float nuzzle = smoothStep01(smoothPulse(elapsed, 0.42f, 0.68f, 1.42f, 2.15f)); + float tinyNod = sin(elapsed * 3.2f) * nuzzle; + pose.rootY -= 0.014f * response; + pose.rootPitch = 1.5f * response; + pose.rootRoll = (3.2f + tinyNod * 0.7f) * response; + rig.setWorld(pose, MascotPose.SPINE, 2f * response, -1.5f * response, + -2f * response); + look(pose, -5f * response, (3.5f + tinyNod) * response, 0.24f); + rig.addWorld(pose, MascotPose.HEAD, + tinyNod * 1.4f, 0f, 5.5f * response); + armSide(pose, true, mix(-36f, -28f, response), + mix(8f, 18f, response), mix(-22f, -42f, response), 8f * response); + armSide(pose, false, mix(-36f, -25f, response), + mix(8f, 23f, response), mix(-22f, -50f, response), -10f * response); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0.08f, + elapsed * 1.6f); + hands.apply(pose, false, MascotHands.Shape.CUP, handBlend, 0.05f, + elapsed * 1.6f + 1.2f); + pose.blink = Math.max(pose.blink, response * (0.78f + nuzzle * 0.22f)); + pose.smile = 0.18f + response * 0.72f; + pose.rimColor = MascotPalette.GOLD; + pose.rimStrength = 0.36f + response * 0.28f; + pose.sparkle = response * 0.12f; + } + + /** A readable but restrained surprise, followed by briefly guarding the torso. */ + private void poseTapBody(MascotPose pose, float elapsed, float handBlend) { + float surprise = smoothStep01(smoothPulse(elapsed, 0f, 0.16f, 0.42f, 0.88f)); + float guard = smoothStep01(smoothPulse(elapsed, 0.28f, 0.62f, 1.72f, 2.72f)); + float reaction = Math.max(surprise, guard); + pose.rootY += surprise * 0.052f - guard * 0.010f; + pose.rootZ -= surprise * 0.020f; + pose.rootPitch = -3f * surprise + 3.5f * guard; + rig.setWorld(pose, MascotPose.HIPS, 2f * guard, 0f, 0f); + rig.setWorld(pose, MascotPose.SPINE, + -4f * surprise + 5f * guard, 0f, 0f); + rig.setWorld(pose, MascotPose.CHEST, + -2f * surprise + 3f * guard, 0f, 0f); + rig.setWorld(pose, MascotPose.SHOULDER_L, + -3f * guard, 0f, 7f * guard); + rig.setWorld(pose, MascotPose.SHOULDER_R, + -3f * guard, 0f, -7f * guard); + look(pose, 0f, 2f * surprise + 8f * guard, 0.30f); + armSide(pose, true, mix(-36f, -31f, guard), + mix(8f, 47f, guard), mix(-22f, -78f, guard), 24f * guard); + armSide(pose, false, mix(-36f, -31f, guard), + mix(8f, 47f, guard), mix(-22f, -78f, guard), -24f * guard); + hands.apply(pose, MascotHands.Shape.CUP, handBlend); + pose.blink = Math.max(pose.blink, surprise * 0.38f); + pose.trouble = surprise * 0.55f + guard * 0.14f; + pose.smile = 0.12f + (1f - surprise) * guard * 0.18f; + pose.rimColor = MascotPalette.VIOLET; + pose.rimStrength = 0.38f + reaction * 0.30f; + pose.sparkle = reaction * 0.10f; + } + + /** Reaches the touched on-screen hand forward and answers with a small finger ripple. */ + private void poseTapHand(MascotPose pose, float elapsed, boolean screenLeft, + float handBlend) { + float reach = actionEnvelope(elapsed, 0.30f, 1.72f, 2.65f); + float answer = smoothStep01(smoothPulse(elapsed, 0.52f, 0.74f, 1.50f, 2.18f)); + float screenSide = screenLeft ? -1f : 1f; + // A front-facing character's model-right hand is visible on screen-left. + boolean modelLeft = !screenLeft; + pose.rootX = screenSide * 0.012f * reach; + pose.rootRoll = -screenSide * 3.4f * reach; + pose.rootYaw = screenSide * 3f * reach; + rig.setWorld(pose, MascotPose.SPINE, + -1.5f * reach, screenSide * 2.5f * reach, + -screenSide * 2.5f * reach); + look(pose, screenSide * 14f * reach, -2f * reach, 0.28f); + + if (modelLeft) { + armSide(pose, true, mix(-36f, 9f, reach), + mix(8f, 50f, reach), mix(-22f, -44f, reach), 20f * reach); + armSide(pose, false, mix(-36f, -38f, reach), + mix(8f, 12f, reach), mix(-22f, -28f, reach), -4f * reach); + } else { + armSide(pose, true, mix(-36f, -38f, reach), + mix(8f, 12f, reach), mix(-22f, -28f, reach), 4f * reach); + armSide(pose, false, mix(-36f, 9f, reach), + mix(8f, 50f, reach), mix(-22f, -44f, reach), -20f * reach); + } + int wrist = modelLeft ? MascotPose.WRIST_L : MascotPose.WRIST_R; + rig.setWorld(pose, wrist, -9f * reach, 0f, + screenSide * sin(elapsed * 5f) * 7f * answer); + hands.apply(pose, modelLeft, MascotHands.Shape.OPEN, handBlend, + 0.18f + answer * 0.35f, elapsed * 5.2f); + hands.apply(pose, !modelLeft, MascotHands.Shape.RELAX, handBlend, + 0.05f, elapsed * 1.4f); + pose.smile = 0.22f + reach * 0.55f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.38f + reach * 0.30f; + pose.sparkle = reach * 0.06f + answer * 0.10f; + } + + /** Looks down and alternates two compact, low-amplitude ticklish foot lifts. */ + private void poseTapFeet(MascotPose pose, float elapsed, float handBlend) { + float response = actionEnvelope(elapsed, 0.24f, 2.10f, 2.88f); + float liftLeft = smoothStep01(smoothPulse(elapsed, 0.12f, 0.34f, 0.82f, 1.28f)); + float liftRight = smoothStep01(smoothPulse(elapsed, 0.82f, 1.08f, 1.62f, 2.18f)); + float lift = Math.max(liftLeft, liftRight); + pose.rootY += lift * 0.032f; + pose.rootPitch = 3.5f * response; + rig.setWorld(pose, MascotPose.SPINE, 4f * response, 0f, + (liftLeft - liftRight) * 1.8f); + look(pose, (liftLeft - liftRight) * 5f, 15f * response, 0.30f); + armSide(pose, true, mix(-36f, -19f, response), + mix(8f, 18f, response), mix(-22f, -43f, response), 10f * response); + armSide(pose, false, mix(-36f, -19f, response), + mix(8f, 18f, response), mix(-22f, -43f, response), -10f * response); + rig.addWorld(pose, MascotPose.LEG_L, + 9f * liftLeft - 2f * response, 0f, 5f * liftLeft); + rig.addWorld(pose, MascotPose.LEG_R, + 9f * liftRight - 2f * response, 0f, -5f * liftRight); + rig.addWorld(pose, MascotPose.KNEE_L, 22f * liftLeft, 0f, 0f); + rig.addWorld(pose, MascotPose.KNEE_R, 22f * liftRight, 0f, 0f); + rig.addWorld(pose, MascotPose.ANKLE_L, -16f * liftLeft, 0f, -3f * liftLeft); + rig.addWorld(pose, MascotPose.ANKLE_R, -16f * liftRight, 0f, 3f * liftRight); + hands.apply(pose, MascotHands.Shape.OPEN, handBlend); + pose.blink = Math.max(pose.blink, lift * 0.58f); + pose.smile = 0.18f + response * 0.48f; + pose.trouble = lift * 0.12f; + pose.rimColor = MascotPalette.GOLD; + pose.rimStrength = 0.38f + response * 0.26f; + pose.sparkle = response * 0.11f; + } + + private void poseGreet(MascotPose pose, double seconds, float elapsed, float handBlend) { + float intro = clamp01(elapsed / 0.35f); + float wave = sin(seconds * 8.4f); + pose.rootY += 0.03f * intro + Math.max(0f, sin(elapsed * 5.2f)) * 0.05f; + pose.rootYaw = -9f * intro; + rig.setWorld(pose, MascotPose.SPINE, -2f * intro, -5f * intro, 0f); + look(pose, 6f, -4f, 0.22f); + rig.addWorld(pose, MascotPose.HEAD, 0f, 0f, wave * 3.5f * intro); + + // Right arm up beside the head; the wave lives in the wrist and fingers, not the shoulder. + armSide(pose, false, 62f * intro, -14f * intro, -74f * intro, 26f * intro); + rig.setWorld(pose, MascotPose.WRIST_R, 0f, 0f, wave * 24f * intro); + armSide(pose, true, -40f, 12f, -30f, -8f); + hands.apply(pose, false, MascotHands.Shape.OPEN, handBlend, + 0.55f * intro, (float) seconds * 8.4f); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0.08f, (float) seconds); + pose.smile = 1f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.62f; + pose.sparkle = 0.25f * intro; + } + + private void poseClimate( + MascotPose pose, double seconds, MascotActionBus.Context context, float handBlend) { + float target = context == null ? Float.NaN : context.climateTargetC; + boolean cold = Float.isFinite(target) && target <= 20.5f; + boolean warm = Float.isFinite(target) && target >= 25.5f; + + if (cold) { + // Shiver with cupped hands beside the cheeks. This reads clearly as warming up while + // avoiding the old low, rounded "holding the belly" silhouette on the puffy costume. + float shiver = sin(seconds * 22f) * 1.4f + sin(seconds * 31f) * 0.8f; + pose.rootY -= 0.02f; + pose.rootPitch = 0.65f; + pose.rootRoll = sin(seconds * 2.1f) * 2.2f + shiver * 0.25f; + rig.setWorld(pose, MascotPose.HIPS, 1.8f, 0f, 0f); + rig.setWorld(pose, MascotPose.SPINE, + -1.6f, 0f, sin(seconds * 2.1f) * 1.8f); + rig.setWorld(pose, MascotPose.CHEST, -0.35f + shiver * 0.16f, 0f, 0f); + look(pose, 0f, 7f + shiver * 0.5f, 0.25f); + rig.setWorld(pose, MascotPose.SHOULDER_L, -5f, 0f, 10f); + rig.setWorld(pose, MascotPose.SHOULDER_R, -5f, 0f, -10f); + armSide(pose, true, 38f, 24f, -116f + shiver, 36f); + armSide(pose, false, 38f, 24f, -116f - shiver, -36f); + rig.setWorld(pose, MascotPose.WRIST_L, -18f, -5f, -8f); + rig.setWorld(pose, MascotPose.WRIST_R, -18f, 5f, 8f); + hands.apply(pose, MascotHands.Shape.CUP, handBlend); + pose.trouble = 0.45f; + pose.smile = 0.05f; + pose.rimColor = MascotPalette.COOL; + pose.rimStrength = 0.80f; + } else if (warm) { + // Fan the face with the right hand while the left stays relaxed outside the costume. + // A hand planted on this model's wide hip reads as a hand across the abdomen in the + // compact overlay, so keep the lower arm behind the side seam instead. + float fan = sin(seconds * 7.4f); + pose.rootRoll = fan * 1.6f; + pose.rootPitch = 0.35f; + rig.setWorld(pose, MascotPose.HIPS, 1.6f, 0f, 0f); + rig.setWorld(pose, MascotPose.SPINE, -1.4f, fan * 1.8f, 0f); + look(pose, fan * 4f, 5f, 0.25f); + armSide(pose, false, 44f, 24f, -116f, -38f); + rig.setWorld(pose, MascotPose.WRIST_R, -18f, fan * 26f, 4f); + armSide(pose, true, -12f, -10f, -5f, 4f); + rig.setWorld(pose, MascotPose.WRIST_L, -2f, -2f, -4f); + hands.apply(pose, false, MascotHands.Shape.OPEN, handBlend, + 0.35f, (float) seconds * 7.4f); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0f, 0f); + pose.smile = 0.3f; + pose.trouble = 0.2f; + pose.rimColor = MascotPalette.WARM; + pose.rimStrength = 0.78f; + } else { + // Keep pelvis, lumbar spine and rib cage vertically stacked. The air-flow phrase is + // drawn above and beside the torso, never with both oversized hands framing the belly. + float push = 0.5f + 0.5f * sin(seconds * 4.6f); + pose.rootPitch = 0.40f; + // Air flow moves the hands and lifts the body; it must not pump the whole character + // toward and away from the camera, which exaggerates the costume volume. + pose.rootZ -= 0.016f; + pose.rootY += 0.02f + push * 0.008f; + rig.setWorld(pose, MascotPose.HIPS, 1.8f, 0f, 0f); + rig.setWorld(pose, MascotPose.SPINE, + -1.5f, 0f, sin(seconds * 2.3f) * 1.4f); + look(pose, sin(seconds * 1.4f) * 5f, -3f + sin(seconds * 2.3f) * 2.5f, 0.25f); + armSide(pose, true, 10f + push * 7f, 24f + push * 5f, + -34f + push * 4f, 18f); + armSide(pose, false, -10f + push * 4f, -10f + push * 3f, + -4f + push, -5f); + rig.setWorld(pose, MascotPose.WRIST_L, -10f + push * 7f, -6f, -8f); + rig.setWorld(pose, MascotPose.WRIST_R, -1f + push * 2f, 2f, 5f); + hands.apply(pose, MascotHands.Shape.OPEN, handBlend); + pose.smile = 0.35f; + pose.rimColor = MascotPalette.COOL; + pose.rimStrength = 0.72f; + pose.emission = 0.05f + push * 0.05f; + } + pose.sparkle = cold ? 0.24f : warm ? 0.22f : 0.20f; + } + + private void poseWindow(MascotPose pose, double seconds, float handBlend) { + // Both palms slide an invisible pane down; the wind then blows her hair back. + float slide = 0.5f + 0.5f * sin(seconds * 2.4f); + pose.rootPitch = 3f; + pose.rootY += 0.01f - slide * 0.03f; + pose.rootYaw = sin(seconds * 1.2f) * 5f; + rig.setWorld(pose, MascotPose.SPINE, 5f, pose.rootYaw * 0.4f, 0f); + look(pose, sin(seconds * 1.2f + 0.6f) * 7f, 6f + slide * 4f, 0.25f); + float raise = 26f - slide * 54f; + armSide(pose, true, raise, 66f, -52f + slide * 22f, 26f); + armSide(pose, false, raise, 66f, -52f + slide * 22f, -26f); + rig.setWorld(pose, MascotPose.WRIST_L, -34f, -8f, 0f); + rig.setWorld(pose, MascotPose.WRIST_R, -34f, 8f, 0f); + hands.apply(pose, MascotHands.Shape.OPEN, handBlend); + pose.smile = 0.45f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.62f; + pose.sparkle = 0.24f; + } + + private void poseLight(MascotPose pose, double seconds, float handBlend) { + // Finger snap on a 1.9 s cycle: wind up, snap, glow, relax. + float cycle = fract(seconds / 1.9f); + float windUp = smoothPulse(cycle, 0.00f, 0.22f, 0.44f, 0.50f); + float snapped = cycle >= 0.46f && cycle < 0.92f + ? 1f - (cycle - 0.46f) / 0.46f : 0f; + float flash = cycle >= 0.46f && cycle < 0.60f + ? 1f - (cycle - 0.46f) / 0.14f : 0f; + + pose.rootY += 0.02f + flash * 0.05f; + pose.rootYaw = -6f; + rig.setWorld(pose, MascotPose.SPINE, -3f, -4f, 2f); + look(pose, -12f, -6f - flash * 6f, 0.3f); + armSide(pose, false, 44f + windUp * 10f, -18f, -96f - windUp * 8f, 34f); + rig.setWorld(pose, MascotPose.WRIST_R, -14f, 0f, -18f + flash * 22f); + armSide(pose, true, -48f, 22f, -58f, -16f); + hands.apply(pose, false, + snapped > 0.05f ? MascotHands.Shape.SNAP : MascotHands.Shape.PINCH, + snapped > 0.05f ? Math.min(1f, handBlend * 4f) : handBlend, 0f, 0f); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0f, 0f); + pose.smile = 0.55f + flash * 0.4f; + pose.emission = 0.06f + flash * 0.46f; + pose.rimColor = MascotPalette.GOLD; + pose.rimStrength = 0.55f + flash * 0.85f; + pose.sparkle = 0.16f + flash * 0.84f; + } + + private void poseSunshade(MascotPose pose, double seconds, float handBlend) { + // Right hand shades the eyes; a soft squint sells the strong sunlight. + pose.rootPitch = 2f; + pose.rootY -= 0.02f; + look(pose, sin(seconds * 1.1f) * 6f, 9f + sin(seconds * 1.9f) * 2f, 0.2f); + armSide(pose, false, 52f, -34f, -112f, 52f); + rig.setWorld(pose, MascotPose.WRIST_R, -30f, 0f, 10f); + armSide(pose, true, -40f, 10f, -26f, -10f); + hands.apply(pose, false, MascotHands.Shape.OPEN, handBlend, 0f, 0f); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0f, 0f); + pose.blink = Math.max(pose.blink, 0.45f); + pose.smile = 0.2f; + pose.rimColor = MascotPalette.VIOLET; + pose.rimStrength = 0.60f; + pose.sparkle = 0.22f; + } + + private void poseDevice( + MascotPose pose, double seconds, float elapsed, float handBlend) { + // A complete inspection phrase: acquire left, sweep right, confirm on a virtual panel, + // then return to neutral. Unlike the old baked point clip, it never parks on a tilted end. + float active = actionEnvelope(elapsed, 0.34f, 4.95f, 5.68f); + float scanLeft = smoothStep01(smoothPulse(elapsed, 0.28f, 0.72f, 1.22f, 1.72f)); + float scanRight = smoothStep01(smoothPulse(elapsed, 1.35f, 1.82f, 2.38f, 2.82f)); + float confirm = smoothStep01(smoothPulse(elapsed, 2.55f, 2.95f, 4.55f, 5.30f)); + float scanYaw = -18f * scanLeft + 18f * scanRight; + float tap = Math.max(0f, sin((elapsed - 2.82f) * 7.2f)) * confirm; + float pulse = 0.5f + 0.5f * sin(seconds * 4.8f); + + pose.rootYaw = -scanYaw * 0.22f * active; + pose.rootY += (scanLeft + scanRight) * 0.010f + tap * 0.012f; + rig.setWorld(pose, MascotPose.HIPS, 0f, scanYaw * 0.10f, 0f); + rig.setWorld(pose, MascotPose.SPINE, + -1.5f * active, -scanYaw * 0.20f, 0f); + rig.setWorld(pose, MascotPose.CHEST, + -1f * active, scanYaw * 0.10f, 0f); + look(pose, scanYaw - confirm * 5f, -2f * active + tap * 1.5f, 0.30f); + rig.setWorld(pose, MascotPose.EYE_L, 0f, scanYaw * 0.42f, 0f); + rig.setWorld(pose, MascotPose.EYE_R, 0f, scanYaw * 0.42f, 0f); + + armSide(pose, false, + mix(-36f, 5f, confirm), + mix(8f, 58f - tap * 9f, confirm), + mix(-22f, -60f + tap * 14f, confirm), + -18f * confirm); + rig.setWorld(pose, MascotPose.WRIST_R, + -9f * confirm, 5f * confirm, tap * 3f); + armSide(pose, true, + mix(-36f, -40f, active), + mix(8f, 12f, active), + mix(-22f, -30f, active), + 5f * active); + hands.apply(pose, false, + confirm > 0.08f ? MascotHands.Shape.POINT : MascotHands.Shape.RELAX, + handBlend, 0.04f, elapsed * 2f); + hands.apply(pose, true, MascotHands.Shape.RELAX, handBlend, 0.04f, + elapsed * 1.4f + 1.2f); + pose.blink = Math.max(pose.blink, tap * 0.32f); + pose.smile = 0.16f + confirm * 0.28f; + pose.emission = active * (0.04f + pulse * 0.05f) + tap * 0.08f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.42f + active * 0.34f + tap * 0.16f; + pose.sparkle = active * 0.18f + (scanLeft + scanRight) * 0.10f + tap * 0.18f; + } + + /** + * Fallback groove used before the baked clips load, and the frequency-driven layer that runs + * on top of them once they do. + */ + private void poseMusic(MascotPose pose, double seconds, float rms, float bass, float mid, + float treble, float dominantHz, float handBlend) { + float rate = 2.7f + Math.min(2.2f, dominantHz / 360f); + float beat = Math.max(0f, sin(seconds * rate)); + float wave = sin(seconds * rate); + float half = sin(seconds * rate * 0.5f); + float energy = clamp01(rms * 0.50f + bass * 0.42f + mid * 0.18f); + + pose.rootY = 0.03f + beat * (0.045f + energy * 0.085f); + pose.rootX = sin(seconds * rate * 0.52f) * (0.010f + treble * 0.012f); + pose.rootYaw = half * (5f + mid * 7f); + pose.rootRoll = wave * (1.4f + treble * 2.4f); + pose.scaleX = 1f; + pose.scaleY = 1f; + pose.scaleZ = 1f; + + rig.setWorld(pose, MascotPose.HIPS, 0f, 0f, half * (9f + mid * 13f)); + rig.setWorld(pose, MascotPose.SPINE, wave * (2.5f + bass * 4f), + -half * (6f + mid * 9f), -half * (5f + mid * 8f)); + rig.setWorld(pose, MascotPose.CHEST, -wave * (3f + bass * 5f), + half * (4f + mid * 6f), half * (3f + mid * 5f)); + look(pose, sin(seconds * rate * 0.5f + 0.7f) * (7f + mid * 9f), + -beat * (5f + bass * 9f), 0.4f); + rig.addWorld(pose, MascotPose.HEAD, 0f, 0f, wave * (3f + treble * 6f)); + rig.setWorld(pose, MascotPose.EYE_L, 0f, half * 6f, 0f); + rig.setWorld(pose, MascotPose.EYE_R, 0f, half * 6f, 0f); + + float lift = clamp01((dominantHz - 280f) / 700f); + float swingL = sin(seconds * rate) * (26f + mid * 24f); + float swingR = sin(seconds * rate + (float) Math.PI) * (26f + mid * 24f); + float pumpL = -sin(seconds * rate) * (10f + treble * 14f); + float pumpR = sin(seconds * rate) * (10f + treble * 14f); + armSide(pose, true, mix(-52f, 46f + pumpL, lift), mix(swingL, 6f, lift) * 0.5f, + mix(-46f, -18f, lift), 20f + treble * 24f); + armSide(pose, false, mix(-52f, 46f + pumpR, lift), mix(swingR, 6f, lift) * 0.5f, + mix(-46f, -18f, lift), -20f - treble * 24f); + float flick = sin(seconds * rate * 2f); + rig.setWorld(pose, MascotPose.WRIST_L, 0f, 0f, -flick * (8f + treble * 30f)); + rig.setWorld(pose, MascotPose.WRIST_R, 0f, 0f, flick * (8f + treble * 30f)); + + float kick = sin(seconds * rate + (float) Math.PI * 0.5f) * (7f + bass * 16f); + rig.addWorld(pose, MascotPose.LEG_L, kick, 0f, 0f); + rig.addWorld(pose, MascotPose.LEG_R, -kick, 0f, 0f); + rig.addWorld(pose, MascotPose.KNEE_L, Math.max(0f, -kick) * 1.6f, 0f, 0f); + rig.addWorld(pose, MascotPose.KNEE_R, Math.max(0f, kick) * 1.6f, 0f, 0f); + + // Hands open on the beat and relax between, with a travelling ripple on the treble. + MascotHands.Shape shape = beat > 0.6f ? MascotHands.Shape.OPEN : MascotHands.Shape.RELAX; + hands.apply(pose, true, shape, handBlend, 0.25f + treble * 0.6f, + (float) seconds * rate * 1.6f); + hands.apply(pose, false, shape, handBlend, 0.25f + treble * 0.6f, + (float) seconds * rate * 1.6f + 1.4f); + + float beatCount = fract(seconds * rate / (TWO_PI * 4f)); + if (beatCount < 0.045f) pose.blink = Math.max(pose.blink, 0.85f); + + pose.smile = 1f; + pose.emission = 0.05f + energy * 0.36f; + pose.rimColor = treble > bass ? MascotPalette.VIOLET : MascotPalette.TEAL; + pose.rimStrength = 0.72f + energy * 0.75f; + pose.sparkle = 0.35f + energy * 0.65f; + } + + private void poseSuccess(MascotPose pose, float elapsed, float handBlend) { + // 1.2 s celebratory jump with a full spin that lands in a V-sign pose. + float p = clamp01(elapsed / 1.18f); + float jumpArc = 4f * p * (1f - p); + pose.rootY = 0.05f + jumpArc * 0.42f; + pose.rootYaw = p * 360f; + pose.rootRoll = sin(p * TWO_PI) * 4f; + pose.scaleX = 1f + jumpArc * 0.04f; + pose.scaleY = 1f - jumpArc * 0.02f; + look(pose, 0f, -jumpArc * 9f, 0.5f); + rig.addWorld(pose, MascotPose.HEAD, 0f, 0f, sin(p * TWO_PI) * 5f); + + float land = clamp01((p - 0.66f) / 0.34f); + armSide(pose, true, mix(72f, 58f, land), mix(-6f, -22f, land), + mix(-18f, -74f, land), 10f); + armSide(pose, false, mix(72f, 58f, land), mix(-6f, -22f, land), + mix(-18f, -74f, land), -10f); + rig.setWorld(pose, MascotPose.WRIST_L, 0f, 0f, -14f); + rig.setWorld(pose, MascotPose.WRIST_R, 0f, 0f, 14f); + rig.addWorld(pose, MascotPose.LEG_L, 8f + jumpArc * 16f, 0f, 4f); + rig.addWorld(pose, MascotPose.LEG_R, 8f + jumpArc * 16f, 0f, -4f); + rig.addWorld(pose, MascotPose.KNEE_L, jumpArc * 38f, 0f, 0f); + rig.addWorld(pose, MascotPose.KNEE_R, jumpArc * 38f, 0f, 0f); + hands.apply(pose, land > 0.35f ? MascotHands.Shape.VICTORY : MascotHands.Shape.OPEN, + Math.min(1f, handBlend * 2.2f)); + pose.blink = 1f; + pose.smile = 1f; + pose.emission = 0.18f; + pose.rimColor = MascotPalette.TEAL; + pose.rimStrength = 0.95f; + pose.sparkle = 1f; + } + + private void poseError(MascotPose pose, double seconds, float elapsed, float handBlend) { + // Both hands to the temples, deflated hover and a slow apologetic head shake. + float shake = sin(elapsed * 9.5f) * Math.max(0f, 1f - elapsed * 0.16f); + float settle = clamp01(elapsed / 0.45f); + pose.rootY = -0.10f + sin(seconds * 1.1f) * 0.012f; + pose.rootPitch = 6f; + rig.setWorld(pose, MascotPose.SPINE, 9f, 0f, 0f); + look(pose, shake * 9f, 17f, 0.3f); + rig.setWorld(pose, MascotPose.EYE_L, 6f, 0f, 0f); + rig.setWorld(pose, MascotPose.EYE_R, 6f, 0f, 0f); + rig.setWorld(pose, MascotPose.SHOULDER_L, -6f * settle, 0f, 14f * settle); + rig.setWorld(pose, MascotPose.SHOULDER_R, -6f * settle, 0f, -14f * settle); + armSide(pose, true, mix(-58f, 46f, settle), mix(26f, 24f, settle), + mix(-26f, -118f, settle), 40f * settle); + armSide(pose, false, mix(-58f, 46f, settle), mix(26f, 24f, settle), + mix(-26f, -118f, settle), -40f * settle); + rig.setWorld(pose, MascotPose.WRIST_L, -18f, 0f, 0f); + rig.setWorld(pose, MascotPose.WRIST_R, -18f, 0f, 0f); + rig.addWorld(pose, MascotPose.LEG_L, -4f, 0f, 0f); + rig.addWorld(pose, MascotPose.LEG_R, -4f, 0f, 0f); + hands.apply(pose, MascotHands.Shape.CUP, handBlend); + pose.blink = Math.max(pose.blink, 0.25f); + pose.trouble = 1f; + pose.smile = 0f; + pose.emission = 0.02f; + pose.rimColor = MascotPalette.RED; + pose.rimStrength = 0.88f; + pose.sparkle = 0.26f; + } + + private void poseDrive( + MascotPose pose, double seconds, MascotActionBus.Context context, float handBlend) { + float speed = context == null || !Float.isFinite(context.speedKph) + ? 0f : Math.max(0f, context.speedKph); + float pace = clamp01(speed / 90f); + // Leans into the airflow and picks up a faster bob as the car speeds up. + float rush = sin(seconds * (2.2f + pace * 3.4f)); + pose.rootPitch = -3f - pace * 7f; + pose.rootZ = pace * 0.03f; + pose.rootY += rush * (0.008f + pace * 0.02f); + rig.setWorld(pose, MascotPose.SPINE, -2f - pace * 4f, 0f, rush * 1.6f); + look(pose, sin(seconds * 0.5f) * 4f, -4f - pace * 5f, 0.15f); + armSide(pose, true, -30f - pace * 6f, 18f + pace * 14f, -36f - pace * 16f, 12f); + armSide(pose, false, -30f - pace * 6f, 18f + pace * 14f, -36f - pace * 16f, -12f); + hands.apply(pose, MascotHands.Shape.RELAX, handBlend); + // Glances at the mirrors every few seconds. + float glance = fract(seconds / 6.1f); + if (glance > 0.5f && glance < 0.72f) { + float in = smoothPulse(glance, 0.50f, 0.55f, 0.66f, 0.72f); + rig.addWorld(pose, MascotPose.HEAD, 0f, -22f * in, 0f); + rig.setWorld(pose, MascotPose.EYE_L, 0f, -12f * in, 0f); + rig.setWorld(pose, MascotPose.EYE_R, 0f, -12f * in, 0f); + } + pose.smile = 0.25f; + pose.rimColor = MascotPalette.COOL; + pose.rimStrength = 0.40f + pace * 0.3f; + pose.sparkle = 0.10f + pace * 0.18f; + } + + private void poseSleep(MascotPose pose, double seconds, float handBlend) { + // Nodding off: the head sinks, then jerks back up every few seconds. + float cycle = fract(seconds / 5.4f); + float sink = cycle < 0.72f ? cycle / 0.72f : 0f; + float jerk = cycle >= 0.72f && cycle < 0.86f + ? 1f - (cycle - 0.72f) / 0.14f : 0f; + float breath = sin(seconds * 0.8f); + pose.rootY = -0.04f + breath * 0.03f - sink * 0.05f + jerk * 0.03f; + pose.rootPitch = 4f + sink * 5f - jerk * 6f; + rig.setWorld(pose, MascotPose.SPINE, 6f + sink * 7f - jerk * 8f, 0f, breath * 2f); + rig.setWorld(pose, MascotPose.CHEST, 3f + breath * 2.4f, 0f, 0f); + look(pose, breath * 3f, 14f + sink * 18f - jerk * 26f, 0.12f); + armSide(pose, true, -52f, 6f, -18f, 0f); + armSide(pose, false, -52f, 6f, -18f, 0f); + hands.apply(pose, MascotHands.Shape.RELAX, handBlend * 0.5f); + pose.blink = Math.max(0.82f, 1f - jerk * 0.7f); + pose.smile = 0.12f; + pose.rimColor = MascotPalette.VIOLET; + pose.rimStrength = 0.22f; + pose.sparkle = 0.12f; + } + + // ------------------------------------------------------------------ + // Pose helpers + // ------------------------------------------------------------------ + + /** + * Smoothly steers the head and neck toward a target, splitting the rotation so the neck leads + * and the head follows. Keeping this in one place is what stops actions from snapping the + * head when they hand over. + */ + private void look(MascotPose pose, float yaw, float pitch, float rate) { + lookYaw += (yaw - lookYaw) * clamp01(rate); + lookPitch += (pitch - lookPitch) * clamp01(rate); + rig.setWorld(pose, MascotPose.NECK, lookPitch * 0.38f, lookYaw * 0.34f, 0f); + rig.setWorld(pose, MascotPose.HEAD, lookPitch * 0.62f, lookYaw * 0.66f, 0f); + } + + private void arms(MascotPose pose, + float raiseL, float forwardL, float elbowL, float twistL, + float raiseR, float forwardR, float elbowR, float twistR) { + armSide(pose, true, raiseL, forwardL, elbowL, twistL); + armSide(pose, false, raiseR, forwardR, elbowR, twistR); + } + + /** + * Shoulder-relative arm pose. + * + * @param raise degrees above (+) or below (-) the A-pose rest + * @param forward degrees swung toward the camera (+) or behind the back (-) + * @param elbow elbow bend; negative folds the forearm toward the body front + * @param twist forearm rotation about its own length, positive pronates the left hand + */ + private void armSide( + MascotPose pose, boolean left, float raise, float forward, float elbow, float twist) { + int arm = left ? MascotPose.ARM_L : MascotPose.ARM_R; + int elbowSlot = left ? MascotPose.ELBOW_L : MascotPose.ELBOW_R; + int shoulder = left ? MascotPose.SHOULDER_L : MascotPose.SHOULDER_R; + int forearm = left ? MascotPose.FOREARM_L : MascotPose.FOREARM_R; + float side = left ? 1f : -1f; + // Raising the left (+X) arm is a positive world-Z roll; mirrored for the right arm. + rig.setWorld(pose, arm, forward * 0.55f, -side * forward * 0.45f, side * raise); + rig.setWorld(pose, shoulder, 0f, 0f, side * raise * 0.18f); + rig.setWorld(pose, elbowSlot, elbow * 0.85f, side * elbow * 0.35f, side * elbow * 0.20f); + // Twist about the measured forearm axis rather than a world axis, so a raised arm still + // rotates its palm correctly. + rig.setWorldAxis(pose, forearm, left ? forearmAxisL : forearmAxisR, side * twist); + // A little upper-arm counter-twist keeps the deltoid from shearing on big raises. + if (Math.abs(raise) > 40f) { + rig.addWorldAxis(pose, arm, left ? upperArmAxisL : upperArmAxisR, + side * (raise - 40f) * 0.12f); + } + } + + // ------------------------------------------------------------------ + // Maths + // ------------------------------------------------------------------ + + /** Rises 0->1 between a..b, holds 1 between b..c, falls back to 0 between c..d. */ + private static float smoothPulse(float value, float a, float b, float c, float d) { + if (value <= a || value >= d) return 0f; + if (value < b) return (value - a) / Math.max(0.0001f, b - a); + if (value <= c) return 1f; + return 1f - (value - c) / Math.max(0.0001f, d - c); + } + + private static float mix(float from, float to, float t) { + return from + (to - from) * t; + } + + private static float smoothStep01(float value) { + float t = clamp01(value); + return t * t * (3f - 2f * t); + } + + /** Smooth one-shot envelope with a held center and a settled end pose. */ + private static float actionEnvelope(float elapsed, float enterEnd, float holdEnd, + float exitEnd) { + return smoothStep01(smoothPulse(elapsed, 0f, enterEnd, holdEnd, exitEnd)); + } + + private static float sin(double value) { + return (float) Math.sin(value); + } + + private static float fract(double value) { + return (float) (value - Math.floor(value)); + } + + private static float clamp01(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/Quat.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/Quat.java new file mode 100644 index 0000000..3323c90 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/Quat.java @@ -0,0 +1,210 @@ +package com.seeker.n7cockpit; + +/** + * Minimal quaternion helpers for the mascot animation runtime. + * + *

Quaternions are stored as four consecutive floats in {@code (x, y, z, w)} order, matching + * glTF so baked clips need no re-ordering. Every method writes into a caller-supplied array at a + * caller-supplied offset: the animator evaluates 51 joints across several layers every frame and + * must not allocate.

+ */ +final class Quat { + private Quat() { + } + + static final float[] IDENTITY = {0f, 0f, 0f, 1f}; + + static void setIdentity(float[] out, int offset) { + out[offset] = 0f; + out[offset + 1] = 0f; + out[offset + 2] = 0f; + out[offset + 3] = 1f; + } + + static void copy(float[] src, int srcOffset, float[] dst, int dstOffset) { + dst[dstOffset] = src[srcOffset]; + dst[dstOffset + 1] = src[srcOffset + 1]; + dst[dstOffset + 2] = src[srcOffset + 2]; + dst[dstOffset + 3] = src[srcOffset + 3]; + } + + static boolean isIdentity(float[] q, int offset) { + return q[offset] == 0f && q[offset + 1] == 0f && q[offset + 2] == 0f; + } + + /** {@code out = a * b}; applying {@code out} rotates by {@code b} first, then {@code a}. */ + static void multiply(float[] a, int ao, float[] b, int bo, float[] out, int oo) { + float ax = a[ao], ay = a[ao + 1], az = a[ao + 2], aw = a[ao + 3]; + float bx = b[bo], by = b[bo + 1], bz = b[bo + 2], bw = b[bo + 3]; + out[oo] = aw * bx + ax * bw + ay * bz - az * by; + out[oo + 1] = aw * by - ax * bz + ay * bw + az * bx; + out[oo + 2] = aw * bz + ax * by - ay * bx + az * bw; + out[oo + 3] = aw * bw - ax * bx - ay * by - az * bz; + } + + static void conjugate(float[] q, int qo, float[] out, int oo) { + out[oo] = -q[qo]; + out[oo + 1] = -q[qo + 1]; + out[oo + 2] = -q[qo + 2]; + out[oo + 3] = q[qo + 3]; + } + + /** Axis must be unit length; the angle is in degrees. */ + static void fromAxisAngle(float x, float y, float z, float degrees, float[] out, int oo) { + float half = (float) Math.toRadians(degrees) * 0.5f; + float s = (float) Math.sin(half); + out[oo] = x * s; + out[oo + 1] = y * s; + out[oo + 2] = z * s; + out[oo + 3] = (float) Math.cos(half); + } + + /** + * Builds {@code Ry(yaw) * Rx(pitch) * Rz(roll)} in degrees. + * + *

This is the historical mascot authoring convention: "yaw turns, pitch nods, roll tilts" + * around world axes, so choreography code never has to reason about MMD bone axes.

+ */ + static void fromEuler(float pitch, float yaw, float roll, float[] out, int oo) { + float hx = (float) Math.toRadians(pitch) * 0.5f; + float hy = (float) Math.toRadians(yaw) * 0.5f; + float hz = (float) Math.toRadians(roll) * 0.5f; + float sx = (float) Math.sin(hx), cx = (float) Math.cos(hx); + float sy = (float) Math.sin(hy), cy = (float) Math.cos(hy); + float sz = (float) Math.sin(hz), cz = (float) Math.cos(hz); + // qY * qX + float ax = cy * sx; + float ay = sy * cx; + float az = -sy * sx; + float aw = cy * cx; + // (qY * qX) * qZ + out[oo] = ax * cz + ay * sz; + out[oo + 1] = ay * cz - ax * sz; + out[oo + 2] = aw * sz + az * cz; + out[oo + 3] = aw * cz - az * sz; + } + + static void normalize(float[] q, int offset) { + float x = q[offset], y = q[offset + 1], z = q[offset + 2], w = q[offset + 3]; + float lengthSquared = x * x + y * y + z * z + w * w; + if (lengthSquared <= 1e-12f) { + setIdentity(q, offset); + return; + } + float inverse = (float) (1.0 / Math.sqrt(lengthSquared)); + q[offset] = x * inverse; + q[offset + 1] = y * inverse; + q[offset + 2] = z * inverse; + q[offset + 3] = w * inverse; + } + + /** + * Shortest-arc interpolation. Falls back to normalized lerp for small angles, which is both + * cheaper and numerically safer than slerp near t=0 or coincident inputs. + */ + static void slerp(float[] a, int ao, float[] b, int bo, float t, float[] out, int oo) { + float ax = a[ao], ay = a[ao + 1], az = a[ao + 2], aw = a[ao + 3]; + float bx = b[bo], by = b[bo + 1], bz = b[bo + 2], bw = b[bo + 3]; + float dot = ax * bx + ay * by + az * bz + aw * bw; + if (dot < 0f) { + bx = -bx; + by = -by; + bz = -bz; + bw = -bw; + dot = -dot; + } + float scaleA; + float scaleB; + if (dot > 0.9995f) { + scaleA = 1f - t; + scaleB = t; + } else { + float theta = (float) Math.acos(dot); + float sinTheta = (float) Math.sin(theta); + scaleA = (float) Math.sin((1f - t) * theta) / sinTheta; + scaleB = (float) Math.sin(t * theta) / sinTheta; + } + out[oo] = ax * scaleA + bx * scaleB; + out[oo + 1] = ay * scaleA + by * scaleB; + out[oo + 2] = az * scaleA + bz * scaleB; + out[oo + 3] = aw * scaleA + bw * scaleB; + normalize(out, oo); + } + + /** + * Extracts a unit quaternion from the rotation part of a column-major 4x4 matrix. Any uniform + * scale baked into the matrix is divided out first, so skinning matrices produced by + * {@code jointWorld * inverseBind} convert cleanly. + */ + static void fromMatrix(float[] m, int mo, float[] out, int oo) { + float m00 = m[mo], m10 = m[mo + 1], m20 = m[mo + 2]; + float m01 = m[mo + 4], m11 = m[mo + 5], m21 = m[mo + 6]; + float m02 = m[mo + 8], m12 = m[mo + 9], m22 = m[mo + 10]; + float scale = (float) Math.sqrt(m00 * m00 + m10 * m10 + m20 * m20); + if (scale > 1e-8f) { + float inverse = 1f / scale; + m00 *= inverse; m10 *= inverse; m20 *= inverse; + m01 *= inverse; m11 *= inverse; m21 *= inverse; + m02 *= inverse; m12 *= inverse; m22 *= inverse; + } + float trace = m00 + m11 + m22; + if (trace > 0f) { + float s = (float) Math.sqrt(trace + 1f) * 2f; + out[oo] = (m21 - m12) / s; + out[oo + 1] = (m02 - m20) / s; + out[oo + 2] = (m10 - m01) / s; + out[oo + 3] = 0.25f * s; + } else if (m00 > m11 && m00 > m22) { + float s = (float) Math.sqrt(1f + m00 - m11 - m22) * 2f; + out[oo] = 0.25f * s; + out[oo + 1] = (m01 + m10) / s; + out[oo + 2] = (m02 + m20) / s; + out[oo + 3] = (m21 - m12) / s; + } else if (m11 > m22) { + float s = (float) Math.sqrt(1f + m11 - m00 - m22) * 2f; + out[oo] = (m01 + m10) / s; + out[oo + 1] = 0.25f * s; + out[oo + 2] = (m12 + m21) / s; + out[oo + 3] = (m02 - m20) / s; + } else { + float s = (float) Math.sqrt(1f + m22 - m00 - m11) * 2f; + out[oo] = (m02 + m20) / s; + out[oo + 1] = (m12 + m21) / s; + out[oo + 2] = 0.25f * s; + out[oo + 3] = (m10 - m01) / s; + } + normalize(out, oo); + } + + /** Writes a column-major 4x4 rotation matrix for the given unit quaternion. */ + static void toMatrix(float[] q, int qo, float[] m, int mo) { + float x = q[qo], y = q[qo + 1], z = q[qo + 2], w = q[qo + 3]; + float x2 = x + x, y2 = y + y, z2 = z + z; + float xx = x * x2, xy = x * y2, xz = x * z2; + float yy = y * y2, yz = y * z2, zz = z * z2; + float wx = w * x2, wy = w * y2, wz = w * z2; + m[mo] = 1f - (yy + zz); + m[mo + 1] = xy + wz; + m[mo + 2] = xz - wy; + m[mo + 3] = 0f; + m[mo + 4] = xy - wz; + m[mo + 5] = 1f - (xx + zz); + m[mo + 6] = yz + wx; + m[mo + 7] = 0f; + m[mo + 8] = xz + wy; + m[mo + 9] = yz - wx; + m[mo + 10] = 1f - (xx + yy); + m[mo + 11] = 0f; + m[mo + 12] = 0f; + m[mo + 13] = 0f; + m[mo + 14] = 0f; + m[mo + 15] = 1f; + } + + /** Rotation angle in degrees between two unit quaternions; used by the clip loop finder. */ + static float angleBetween(float[] a, int ao, float[] b, int bo) { + float dot = Math.abs(a[ao] * b[bo] + a[ao + 1] * b[bo + 1] + + a[ao + 2] * b[bo + 2] + a[ao + 3] * b[bo + 3]); + return (float) Math.toDegrees(2.0 * Math.acos(Math.min(1f, dot))); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealUsbCanGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealUsbCanGateway.java new file mode 100644 index 0000000..6302526 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealUsbCanGateway.java @@ -0,0 +1,41 @@ +package com.seeker.n7cockpit; + +/** Adapter retaining the copied USBCANFD-200U permission, self-test and transmit behavior. */ +final class RealUsbCanGateway implements UsbCanGateway { + private final UsbCanSelfTest delegate; + + RealUsbCanGateway(android.content.Context context, Listener listener) { + Listener safe = listener == null ? detail -> { } : listener; + delegate = new UsbCanSelfTest(context, safe::onStatus); + } + + @Override + public String describe() { + return delegate.describe(); + } + + @Override + public void refresh() { + delegate.refresh(); + } + + @Override + public void selfTest() { + delegate.requestPermissionOrVerify(); + } + + @Override + public void sendAc(boolean enabled) { + delegate.requestPermissionAndSendAc(enabled); + } + + @Override + public void close() { + delegate.close(); + } + + @Override + public boolean isSimulation() { + return false; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealVehicleGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealVehicleGateway.java new file mode 100644 index 0000000..3d28344 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/RealVehicleGateway.java @@ -0,0 +1,79 @@ +package com.seeker.n7cockpit; + +/** Real DiLink implementation backed by the authenticated shell-UID helper. */ +final class RealVehicleGateway implements VehicleGateway { + static final String ENVIRONMENT = "实车环境 · DiLink autoservice · N7COCKPIT1:37652"; + + private final AcBridgeClient client; + + RealVehicleGateway(android.content.Context context) { + client = new AcBridgeClient(context); + } + + @Override + public void prepare(Callback callback) { + Callback safe = callbackOrNoOp(callback); + client.prepare(safe::onResult); + } + + @Override + public void sendCommand(String command, Callback callback) { + Callback safe = callbackOrNoOp(callback); + if (command == null || command.indexOf('\n') >= 0 || command.indexOf('\r') >= 0) { + safe.onResult(false, "命令为空或包含换行符"); + return; + } + // Do not reinterpret commands here: the copied helper remains the authoritative allowlist. + client.sendVehicleCommand(command.trim(), safe::onResult); + } + + @Override + public void readSnapshot(SnapshotCallback callback) { + SnapshotCallback safe = snapshotCallbackOrNoOp(callback); + client.sendVehicleCommandRaw(VehicleCommands.VEHICLE_STATUS, + (vehicleOk, vehicleRaw) -> { + if (!vehicleOk) { + safe.onResult(false, null, vehicleRaw); + return; + } + client.sendVehicleCommandRaw(VehicleCommands.CLIMATE_STATUS, + (climateOk, climateRaw) -> { + if (!climateOk) { + safe.onResult(false, null, climateRaw); + return; + } + try { + VehicleSnapshot snapshot = VehicleResponseParser.parseSnapshot( + vehicleRaw, climateRaw, false, ENVIRONMENT); + safe.onResult(true, snapshot, "实车状态已刷新"); + } catch (IllegalArgumentException parseError) { + safe.onResult(false, null, + "车辆服务返回格式异常:" + parseError.getMessage()); + } + }); + }); + } + + @Override + public void shutdown() { + client.shutdown(); + } + + @Override + public boolean isSimulation() { + return false; + } + + @Override + public String environmentLabel() { + return ENVIRONMENT; + } + + private static Callback callbackOrNoOp(Callback callback) { + return callback == null ? (success, detail) -> { } : callback; + } + + private static SnapshotCallback snapshotCallbackOrNoOp(SnapshotCallback callback) { + return callback == null ? (success, snapshot, detail) -> { } : callback; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfActivationService.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfActivationService.java new file mode 100644 index 0000000..ddba07a --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfActivationService.java @@ -0,0 +1,66 @@ +package com.seeker.n7cockpit; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** Short-lived foreground service that waits for adbd and restores the helper after boot. */ +public final class SelfActivationService extends Service { + private static final String CHANNEL_ID = "n7_cockpit_restore"; + private static final int NOTIFICATION_ID = 7_652; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + + @Override + public void onCreate() { + super.onCreate(); + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(new NotificationChannel(CHANNEL_ID, + "N7 车辆控制恢复", NotificationManager.IMPORTANCE_LOW)); + Notification notification = new Notification.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher) + .setContentTitle("N7 车辆控制") + .setContentText("正在恢复车辆控制服务") + .setOngoing(true) + .build(); + startForeground(NOTIFICATION_ID, notification); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + executor.execute(() -> { + SelfAdbActivator activator = new SelfAdbActivator(getApplicationContext()); + for (int i = 0; i < 24; i++) { + SelfAdbActivator.Result result = activator.ensureHelper(); + if (result.success) break; + try { + Thread.sleep(5_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + stopForeground(true); + stopSelf(startId); + }); + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } +} + + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfAdbActivator.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfAdbActivator.java new file mode 100644 index 0000000..a034c66 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/SelfAdbActivator.java @@ -0,0 +1,112 @@ +package com.seeker.n7cockpit; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageManager; + +import java.io.File; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; + +import dadb.AdbKeyPair; +import dadb.AdbShellResponse; +import dadb.Dadb; + +/** Starts the fixed-function helper through the vehicle's loopback ADB daemon. */ +final class SelfAdbActivator { + private static final int ADB_PORT = 5_555; + private static final String HELPER_NAME = "n7_cockpit_helper"; + private final Context context; + + static final class Result { + final boolean success; + final String detail; + + Result(boolean success, String detail) { + this.success = success; + this.detail = detail; + } + } + + SelfAdbActivator(Context context) { + this.context = context; + } + + Result ensureHelper() { + if (AcBridgeClient.isExpectedHelperReady(context)) { + enableBootRecovery(); + return new Result(true, "车辆控制服务已就绪"); + } + + if (!canConnect(ADB_PORT, 800)) { + return new Result(false, + "车机本机 ADB 端口 5555 未开启。需要先让无线 ADB 持久开关生效。"); + } + + File keyDir = new File(context.getNoBackupFilesDir(), "self-adb"); + File privateKey = new File(keyDir, "adbkey"); + File publicKey = new File(keyDir, "adbkey.pub"); + + try { + if (!privateKey.isFile() || !publicKey.isFile()) { + AdbKeyPair.generate(privateKey, publicKey); + } + AdbKeyPair keyPair = AdbKeyPair.read(privateKey, publicKey); + + String apkPath = context.getApplicationInfo().sourceDir; + String authToken = BridgeAuth.getOrCreate(context); + String command = "old=$(pidof " + HELPER_NAME + "); " + + "[ -z \"$old\" ] || kill -9 $old; " + + "CLASSPATH='" + shellQuote(apkPath) + "' " + + "setsid app_process /system/bin --nice-name=" + HELPER_NAME + " " + + AcShellBridge.class.getName() + " " + shellQuote(authToken) + + " /data/local/tmp/n7_cockpit_helper.log 2>&1 &"; + + try (Dadb dadb = Dadb.create("127.0.0.1", ADB_PORT, keyPair, + 2_500, 25_000, false)) { + AdbShellResponse response = dadb.shell(command); + if (response.getExitCode() != 0) { + return new Result(false, "自动激活失败:" + response.getAllOutput().trim()); + } + } + + for (int i = 0; i < 20; i++) { + if (AcBridgeClient.isExpectedHelperReady(context)) { + enableBootRecovery(); + return new Result(true, "车辆控制服务已自动恢复"); + } + Thread.sleep(150); + } + return new Result(false, "辅助服务未启动,请检查车机授权弹窗和 ADB 状态"); + } catch (Exception e) { + String message = e.getMessage(); + if (message == null || message.isEmpty()) message = e.getClass().getSimpleName(); + return new Result(false, "本机 ADB 授权/连接失败:" + message + + "。首次使用请在车机弹窗中勾选“始终允许”,然后再点一次。"); + } + } + + private void enableBootRecovery() { + ComponentName receiver = new ComponentName(context, BootReceiver.class); + context.getPackageManager().setComponentEnabledSetting(receiver, + PackageManager.COMPONENT_ENABLED_STATE_ENABLED, + PackageManager.DONT_KILL_APP); + } + + private static boolean canConnect(int port, int timeoutMillis) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), + timeoutMillis); + return true; + } catch (Exception ignored) { + return false; + } + } + + private static String shellQuote(String value) { + return value.replace("'", "'\\''"); + } +} + + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanGateway.java new file mode 100644 index 0000000..e365d71 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanGateway.java @@ -0,0 +1,24 @@ +package com.seeker.n7cockpit; + +/** USB-CAN boundary used by the diagnostics panel. */ +public interface UsbCanGateway { + interface Listener { + void onStatus(String detail); + } + + String describe(); + + void refresh(); + + void selfTest(); + + void sendAc(boolean enabled); + + void close(); + + boolean isSimulation(); + + static UsbCanGateway create(android.content.Context context, Listener listener) { + return GatewayFactory.createUsb(context, listener); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanSelfTest.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanSelfTest.java new file mode 100644 index 0000000..fe2c4fa --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/UsbCanSelfTest.java @@ -0,0 +1,284 @@ +package com.seeker.n7cockpit; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; +import android.os.Build; + +import java.util.Locale; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * USB permission and fixed-function test coordinator for the ZLG USBCANFD-200U. + */ +final class UsbCanSelfTest { + interface Listener { + void onStatus(String detail); + } + + static final int ZLG_VENDOR_ID = 0x3068; + static final int USBCANFD_200U_PRODUCT_ID = 0x0009; + + private static final String ACTION_USB_PERMISSION = + "com.seeker.n7cockpit.USB_CAN_PERMISSION"; + + private final Context context; + private final UsbManager usbManager; + private final Listener listener; + private final ExecutorService usbWorker = Executors.newSingleThreadExecutor(); + private final AtomicInteger pendingAcAction = new AtomicInteger(); + private final AtomicBoolean permissionPending = new AtomicBoolean(false); + private final AtomicBoolean workPending = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); + private boolean receiverRegistered; + + private final BroadcastReceiver permissionReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context receiverContext, Intent intent) { + if (closed.get()) return; + if (!ACTION_USB_PERMISSION.equals(intent.getAction())) return; + permissionPending.set(false); + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + if (!isTarget(device)) { + pendingAcAction.set(0); + publish("USB 授权返回了非目标设备,未进行访问。\n" + describe()); + return; + } + boolean granted = intent.getBooleanExtra( + UsbManager.EXTRA_PERMISSION_GRANTED, false); + if (!granted) { + pendingAcAction.set(0); + publish("USB CAN 盒授权未允许;不会访问设备,也不会发送 CAN。\n" + describe()); + return; + } + int action = pendingAcAction.getAndSet(0); + if (action != 0) { + boolean enabled = action == 1; + sendAcPower(device, enabled); + } else { + submitUsbWork("自检", () -> verifyOpenAndClaim(device)); + } + } + }; + + UsbCanSelfTest(Context context, Listener listener) { + this.context = context.getApplicationContext(); + this.usbManager = (UsbManager) context.getSystemService(Context.USB_SERVICE); + this.listener = listener; + registerReceiver(); + } + + void requestPermissionOrVerify() { + requestPermission(0); + } + + void requestPermissionAndSendAc(boolean enabled) { + requestPermission(enabled ? 1 : 2); + } + + private void requestPermission(int acAction) { + if (closed.get()) return; + UsbDevice device = findTarget(); + if (device == null) { + publish("未检测到 ZLG USBCANFD-200U,请检查车机 USB 连接。\n" + describe()); + return; + } + if (usbManager.hasPermission(device)) { + if (acAction != 0) sendAcPower(device, acAction == 1); + else submitUsbWork("自检", () -> verifyOpenAndClaim(device)); + return; + } + + if (!permissionPending.compareAndSet(false, true)) { + publish("USB 授权请求已在等待用户确认,未追加新任务。"); + return; + } + pendingAcAction.set(acAction); + + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // UsbManager adds the device and permission result to this PendingIntent. + flags |= PendingIntent.FLAG_MUTABLE; + } + Intent permissionIntent = new Intent(ACTION_USB_PERMISSION) + .setPackage(context.getPackageName()); + PendingIntent pendingIntent = PendingIntent.getBroadcast( + context, 0, permissionIntent, flags); + publish("已检测到 CAN 盒,等待车机确认 USB 访问权限…\n" + describe()); + try { + usbManager.requestPermission(device, pendingIntent); + } catch (RuntimeException error) { + permissionPending.set(false); + pendingAcAction.set(0); + publish("USB 授权请求失败,未访问设备:" + error.getMessage()); + } + } + + void refresh() { + publish(describe()); + } + + void close() { + if (!closed.compareAndSet(false, true)) return; + permissionPending.set(false); + pendingAcAction.set(0); + if (!receiverRegistered) return; + try { + context.unregisterReceiver(permissionReceiver); + } catch (IllegalArgumentException ignored) { + // The Activity may already be tearing down after a configuration change. + } + receiverRegistered = false; + usbWorker.shutdownNow(); + } + + String describe() { + UsbDevice device = findTarget(); + if (device == null) { + return "CAN 盒:未连接\n目标设备:ZLG USBCANFD-200U (3068:0009)"; + } + + StringBuilder text = new StringBuilder(); + text.append("CAN 盒:已连接") + .append("\n型号:") + .append(safe(device.getProductName(), "USBCANFD-200U")) + .append("\nUSB:") + .append(String.format(Locale.US, "%04X:%04X", + device.getVendorId(), device.getProductId())) + .append(" · 接口 ").append(device.getInterfaceCount()) + .append(" · 授权 ") + .append(usbManager.hasPermission(device) ? "已允许" : "待允许"); + + if (device.getInterfaceCount() > 0) { + UsbInterface usbInterface = device.getInterface(0); + text.append("\n端点:").append(usbInterface.getEndpointCount()).append(" 个"); + for (int i = 0; i < usbInterface.getEndpointCount(); i++) { + UsbEndpoint endpoint = usbInterface.getEndpoint(i); + text.append(i == 0 ? "(" : "、") + .append(String.format(Locale.US, "0x%02X", endpoint.getAddress())); + } + text.append(")"); + } + text.append("\n模式:只读 USB 自检,未初始化 CAN、未发送报文"); + return text.toString(); + } + + private void verifyOpenAndClaim(UsbDevice device) { + if (device.getInterfaceCount() < 1) { + publish("USB CAN 盒没有可用接口;未发送 CAN。\n" + describe()); + return; + } + UsbInterface usbInterface = device.getInterface(0); + UsbDeviceConnection connection = usbManager.openDevice(device); + if (connection == null) { + publish("USB 权限已允许,但打开设备失败;未发送 CAN。\n" + describe()); + return; + } + + boolean claimed = false; + try { + claimed = connection.claimInterface(usbInterface, true); + int bulkIn = 0; + int bulkOut = 0; + for (int i = 0; i < usbInterface.getEndpointCount(); i++) { + UsbEndpoint endpoint = usbInterface.getEndpoint(i); + if (endpoint.getType() != UsbConstantsCompat.USB_ENDPOINT_XFER_BULK) continue; + if (endpoint.getDirection() == UsbConstantsCompat.USB_DIR_IN) bulkIn++; + else bulkOut++; + } + String verdict = claimed + ? "USB CAN 自检通过:接口可打开并独占,批量输入 " + bulkIn + + "、批量输出 " + bulkOut + "。" + : "USB 权限已允许,但接口独占失败。"; + publish(verdict + "\n未初始化 CAN、未启用终端电阻、未发送任何报文。\n" + + describe()); + } finally { + if (claimed) connection.releaseInterface(usbInterface); + connection.close(); + } + } + + private void sendAcPower(UsbDevice device, boolean enabled) { + submitUsbWork("空调" + (enabled ? "开" : "关"), () -> { + publish("准备实际发送空调" + (enabled ? "开" : "关") + + ":只允许标准 ID 0x1DE,终端电阻关闭,初始化失败会立即停止…"); + ZlgUsbCanDriver.Result result = ZlgUsbCanDriver.sendAcPower( + usbManager, device, enabled); + publish((result.success ? "CAN 空调" + (enabled ? "开" : "关") + "测试成功\n" + : "CAN 空调" + (enabled ? "开" : "关") + "测试失败\n") + + result.detail); + }); + } + + private void submitUsbWork(String label, Runnable work) { + if (closed.get()) return; + if (!workPending.compareAndSet(false, true)) { + publish("USB-CAN 上一项任务尚未结束,未排队:" + label); + return; + } + try { + usbWorker.execute(() -> { + try { + if (!closed.get()) work.run(); + } finally { + workPending.set(false); + } + }); + } catch (RejectedExecutionException rejected) { + workPending.set(false); + if (!closed.get()) publish("USB-CAN 任务无法启动:" + label); + } + } + + private UsbDevice findTarget() { + if (usbManager == null) return null; + for (UsbDevice device : usbManager.getDeviceList().values()) { + if (isTarget(device)) return device; + } + return null; + } + + private static boolean isTarget(UsbDevice device) { + return device != null + && device.getVendorId() == ZLG_VENDOR_ID + && device.getProductId() == USBCANFD_200U_PRODUCT_ID; + } + + private void registerReceiver() { + IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED); + } else { + context.registerReceiver(permissionReceiver, filter); + } + receiverRegistered = true; + } + + private void publish(String detail) { + if (!closed.get() && listener != null) listener.onStatus(detail); + } + + private static String safe(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + /** Keeps the USB numeric constants local and avoids any vendor SDK dependency. */ + private static final class UsbConstantsCompat { + static final int USB_ENDPOINT_XFER_BULK = 2; + static final int USB_DIR_IN = 0x80; + + private UsbConstantsCompat() {} + } +} + diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommand.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommand.java new file mode 100644 index 0000000..f696e7d --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommand.java @@ -0,0 +1,126 @@ +package com.seeker.n7cockpit; + +/** Parsed allowlisted command used only by the simulator. The real helper enforces its own list. */ +final class VehicleCommand { + enum Kind { + VEHICLE_STATUS, + CLIMATE_STATUS, + AC_POWER, + CLIMATE_POWER, + CLIMATE_AUTO, + CLIMATE_CYCLE, + DEFROST_FRONT, + DEFROST_REAR, + CLIMATE_TEMP, + ALL_WINDOWS, + WINDOW, + SUNSHADE, + LIGHT + } + + final Kind kind; + final String target; + final int value; + final String wireCommand; + + private VehicleCommand(Kind kind, String target, int value, String wireCommand) { + this.kind = kind; + this.target = target; + this.value = value; + this.wireCommand = wireCommand; + } + + static VehicleCommand parse(String input) { + if (input == null) throw invalid(); + String command = input.trim(); + if (command.isEmpty() || command.indexOf('\n') >= 0 || command.indexOf('\r') >= 0) { + throw invalid(); + } + if (VehicleCommands.VEHICLE_STATUS.equals(command)) { + return new VehicleCommand(Kind.VEHICLE_STATUS, "", 0, command); + } + if (VehicleCommands.CLIMATE_STATUS.equals(command)) { + return new VehicleCommand(Kind.CLIMATE_STATUS, "", 0, command); + } + if ("AC 0".equals(command) || "AC 1".equals(command)) { + return bitCommand(Kind.AC_POWER, "AC", command); + } + if (command.startsWith("CLIMATE POWER ")) { + return bitCommand(Kind.CLIMATE_POWER, "CLIMATE POWER", command); + } + if (command.startsWith("CLIMATE AUTO ")) { + return bitCommand(Kind.CLIMATE_AUTO, "CLIMATE AUTO", command); + } + if ("CLIMATE CYCLE INNER".equals(command) || "CLIMATE CYCLE OUTER".equals(command)) { + return new VehicleCommand(Kind.CLIMATE_CYCLE, "", + command.endsWith("INNER") ? 1 : 0, command); + } + if (command.startsWith("CLIMATE DEFROST_FRONT ")) { + return bitCommand(Kind.DEFROST_FRONT, "CLIMATE DEFROST_FRONT", command); + } + if (command.startsWith("CLIMATE DEFROST_REAR ")) { + return bitCommand(Kind.DEFROST_REAR, "CLIMATE DEFROST_REAR", command); + } + if (command.startsWith("CLIMATE TEMP ")) { + int value = decimal(command.substring("CLIMATE TEMP ".length())); + if (value < 16 || value > 30) throw invalid(); + return new VehicleCommand(Kind.CLIMATE_TEMP, "", value, command); + } + if (command.startsWith("WINDOWS ")) { + int value = decimal(command.substring("WINDOWS ".length())); + if (value != 0 && value != 10 && value != 50 && value != 100) throw invalid(); + return new VehicleCommand(Kind.ALL_WINDOWS, "", value, command); + } + if (command.startsWith("WINDOW ")) { + String[] parts = command.split(" ", -1); + if (parts.length != 3 || !isWindow(parts[1])) throw invalid(); + int value = decimal(parts[2]); + if (value != 0 && value != 100) throw invalid(); + return new VehicleCommand(Kind.WINDOW, parts[1], value, command); + } + if ("SUNSHADE OPEN".equals(command) || "SUNSHADE CLOSE".equals(command)) { + return new VehicleCommand(Kind.SUNSHADE, "", + command.endsWith("OPEN") ? 1 : 0, command); + } + if (command.startsWith("LIGHT ")) { + String[] parts = command.split(" ", -1); + if (parts.length != 3 || !("INTERIOR".equals(parts[1]) + || "AMBIENT".equals(parts[1]) || "DRL".equals(parts[1]))) { + throw invalid(); + } + int value = bit(parts[2]); + return new VehicleCommand(Kind.LIGHT, parts[1], value, command); + } + throw invalid(); + } + + private static VehicleCommand bitCommand(Kind kind, String prefix, String command) { + if (!(command.equals(prefix + " 0") || command.equals(prefix + " 1"))) throw invalid(); + int value = bit(command.substring(prefix.length() + 1)); + return new VehicleCommand(kind, "", value, command); + } + + private static int bit(String value) { + if ("0".equals(value)) return 0; + if ("1".equals(value)) return 1; + throw invalid(); + } + + private static int decimal(String value) { + if (!value.matches("0|[1-9][0-9]*")) throw invalid(); + try { + return Integer.parseInt(value); + } catch (NumberFormatException error) { + throw invalid(); + } + } + + private static boolean isWindow(String target) { + return "FL".equals(target) || "FR".equals(target) + || "RL".equals(target) || "RR".equals(target); + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("ERR invalid-command"); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommands.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommands.java new file mode 100644 index 0000000..4d39785 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleCommands.java @@ -0,0 +1,79 @@ +package com.seeker.n7cockpit; + +/** Type-safe builders for every command accepted by the fixed-function shell helper. */ +public final class VehicleCommands { + public static final String VEHICLE_STATUS = "VEHICLE STATUS"; + public static final String CLIMATE_STATUS = "CLIMATE STATUS"; + + public enum Window { + FRONT_LEFT("FL"), FRONT_RIGHT("FR"), REAR_LEFT("RL"), REAR_RIGHT("RR"); + + final String wireName; + + Window(String wireName) { + this.wireName = wireName; + } + } + + public enum Light { + INTERIOR, AMBIENT, DRL + } + + private VehicleCommands() {} + + public static String acPower(boolean enabled) { + return "AC " + bit(enabled); + } + + public static String climatePower(boolean enabled) { + return "CLIMATE POWER " + bit(enabled); + } + + public static String climateAuto(boolean enabled) { + return "CLIMATE AUTO " + bit(enabled); + } + + public static String climateInnerCycle(boolean inner) { + return "CLIMATE CYCLE " + (inner ? "INNER" : "OUTER"); + } + + public static String climateTemperature(int celsius) { + if (celsius < 16 || celsius > 30) { + throw new IllegalArgumentException("climate temperature must be 16..30℃"); + } + return "CLIMATE TEMP " + celsius; + } + + public static String frontDefrost(boolean enabled) { + return "CLIMATE DEFROST_FRONT " + bit(enabled); + } + + public static String rearDefrost(boolean enabled) { + return "CLIMATE DEFROST_REAR " + bit(enabled); + } + + public static String allWindows(int percent) { + if (percent != 0 && percent != 10 && percent != 50 && percent != 100) { + throw new IllegalArgumentException("all-window position must be 0, 10, 50 or 100"); + } + return "WINDOWS " + percent; + } + + public static String window(Window window, boolean open) { + if (window == null) throw new IllegalArgumentException("window is null"); + return "WINDOW " + window.wireName + " " + (open ? 100 : 0); + } + + public static String sunshade(boolean open) { + return "SUNSHADE " + (open ? "OPEN" : "CLOSE"); + } + + public static String light(Light light, boolean enabled) { + if (light == null) throw new IllegalArgumentException("light is null"); + return "LIGHT " + light.name() + " " + bit(enabled); + } + + private static int bit(boolean enabled) { + return enabled ? 1 : 0; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGateway.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGateway.java new file mode 100644 index 0000000..24fe5e1 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGateway.java @@ -0,0 +1,35 @@ +package com.seeker.n7cockpit; + +/** + * Small asynchronous boundary between the cockpit UI and vehicle hardware. + * + *

Callbacks are delivered on a gateway worker thread. Android views must marshal them back + * to the main thread (for example with {@code Activity.runOnUiThread}).

+ */ +public interface VehicleGateway { + interface Callback { + void onResult(boolean success, String detail); + } + + interface SnapshotCallback { + void onResult(boolean success, VehicleSnapshot snapshot, String detail); + } + + /** Prepares the real helper or the local simulator. Safe to call more than once. */ + void prepare(Callback callback); + + /** Sends one command from the fixed vehicle command vocabulary. */ + void sendCommand(String command, Callback callback); + + /** Reads vehicle and climate state as one immutable snapshot. */ + void readSnapshot(SnapshotCallback callback); + + /** Stops worker threads and releases local resources. */ + void shutdown(); + + /** True only when no real vehicle service is touched. */ + boolean isSimulation(); + + /** Human-readable source label; simulation labels are deliberately prominent. */ + String environmentLabel(); +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlMesh.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlMesh.java new file mode 100644 index 0000000..5321f89 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlMesh.java @@ -0,0 +1,697 @@ +package com.seeker.n7cockpit; + +import android.opengl.GLES20; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.ShortBuffer; + +/** + * Small GLES 2.0 indexed mesh used by the procedural vehicle renderer. + * + *

Vertices are interleaved as {@code position.xyz, normal.xyz}. Geometry factories allocate + * CPU data only when a mesh is created; {@link #draw(int, int)} performs no Java allocation. + * Buffer upload and release must run on the GL thread while its EGL context is current.

+ */ +final class VehicleGlMesh { + private static final int FLOAT_BYTES = 4; + private static final int SHORT_BYTES = 2; + private static final int COMPONENTS_PER_VERTEX = 6; + private static final int MAX_VERTEX_COUNT_EXCLUSIVE = 65_535; + + private static final float HALF = 0.5f; + private static final float BEVEL_RADIUS = 0.12f; + private static final float BEVEL_CORE = HALF - BEVEL_RADIUS; + private static final float[] BEVEL_GRID = {-HALF, -BEVEL_CORE, BEVEL_CORE, HALF}; + + // X/Y profile travels counter-clockwise when viewed from +Z. A broad shoulder, flatter deck + // and tucked rocker avoid the oval/tube silhouette produced by a conventional ellipse. + private static final float[] CROSSOVER_PROFILE_X = { + 1.00f, 0.99f, 0.90f, 0.64f, + 0.00f, -0.64f, -0.90f, -0.99f, + -1.00f, -0.94f, -0.72f, -0.34f, + 0.00f, 0.34f, 0.72f, 0.94f + }; + private static final float[] CROSSOVER_PROFILE_Y = { + -0.04f, 0.38f, 0.70f, 0.92f, + 1.00f, 0.92f, 0.70f, 0.38f, + -0.04f, -0.48f, -0.78f, -0.95f, + -1.00f, -0.95f, -0.78f, -0.48f + }; + + // Front is -Z. Width peaks at the front/rear wheel shoulders, the nose and bonnet stay low, + // the middle/rear belt rises slightly, and the final two stations pull into a compact tail. + private static final float[] CROSSOVER_SECTION_Z = { + -0.50f, -0.46f, -0.38f, -0.28f, + -0.16f, -0.03f, 0.10f, 0.23f, + 0.34f, 0.42f, 0.48f, 0.50f + }; + private static final float[] CROSSOVER_HALF_WIDTH = { + 0.24f, 0.35f, 0.45f, 0.50f, + 0.495f, 0.485f, 0.49f, 0.505f, + 0.50f, 0.47f, 0.36f, 0.24f + }; + private static final float[] CROSSOVER_CENTER_Y = { + -0.12f, -0.10f, -0.06f, -0.01f, + 0.01f, 0.025f, 0.04f, 0.055f, + 0.07f, 0.075f, 0.055f, 0.02f + }; + private static final float[] CROSSOVER_HALF_HEIGHT = { + 0.17f, 0.25f, 0.34f, 0.42f, + 0.45f, 0.46f, 0.47f, 0.48f, + 0.48f, 0.43f, 0.32f, 0.20f + }; + + private final float[] vertices; + private final short[] indices; + private final int vertexComponents; + private final int vertexStrideBytes; + private final boolean hasTexCoords; + private final int[] glBufferIds = new int[2]; + + private int vertexBufferId; + private int indexBufferId; + + private VehicleGlMesh(float[] vertices, short[] indices) { + this(vertices, indices, COMPONENTS_PER_VERTEX); + } + + private VehicleGlMesh(float[] vertices, short[] indices, int vertexComponents) { + if (vertexComponents != 6 && vertexComponents != 8) { + throw new IllegalArgumentException("Vehicle vertex stride must be position/normal or position/normal/UV"); + } + if (vertices == null || vertices.length == 0 + || vertices.length % vertexComponents != 0) { + throw new IllegalArgumentException("Vehicle mesh vertices must be non-empty xyz/normal data"); + } + if (indices == null || indices.length == 0 || indices.length % 3 != 0) { + throw new IllegalArgumentException("Vehicle mesh indices must contain complete triangles"); + } + int vertexCount = vertices.length / vertexComponents; + if (vertexCount >= MAX_VERTEX_COUNT_EXCLUSIVE) { + throw new IllegalArgumentException("Vehicle mesh exceeds GLES2 ushort vertex budget"); + } + for (short index : indices) { + if ((index & 0xffff) >= vertexCount) { + throw new IllegalArgumentException("Vehicle mesh index is outside vertex data"); + } + } + this.vertices = vertices; + this.indices = indices; + this.vertexComponents = vertexComponents; + vertexStrideBytes = vertexComponents * FLOAT_BYTES; + hasTexCoords = vertexComponents == 8; + } + + /** + * Creates a mesh from an offline-authored asset. The arrays remain CPU-side so an Android + * EGL context loss can recreate the immutable VBO/IBO without reopening the asset. + */ + static VehicleGlMesh fromInterleaved(float[] vertices, short[] indices) { + return new VehicleGlMesh(vertices, indices); + } + + static VehicleGlMesh fromInterleaved( + float[] vertices, short[] indices, boolean hasTexCoords) { + return new VehicleGlMesh(vertices, indices, hasTexCoords ? 8 : 6); + } + + /** Creates/recreates immutable VBO and IBO storage. */ + void upload() { + release(); + + FloatBuffer vertexData = ByteBuffer + .allocateDirect(vertices.length * FLOAT_BYTES) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer(); + vertexData.put(vertices).position(0); + ShortBuffer indexData = ByteBuffer + .allocateDirect(indices.length * SHORT_BYTES) + .order(ByteOrder.nativeOrder()) + .asShortBuffer(); + indexData.put(indices).position(0); + + GLES20.glGenBuffers(2, glBufferIds, 0); + vertexBufferId = glBufferIds[0]; + indexBufferId = glBufferIds[1]; + if (vertexBufferId == 0 || indexBufferId == 0) { + release(); + throw new IllegalStateException("Unable to allocate vehicle GL buffers"); + } + + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vertexBufferId); + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, + vertices.length * FLOAT_BYTES, vertexData, GLES20.GL_STATIC_DRAW); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBufferId); + GLES20.glBufferData(GLES20.GL_ELEMENT_ARRAY_BUFFER, + indices.length * SHORT_BYTES, indexData, GLES20.GL_STATIC_DRAW); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, 0); + } + + /** Draws this mesh with the caller's active shader program and uniforms. */ + void draw(int positionHandle, int normalHandle) { + draw(positionHandle, normalHandle, -1); + } + + /** Draws with optional TEXCOORD_0; untextured meshes supply a stable constant UV. */ + void draw(int positionHandle, int normalHandle, int texCoordHandle) { + if (vertexBufferId == 0 || indexBufferId == 0 + || positionHandle < 0 || normalHandle < 0) { + return; + } + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vertexBufferId); + GLES20.glEnableVertexAttribArray(positionHandle); + GLES20.glVertexAttribPointer(positionHandle, 3, GLES20.GL_FLOAT, + false, vertexStrideBytes, 0); + GLES20.glEnableVertexAttribArray(normalHandle); + GLES20.glVertexAttribPointer(normalHandle, 3, GLES20.GL_FLOAT, + false, vertexStrideBytes, 3 * FLOAT_BYTES); + if (texCoordHandle >= 0) { + if (hasTexCoords) { + GLES20.glEnableVertexAttribArray(texCoordHandle); + GLES20.glVertexAttribPointer(texCoordHandle, 2, GLES20.GL_FLOAT, + false, vertexStrideBytes, 6 * FLOAT_BYTES); + } else { + GLES20.glDisableVertexAttribArray(texCoordHandle); + GLES20.glVertexAttrib2f(texCoordHandle, 0f, 0f); + } + } + GLES20.glBindBuffer(GLES20.GL_ELEMENT_ARRAY_BUFFER, indexBufferId); + GLES20.glDrawElements(GLES20.GL_TRIANGLES, indices.length, + GLES20.GL_UNSIGNED_SHORT, 0); + } + + /** Deletes VBO/IBO names. Safe to call repeatedly on the GL thread. */ + void release() { + if (vertexBufferId != 0 || indexBufferId != 0) { + glBufferIds[0] = vertexBufferId; + glBufferIds[1] = indexBufferId; + GLES20.glDeleteBuffers(2, glBufferIds, 0); + } + vertexBufferId = 0; + indexBufferId = 0; + glBufferIds[0] = 0; + glBufferIds[1] = 0; + } + + /** Unit cube centered at the origin, with flat face normals. */ + static VehicleGlMesh unitBox() { + MeshWriter writer = new MeshWriter(24, 36); + addFlatQuad(writer, + -HALF, -HALF, HALF, HALF, -HALF, HALF, + HALF, HALF, HALF, -HALF, HALF, HALF); // +Z + addFlatQuad(writer, + HALF, -HALF, -HALF, -HALF, -HALF, -HALF, + -HALF, HALF, -HALF, HALF, HALF, -HALF); // -Z + addFlatQuad(writer, + HALF, -HALF, HALF, HALF, -HALF, -HALF, + HALF, HALF, -HALF, HALF, HALF, HALF); // +X + addFlatQuad(writer, + -HALF, -HALF, -HALF, -HALF, -HALF, HALF, + -HALF, HALF, HALF, -HALF, HALF, -HALF); // -X + addFlatQuad(writer, + -HALF, HALF, HALF, HALF, HALF, HALF, + HALF, HALF, -HALF, -HALF, HALF, -HALF); // +Y + addFlatQuad(writer, + -HALF, -HALF, -HALF, HALF, -HALF, -HALF, + HALF, -HALF, HALF, -HALF, -HALF, HALF); // -Y + return writer.finish(); + } + + /** Unit rounded box with a fixed 12% edge bevel and continuous outward normals. */ + static VehicleGlMesh beveledBox() { + MeshWriter writer = new MeshWriter(96, 324); + addBeveledFace(writer, 1f, 0f, 0f, 0f, 0f, -1f, 0f, 1f, 0f); + addBeveledFace(writer, -1f, 0f, 0f, 0f, 0f, 1f, 0f, 1f, 0f); + addBeveledFace(writer, 0f, 1f, 0f, 1f, 0f, 0f, 0f, 0f, -1f); + addBeveledFace(writer, 0f, -1f, 0f, 1f, 0f, 0f, 0f, 0f, 1f); + addBeveledFace(writer, 0f, 0f, 1f, 1f, 0f, 0f, 0f, 1f, 0f); + addBeveledFace(writer, 0f, 0f, -1f, -1f, 0f, 0f, 0f, 1f, 0f); + return writer.finish(); + } + + /** + * Trapezoidal unit wedge centered near the origin. Its top rises from {@code y=.15} at + * {@code z=-.5} to {@code y=.5} at {@code z=.5}; all six faces have flat outward normals. + */ + static VehicleGlMesh wedge() { + MeshWriter writer = new MeshWriter(24, 36); + final float lowTop = 0.15f; + addFlatQuad(writer, + -HALF, -HALF, -HALF, -HALF, lowTop, -HALF, + HALF, lowTop, -HALF, HALF, -HALF, -HALF); // -Z + addFlatQuad(writer, + -HALF, -HALF, HALF, HALF, -HALF, HALF, + HALF, HALF, HALF, -HALF, HALF, HALF); // +Z + addFlatQuad(writer, + -HALF, -HALF, -HALF, -HALF, -HALF, HALF, + -HALF, HALF, HALF, -HALF, lowTop, -HALF); // -X + addFlatQuad(writer, + HALF, -HALF, -HALF, HALF, lowTop, -HALF, + HALF, HALF, HALF, HALF, -HALF, HALF); // +X + addFlatQuad(writer, + -HALF, -HALF, -HALF, HALF, -HALF, -HALF, + HALF, -HALF, HALF, -HALF, -HALF, HALF); // -Y + addFlatQuad(writer, + -HALF, lowTop, -HALF, -HALF, HALF, HALF, + HALF, HALF, HALF, HALF, lowTop, -HALF); // sloped +Y + return writer.finish(); + } + + /** + * Continuous lower crossover body centered near the origin, with its nose toward -Z. + * + *

Twelve longitudinal stations and a sixteen-point shoulder/rocker loop form one closed + * loft. Side normals are derived from centered ring and longitudinal tangents, while duplicated + * cap vertices keep the nose and tail normals flat. All allocation happens in this factory.

+ */ + static VehicleGlMesh crossoverBody() { + final int sectionCount = CROSSOVER_SECTION_Z.length; + final int ringCount = CROSSOVER_PROFILE_X.length; + if (sectionCount < 8 + || CROSSOVER_HALF_WIDTH.length != sectionCount + || CROSSOVER_CENTER_Y.length != sectionCount + || CROSSOVER_HALF_HEIGHT.length != sectionCount + || ringCount < 12 + || CROSSOVER_PROFILE_Y.length != ringCount) { + throw new IllegalStateException("Invalid crossover body profile constants"); + } + + final int sideVertexCount = checkedVertexCount( + (long) sectionCount * ringCount, "crossover body side"); + final int totalVertexCount = checkedVertexCount( + (long) sideVertexCount + (ringCount + 1L) * 2L, + "crossover body"); + final int indexCount = checkedIndexCount( + (long) (sectionCount - 1) * ringCount * 6L + ringCount * 6L, + "crossover body"); + final float[] positions = new float[sideVertexCount * 3]; + + for (int section = 0; section < sectionCount; section++) { + float halfWidth = CROSSOVER_HALF_WIDTH[section]; + float centerY = CROSSOVER_CENTER_Y[section]; + float halfHeight = CROSSOVER_HALF_HEIGHT[section]; + float z = CROSSOVER_SECTION_Z[section]; + for (int ring = 0; ring < ringCount; ring++) { + int offset = (section * ringCount + ring) * 3; + positions[offset] = CROSSOVER_PROFILE_X[ring] * halfWidth; + positions[offset + 1] = centerY + CROSSOVER_PROFILE_Y[ring] * halfHeight; + positions[offset + 2] = z; + } + } + + MeshWriter writer = new MeshWriter(totalVertexCount, indexCount); + for (int section = 0; section < sectionCount; section++) { + int previousSection = Math.max(0, section - 1); + int nextSection = Math.min(sectionCount - 1, section + 1); + for (int ring = 0; ring < ringCount; ring++) { + int previousRing = (ring + ringCount - 1) % ringCount; + int nextRing = (ring + 1) % ringCount; + int offset = (section * ringCount + ring) * 3; + int previousRingOffset = (section * ringCount + previousRing) * 3; + int nextRingOffset = (section * ringCount + nextRing) * 3; + int previousSectionOffset = (previousSection * ringCount + ring) * 3; + int nextSectionOffset = (nextSection * ringCount + ring) * 3; + + float ringTangentX = positions[nextRingOffset] - positions[previousRingOffset]; + float ringTangentY = positions[nextRingOffset + 1] + - positions[previousRingOffset + 1]; + float ringTangentZ = positions[nextRingOffset + 2] + - positions[previousRingOffset + 2]; + float longitudinalTangentX = positions[nextSectionOffset] + - positions[previousSectionOffset]; + float longitudinalTangentY = positions[nextSectionOffset + 1] + - positions[previousSectionOffset + 1]; + float longitudinalTangentZ = positions[nextSectionOffset + 2] + - positions[previousSectionOffset + 2]; + + float normalX = ringTangentY * longitudinalTangentZ + - ringTangentZ * longitudinalTangentY; + float normalY = ringTangentZ * longitudinalTangentX + - ringTangentX * longitudinalTangentZ; + float normalZ = ringTangentX * longitudinalTangentY + - ringTangentY * longitudinalTangentX; + float normalScale = inverseLength(normalX, normalY, normalZ); + writer.vertex( + positions[offset], positions[offset + 1], positions[offset + 2], + normalX * normalScale, normalY * normalScale, normalZ * normalScale); + } + } + + for (int section = 0; section < sectionCount - 1; section++) { + int currentStart = section * ringCount; + int nextStart = currentStart + ringCount; + for (int ring = 0; ring < ringCount; ring++) { + int nextRing = (ring + 1) % ringCount; + writer.quad( + currentStart + ring, + currentStart + nextRing, + nextStart + nextRing, + nextStart + ring); + } + } + + int frontCenter = writer.vertexCount(); + writer.vertex(0f, CROSSOVER_CENTER_Y[0], CROSSOVER_SECTION_Z[0], + 0f, 0f, -1f); + int frontRing = writer.vertexCount(); + for (int ring = 0; ring < ringCount; ring++) { + int offset = ring * 3; + writer.vertex(positions[offset], positions[offset + 1], positions[offset + 2], + 0f, 0f, -1f); + } + for (int ring = 0; ring < ringCount; ring++) { + int nextRing = (ring + 1) % ringCount; + writer.triangle(frontCenter, frontRing + nextRing, frontRing + ring); + } + + int rearSection = sectionCount - 1; + int rearPositionStart = rearSection * ringCount * 3; + int rearCenter = writer.vertexCount(); + writer.vertex(0f, CROSSOVER_CENTER_Y[rearSection], CROSSOVER_SECTION_Z[rearSection], + 0f, 0f, 1f); + int rearRing = writer.vertexCount(); + for (int ring = 0; ring < ringCount; ring++) { + int offset = rearPositionStart + ring * 3; + writer.vertex(positions[offset], positions[offset + 1], positions[offset + 2], + 0f, 0f, 1f); + } + for (int ring = 0; ring < ringCount; ring++) { + int nextRing = (ring + 1) % ringCount; + writer.triangle(rearCenter, rearRing + ring, rearRing + nextRing); + } + return writer.finish(); + } + + /** Unit-diameter UV sphere centered at the origin. */ + static VehicleGlMesh uvSphere(int slices, int stacks) { + if (slices < 3 || stacks < 2) { + throw new IllegalArgumentException("Sphere requires at least 3 slices and 2 stacks"); + } + int ringCount = stacks - 1; + int vertexCount = checkedVertexCount( + 2L + (long) ringCount * ((long) slices + 1L), "sphere"); + int indexCount = checkedIndexCount( + (long) slices * ringCount * 6L, "sphere"); + MeshWriter writer = new MeshWriter(vertexCount, indexCount); + + int top = writer.vertexCount(); + writer.vertex(0f, HALF, 0f, 0f, 1f, 0f); + int firstRing = writer.vertexCount(); + for (int stack = 1; stack < stacks; stack++) { + float phi = (float) Math.PI * stack / stacks; + float y = (float) Math.cos(phi); + float ring = (float) Math.sin(phi); + for (int slice = 0; slice <= slices; slice++) { + float theta = (float) (Math.PI * 2.0) * slice / slices; + float nx = ring * (float) Math.cos(theta); + float nz = ring * (float) Math.sin(theta); + writer.vertex(nx * HALF, y * HALF, nz * HALF, nx, y, nz); + } + } + int bottom = writer.vertexCount(); + writer.vertex(0f, -HALF, 0f, 0f, -1f, 0f); + + int rowWidth = slices + 1; + for (int slice = 0; slice < slices; slice++) { + writer.triangle(top, firstRing + slice + 1, firstRing + slice); + } + for (int ring = 0; ring < ringCount - 1; ring++) { + int upperStart = firstRing + ring * rowWidth; + int lowerStart = upperStart + rowWidth; + for (int slice = 0; slice < slices; slice++) { + int a = upperStart + slice; + int b = a + 1; + int d = lowerStart + slice; + int c = d + 1; + writer.quad(a, b, c, d); + } + } + int lastRing = firstRing + (ringCount - 1) * rowWidth; + for (int slice = 0; slice < slices; slice++) { + writer.triangle(bottom, lastRing + slice, lastRing + slice + 1); + } + return writer.finish(); + } + + /** Unit cylinder centered at the origin, aligned to +Y, with smooth sides and flat caps. */ + static VehicleGlMesh cylinder(int segments) { + if (segments < 3) { + throw new IllegalArgumentException("Cylinder requires at least 3 segments"); + } + int vertexCount = checkedVertexCount(4L * segments + 6L, "cylinder"); + int indexCount = checkedIndexCount(12L * segments, "cylinder"); + MeshWriter writer = new MeshWriter(vertexCount, indexCount); + + int sideStart = writer.vertexCount(); + for (int segment = 0; segment <= segments; segment++) { + float angle = (float) (Math.PI * 2.0) * segment / segments; + float nx = (float) Math.cos(angle); + float nz = (float) Math.sin(angle); + float x = nx * HALF; + float z = nz * HALF; + writer.vertex(x, -HALF, z, nx, 0f, nz); + writer.vertex(x, HALF, z, nx, 0f, nz); + } + for (int segment = 0; segment < segments; segment++) { + int bottom = sideStart + segment * 2; + int top = bottom + 1; + int nextBottom = bottom + 2; + int nextTop = bottom + 3; + writer.quad(bottom, top, nextTop, nextBottom); + } + + int topCenter = writer.vertexCount(); + writer.vertex(0f, HALF, 0f, 0f, 1f, 0f); + int topRing = writer.vertexCount(); + for (int segment = 0; segment <= segments; segment++) { + float angle = (float) (Math.PI * 2.0) * segment / segments; + writer.vertex((float) Math.cos(angle) * HALF, HALF, + (float) Math.sin(angle) * HALF, 0f, 1f, 0f); + } + for (int segment = 0; segment < segments; segment++) { + writer.triangle(topCenter, topRing + segment + 1, topRing + segment); + } + + int bottomCenter = writer.vertexCount(); + writer.vertex(0f, -HALF, 0f, 0f, -1f, 0f); + int bottomRing = writer.vertexCount(); + for (int segment = 0; segment <= segments; segment++) { + float angle = (float) (Math.PI * 2.0) * segment / segments; + writer.vertex((float) Math.cos(angle) * HALF, -HALF, + (float) Math.sin(angle) * HALF, 0f, -1f, 0f); + } + for (int segment = 0; segment < segments; segment++) { + writer.triangle(bottomCenter, bottomRing + segment, bottomRing + segment + 1); + } + return writer.finish(); + } + + /** + * Unit-outer-diameter torus centered at the origin. Its ring lies in XZ and its hole axis is Y. + */ + static VehicleGlMesh torus(int majorSegments, int tubeSegments) { + if (majorSegments < 3 || tubeSegments < 3) { + throw new IllegalArgumentException("Torus requires at least 3 major and tube segments"); + } + int vertexCount = checkedVertexCount( + ((long) majorSegments + 1L) * ((long) tubeSegments + 1L), "torus"); + int indexCount = checkedIndexCount( + (long) majorSegments * tubeSegments * 6L, "torus"); + MeshWriter writer = new MeshWriter(vertexCount, indexCount); + final float majorRadius = 0.36f; + final float tubeRadius = HALF - majorRadius; + + for (int major = 0; major <= majorSegments; major++) { + float majorAngle = (float) (Math.PI * 2.0) * major / majorSegments; + float majorCos = (float) Math.cos(majorAngle); + float majorSin = (float) Math.sin(majorAngle); + for (int tube = 0; tube <= tubeSegments; tube++) { + float tubeAngle = (float) (Math.PI * 2.0) * tube / tubeSegments; + float tubeCos = (float) Math.cos(tubeAngle); + float tubeSin = (float) Math.sin(tubeAngle); + float radial = majorRadius + tubeRadius * tubeCos; + float nx = majorCos * tubeCos; + float ny = tubeSin; + float nz = majorSin * tubeCos; + writer.vertex(radial * majorCos, tubeRadius * tubeSin, radial * majorSin, + nx, ny, nz); + } + } + int rowWidth = tubeSegments + 1; + for (int major = 0; major < majorSegments; major++) { + for (int tube = 0; tube < tubeSegments; tube++) { + int a = major * rowWidth + tube; + int b = a + 1; + int d = a + rowWidth; + int c = d + 1; + writer.quad(a, b, c, d); + } + } + return writer.finish(); + } + + /** Unit square in the XY plane, centered at the origin and facing +Z. */ + static VehicleGlMesh quad() { + MeshWriter writer = new MeshWriter(4, 6); + addFlatQuad(writer, + -HALF, -HALF, 0f, HALF, -HALF, 0f, + HALF, HALF, 0f, -HALF, HALF, 0f); + return writer.finish(); + } + + private static void addBeveledFace( + MeshWriter writer, + float nx, float ny, float nz, + float ux, float uy, float uz, + float vx, float vy, float vz) { + int base = writer.vertexCount(); + for (int row = 0; row < BEVEL_GRID.length; row++) { + float v = BEVEL_GRID[row]; + for (int column = 0; column < BEVEL_GRID.length; column++) { + float u = BEVEL_GRID[column]; + float px = nx * HALF + ux * u + vx * v; + float py = ny * HALF + uy * u + vy * v; + float pz = nz * HALF + uz * u + vz * v; + float qx = clamp(px, -BEVEL_CORE, BEVEL_CORE); + float qy = clamp(py, -BEVEL_CORE, BEVEL_CORE); + float qz = clamp(pz, -BEVEL_CORE, BEVEL_CORE); + float dx = px - qx; + float dy = py - qy; + float dz = pz - qz; + float inverseLength = inverseLength(dx, dy, dz); + float outX = dx * inverseLength; + float outY = dy * inverseLength; + float outZ = dz * inverseLength; + writer.vertex( + qx + outX * BEVEL_RADIUS, + qy + outY * BEVEL_RADIUS, + qz + outZ * BEVEL_RADIUS, + outX, outY, outZ); + } + } + for (int row = 0; row < BEVEL_GRID.length - 1; row++) { + for (int column = 0; column < BEVEL_GRID.length - 1; column++) { + int a = base + row * BEVEL_GRID.length + column; + int b = a + 1; + int d = a + BEVEL_GRID.length; + int c = d + 1; + writer.quad(a, b, c, d); + } + } + } + + private static void addFlatQuad( + MeshWriter writer, + float ax, float ay, float az, + float bx, float by, float bz, + float cx, float cy, float cz, + float dx, float dy, float dz) { + float edgeAbX = bx - ax; + float edgeAbY = by - ay; + float edgeAbZ = bz - az; + float edgeAcX = cx - ax; + float edgeAcY = cy - ay; + float edgeAcZ = cz - az; + float normalX = edgeAbY * edgeAcZ - edgeAbZ * edgeAcY; + float normalY = edgeAbZ * edgeAcX - edgeAbX * edgeAcZ; + float normalZ = edgeAbX * edgeAcY - edgeAbY * edgeAcX; + float inverseLength = inverseLength(normalX, normalY, normalZ); + normalX *= inverseLength; + normalY *= inverseLength; + normalZ *= inverseLength; + + int base = writer.vertexCount(); + writer.vertex(ax, ay, az, normalX, normalY, normalZ); + writer.vertex(bx, by, bz, normalX, normalY, normalZ); + writer.vertex(cx, cy, cz, normalX, normalY, normalZ); + writer.vertex(dx, dy, dz, normalX, normalY, normalZ); + writer.quad(base, base + 1, base + 2, base + 3); + } + + private static float inverseLength(float x, float y, float z) { + float lengthSquared = x * x + y * y + z * z; + if (lengthSquared <= 1.0e-12f || !Float.isFinite(lengthSquared)) { + throw new IllegalArgumentException("Degenerate vehicle mesh normal"); + } + return 1f / (float) Math.sqrt(lengthSquared); + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static int checkedVertexCount(long count, String shape) { + if (count <= 0L || count >= MAX_VERTEX_COUNT_EXCLUSIVE) { + throw new IllegalArgumentException(shape + " exceeds GLES2 ushort vertex budget"); + } + return (int) count; + } + + private static int checkedIndexCount(long count, String shape) { + if (count <= 0L || count > Integer.MAX_VALUE || count % 3L != 0L) { + throw new IllegalArgumentException(shape + " index count is invalid"); + } + return (int) count; + } + + /** Fixed-capacity construction helper; used only by factory methods, never while drawing. */ + private static final class MeshWriter { + private final float[] vertexData; + private final short[] indexData; + private int vertexCursor; + private int indexCursor; + + MeshWriter(int vertexCount, int indexCount) { + if (vertexCount <= 0 || vertexCount >= MAX_VERTEX_COUNT_EXCLUSIVE + || indexCount <= 0 || indexCount % 3 != 0) { + throw new IllegalArgumentException("Invalid vehicle mesh capacity"); + } + vertexData = new float[vertexCount * COMPONENTS_PER_VERTEX]; + indexData = new short[indexCount]; + } + + int vertexCount() { + return vertexCursor / COMPONENTS_PER_VERTEX; + } + + void vertex(float x, float y, float z, float nx, float ny, float nz) { + if (vertexCursor + COMPONENTS_PER_VERTEX > vertexData.length) { + throw new IllegalStateException("Vehicle mesh vertex capacity exhausted"); + } + vertexData[vertexCursor++] = x; + vertexData[vertexCursor++] = y; + vertexData[vertexCursor++] = z; + vertexData[vertexCursor++] = nx; + vertexData[vertexCursor++] = ny; + vertexData[vertexCursor++] = nz; + } + + void triangle(int a, int b, int c) { + if (indexCursor + 3 > indexData.length) { + throw new IllegalStateException("Vehicle mesh index capacity exhausted"); + } + int count = vertexCount(); + if (a < 0 || b < 0 || c < 0 || a >= count || b >= count || c >= count) { + throw new IllegalArgumentException("Vehicle triangle references an absent vertex"); + } + indexData[indexCursor++] = (short) a; + indexData[indexCursor++] = (short) b; + indexData[indexCursor++] = (short) c; + } + + void quad(int a, int b, int c, int d) { + triangle(a, b, c); + triangle(a, c, d); + } + + VehicleGlMesh finish() { + if (vertexCursor != vertexData.length || indexCursor != indexData.length) { + throw new IllegalStateException("Vehicle mesh factory did not fill its buffers"); + } + return new VehicleGlMesh(vertexData, indexData); + } + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlTexture.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlTexture.java new file mode 100644 index 0000000..72f03a5 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlTexture.java @@ -0,0 +1,104 @@ +package com.seeker.n7cockpit; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.opengl.GLES20; +import android.opengl.GLUtils; + +/** Embedded glTF texture retained in compressed CPU form across EGL context recreation. */ +final class VehicleGlTexture { + private static final int DEFAULT_MAG_FILTER = GLES20.GL_LINEAR; + private static final int DEFAULT_MIN_FILTER = GLES20.GL_LINEAR_MIPMAP_LINEAR; + private static final int DEFAULT_WRAP = GLES20.GL_REPEAT; + + private final byte[] encodedImage; + private final int magFilter; + private final int minFilter; + private final int wrapS; + private final int wrapT; + private final int[] generatedId = new int[1]; + + private int textureId; + + VehicleGlTexture(byte[] encodedImage, int magFilter, int minFilter, int wrapS, int wrapT) { + if (encodedImage == null || encodedImage.length == 0) { + throw new IllegalArgumentException("Embedded vehicle texture is empty"); + } + this.encodedImage = encodedImage; + this.magFilter = validMagFilter(magFilter) ? magFilter : DEFAULT_MAG_FILTER; + this.minFilter = validMinFilter(minFilter) ? minFilter : DEFAULT_MIN_FILTER; + this.wrapS = validWrap(wrapS) ? wrapS : DEFAULT_WRAP; + this.wrapT = validWrap(wrapT) ? wrapT : DEFAULT_WRAP; + } + + int id() { + return textureId; + } + + void upload() { + release(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = false; + options.inPremultiplied = false; + Bitmap bitmap = BitmapFactory.decodeByteArray(encodedImage, 0, encodedImage.length, options); + if (bitmap == null) { + throw new IllegalStateException("Unable to decode embedded vehicle texture"); + } + try { + GLES20.glGenTextures(1, generatedId, 0); + textureId = generatedId[0]; + if (textureId == 0) { + throw new IllegalStateException("Unable to allocate vehicle texture"); + } + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, magFilter); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, minFilter); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, wrapS); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, wrapT); + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); + if (usesMipmaps(minFilter)) { + GLES20.glGenerateMipmap(GLES20.GL_TEXTURE_2D); + } + int error = GLES20.glGetError(); + if (error != GLES20.GL_NO_ERROR) { + release(); + throw new IllegalStateException("Vehicle texture upload failed: GL error " + error); + } + } finally { + bitmap.recycle(); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); + } + } + + void release() { + if (textureId != 0) { + generatedId[0] = textureId; + GLES20.glDeleteTextures(1, generatedId, 0); + } + textureId = 0; + generatedId[0] = 0; + } + + private static boolean usesMipmaps(int filter) { + return filter == GLES20.GL_NEAREST_MIPMAP_NEAREST + || filter == GLES20.GL_LINEAR_MIPMAP_NEAREST + || filter == GLES20.GL_NEAREST_MIPMAP_LINEAR + || filter == GLES20.GL_LINEAR_MIPMAP_LINEAR; + } + + private static boolean validMagFilter(int filter) { + return filter == GLES20.GL_NEAREST || filter == GLES20.GL_LINEAR; + } + + private static boolean validMinFilter(int filter) { + return filter == GLES20.GL_NEAREST + || filter == GLES20.GL_LINEAR + || usesMipmaps(filter); + } + + private static boolean validWrap(int wrap) { + return wrap == GLES20.GL_REPEAT + || wrap == GLES20.GL_CLAMP_TO_EDGE + || wrap == GLES20.GL_MIRRORED_REPEAT; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlbModel.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlbModel.java new file mode 100644 index 0000000..564dba1 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleGlbModel.java @@ -0,0 +1,919 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.opengl.Matrix; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Minimal, deterministic glTF 2.0 binary loader for the authored cockpit vehicle. + * + *

It intentionally implements only the uncompressed GLB subset exported by the checked-in + * Blender pipeline: one embedded BIN chunk, triangle primitives, POSITION/NORMAL attributes, + * unsigned indices, node TRS/matrices and metallic-roughness material factors. Keeping this + * narrow avoids a large engine dependency while retaining genuine Blender-authored meshes, + * pivots, object names and materials on GLES 2.0 head units.

+ */ +final class VehicleGlbModel { + private static final int GLB_MAGIC = 0x46546c67; + private static final int GLB_VERSION = 2; + private static final int CHUNK_JSON = 0x4e4f534a; + private static final int CHUNK_BIN = 0x004e4942; + + private static final int COMPONENT_BYTE = 5120; + private static final int COMPONENT_UNSIGNED_BYTE = 5121; + private static final int COMPONENT_SHORT = 5122; + private static final int COMPONENT_UNSIGNED_SHORT = 5123; + private static final int COMPONENT_UNSIGNED_INT = 5125; + private static final int COMPONENT_FLOAT = 5126; + + static final class Material { + final String name; + final float[] baseColor = {0.72f, 0.76f, 0.80f, 1f}; + final float[] emissive = {0f, 0f, 0f}; + float metallic; + float roughness = 0.5f; + boolean transparent; + boolean doubleSided; + VehicleGlTexture baseColorTexture; + + Material(String name) { + this.name = name == null ? "material" : name; + } + } + + static final class Primitive { + final VehicleGlMesh mesh; + final Material material; + + Primitive(VehicleGlMesh mesh, Material material) { + this.mesh = mesh; + this.material = material; + } + } + + static final class MeshData { + final String name; + final Primitive[] primitives; + + MeshData(String name, Primitive[] primitives) { + this.name = name == null ? "mesh" : name; + this.primitives = primitives; + } + } + + static final class Node { + final String name; + final int meshIndex; + final int[] children; + final float[] baseLocal = new float[16]; + final float[] local = new float[16]; + final float[] world = new float[16]; + int parent = -1; + + Node(String name, int meshIndex, int[] children, float[] authoredLocal) { + this.name = name == null ? "node" : name; + this.meshIndex = meshIndex; + this.children = children; + System.arraycopy(authoredLocal, 0, baseLocal, 0, 16); + System.arraycopy(authoredLocal, 0, local, 0, 16); + Matrix.setIdentityM(world, 0); + } + + void resetLocal() { + System.arraycopy(baseLocal, 0, local, 0, 16); + } + } + + static final class DrawRef { + final Node node; + final Primitive primitive; + float cameraDistanceSquared; + + DrawRef(Node node, Primitive primitive) { + this.node = node; + this.primitive = primitive; + } + } + + private static final class BufferView { + final int offset; + final int length; + final int stride; + + BufferView(int offset, int length, int stride) { + this.offset = offset; + this.length = length; + this.stride = stride; + } + } + + private static final class Accessor { + final int bufferView; + final int offset; + final int componentType; + final int count; + final int components; + final boolean normalized; + + Accessor(int bufferView, int offset, int componentType, int count, + int components, boolean normalized) { + this.bufferView = bufferView; + this.offset = offset; + this.componentType = componentType; + this.count = count; + this.components = components; + this.normalized = normalized; + } + } + + private static final class Sampler { + final int magFilter; + final int minFilter; + final int wrapS; + final int wrapT; + + Sampler(int magFilter, int minFilter, int wrapS, int wrapT) { + this.magFilter = magFilter; + this.minFilter = minFilter; + this.wrapS = wrapS; + this.wrapT = wrapT; + } + } + + private static final class ParsedTextures { + final VehicleGlTexture[] byIndex; + final VehicleGlTexture[] unique; + + ParsedTextures(VehicleGlTexture[] byIndex, VehicleGlTexture[] unique) { + this.byIndex = byIndex; + this.unique = unique; + } + } + + final Node[] nodes; + final MeshData[] meshes; + final int[] roots; + final DrawRef[] opaqueDraws; + final DrawRef[] transparentDraws; + private final VehicleGlTexture[] textures; + private final Map nodesByName; + private final boolean[] visiting; + private final boolean[] visited; + + private VehicleGlbModel( + Node[] nodes, MeshData[] meshes, int[] roots, VehicleGlTexture[] textures) { + this.nodes = nodes; + this.meshes = meshes; + this.roots = roots; + this.textures = textures; + visiting = new boolean[nodes.length]; + visited = new boolean[nodes.length]; + nodesByName = new HashMap<>(); + for (Node node : nodes) { + nodesByName.put(node.name.toLowerCase(Locale.ROOT), node); + } + for (int parentIndex = 0; parentIndex < nodes.length; parentIndex++) { + for (int child : nodes[parentIndex].children) { + if (child < 0 || child >= nodes.length) { + throw new IllegalArgumentException("GLB child node is outside node table"); + } + if (nodes[child].parent >= 0) { + throw new IllegalArgumentException("GLB node has more than one parent"); + } + nodes[child].parent = parentIndex; + } + } + int opaqueCount = 0; + int transparentCount = 0; + for (Node node : nodes) { + if (node.meshIndex < 0) continue; + for (Primitive primitive : meshes[node.meshIndex].primitives) { + if (primitive.material.transparent) transparentCount++; + else opaqueCount++; + } + } + opaqueDraws = new DrawRef[opaqueCount]; + transparentDraws = new DrawRef[transparentCount]; + int opaqueCursor = 0; + int transparentCursor = 0; + for (Node node : nodes) { + if (node.meshIndex < 0) continue; + for (Primitive primitive : meshes[node.meshIndex].primitives) { + DrawRef ref = new DrawRef(node, primitive); + if (primitive.material.transparent) { + transparentDraws[transparentCursor++] = ref; + } else { + opaqueDraws[opaqueCursor++] = ref; + } + } + } + } + + static VehicleGlbModel load(Context context, String assetPath) + throws IOException, JSONException { + if (context == null) throw new IllegalArgumentException("context == null"); + if (assetPath == null || assetPath.trim().isEmpty()) { + throw new IllegalArgumentException("assetPath is empty"); + } + byte[] bytes; + try (InputStream input = context.getAssets().open(assetPath)) { + bytes = readAll(input); + } + return parse(bytes); + } + + static VehicleGlbModel parse(byte[] bytes) throws IOException, JSONException { + if (bytes == null || bytes.length < 20) throw new IOException("GLB is truncated"); + ByteBuffer glb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + if (glb.getInt() != GLB_MAGIC) throw new IOException("Not a glTF binary file"); + if (glb.getInt() != GLB_VERSION) throw new IOException("Unsupported GLB version"); + int declaredLength = glb.getInt(); + if (declaredLength != bytes.length) throw new IOException("GLB length mismatch"); + + byte[] jsonBytes = null; + byte[] binBytes = null; + while (glb.remaining() >= 8) { + int chunkLength = glb.getInt(); + int chunkType = glb.getInt(); + if (chunkLength < 0 || chunkLength > glb.remaining()) { + throw new IOException("GLB chunk exceeds file bounds"); + } + byte[] chunk = new byte[chunkLength]; + glb.get(chunk); + if (chunkType == CHUNK_JSON) jsonBytes = chunk; + else if (chunkType == CHUNK_BIN) binBytes = chunk; + } + if (glb.hasRemaining()) throw new IOException("GLB has a partial chunk header"); + if (jsonBytes == null || binBytes == null) { + throw new IOException("GLB must contain JSON and BIN chunks"); + } + + String jsonText = new String(jsonBytes, StandardCharsets.UTF_8) + .replace("\u0000", "").trim(); + JSONObject json = new JSONObject(jsonText); + JSONObject asset = json.optJSONObject("asset"); + if (asset == null || !asset.optString("version", "").startsWith("2")) { + throw new IOException("GLB asset is not glTF 2.x"); + } + if (json.has("extensionsRequired")) { + JSONArray required = json.getJSONArray("extensionsRequired"); + if (required.length() > 0) { + throw new IOException("Compressed/required glTF extensions are not supported"); + } + } + + BufferView[] views = parseBufferViews(json.optJSONArray("bufferViews"), binBytes.length); + Accessor[] accessors = parseAccessors(json.optJSONArray("accessors"), views); + byte[][] images = parseImages(json.optJSONArray("images"), views, binBytes); + ParsedTextures parsedTextures = parseTextures( + json.optJSONArray("textures"), json.optJSONArray("samplers"), images); + Material[] materials = parseMaterials( + json.optJSONArray("materials"), parsedTextures.byIndex); + MeshData[] meshes = parseMeshes( + json.optJSONArray("meshes"), views, accessors, materials, binBytes); + Node[] nodes = parseNodes(json.optJSONArray("nodes"), meshes.length); + int[] roots = parseSceneRoots(json, nodes); + return new VehicleGlbModel(nodes, meshes, roots, parsedTextures.unique); + } + + Node findNode(String name) { + if (name == null) return null; + return nodesByName.get(name.toLowerCase(Locale.ROOT)); + } + + void resetPose() { + for (Node node : nodes) node.resetLocal(); + } + + /** Rebuilds authored world matrices under the renderer's root vehicle transform. */ + void updateWorld(float[] rootTransform) { + if (rootTransform == null || rootTransform.length < 16) { + throw new IllegalArgumentException("rootTransform must be a 4x4 matrix"); + } + Arrays.fill(visiting, false); + Arrays.fill(visited, false); + for (int root : roots) { + updateWorldRecursive(root, rootTransform, visiting, visited); + } + } + + private void updateWorldRecursive( + int index, float[] parentWorld, boolean[] visiting, boolean[] visited) { + if (index < 0 || index >= nodes.length) { + throw new IllegalStateException("Scene root is outside node table"); + } + if (visiting[index]) throw new IllegalStateException("Cycle in GLB node graph"); + if (visited[index]) return; + visiting[index] = true; + Node node = nodes[index]; + Matrix.multiplyMM(node.world, 0, parentWorld, 0, node.local, 0); + for (int child : node.children) { + updateWorldRecursive(child, node.world, visiting, visited); + } + visiting[index] = false; + visited[index] = true; + } + + void upload() { + for (VehicleGlTexture texture : textures) texture.upload(); + for (MeshData mesh : meshes) { + for (Primitive primitive : mesh.primitives) primitive.mesh.upload(); + } + } + + void release() { + for (VehicleGlTexture texture : textures) texture.release(); + for (MeshData mesh : meshes) { + for (Primitive primitive : mesh.primitives) primitive.mesh.release(); + } + } + + private static BufferView[] parseBufferViews(JSONArray jsonViews, int binLength) + throws IOException, JSONException { + if (jsonViews == null) return new BufferView[0]; + BufferView[] result = new BufferView[jsonViews.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject item = jsonViews.getJSONObject(i); + int buffer = item.optInt("buffer", 0); + int offset = item.optInt("byteOffset", 0); + int length = item.getInt("byteLength"); + int stride = item.optInt("byteStride", 0); + if (buffer != 0 || offset < 0 || length < 0 + || (long) offset + length > binLength) { + throw new IOException("GLB bufferView is outside embedded buffer"); + } + if (stride != 0 && (stride < 4 || stride > 252 || (stride & 3) != 0)) { + throw new IOException("Unsupported GLB byteStride"); + } + result[i] = new BufferView(offset, length, stride); + } + return result; + } + + private static Accessor[] parseAccessors(JSONArray jsonAccessors, BufferView[] views) + throws IOException, JSONException { + if (jsonAccessors == null) return new Accessor[0]; + Accessor[] result = new Accessor[jsonAccessors.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject item = jsonAccessors.getJSONObject(i); + if (item.has("sparse")) throw new IOException("Sparse GLB accessors are unsupported"); + int view = item.optInt("bufferView", -1); + int offset = item.optInt("byteOffset", 0); + int componentType = item.getInt("componentType"); + int count = item.getInt("count"); + int components = componentsForType(item.getString("type")); + boolean normalized = item.optBoolean("normalized", false); + if (view < 0 || view >= views.length || offset < 0 || count <= 0) { + throw new IOException("Invalid GLB accessor"); + } + int componentBytes = bytesForComponent(componentType); + int elementBytes = componentBytes * components; + int stride = views[view].stride == 0 ? elementBytes : views[view].stride; + if (stride < elementBytes) throw new IOException("GLB accessor stride is too small"); + long lastByte = (long) offset + (long) (count - 1) * stride + elementBytes; + if (lastByte > views[view].length) { + throw new IOException("GLB accessor exceeds its bufferView"); + } + result[i] = new Accessor( + view, offset, componentType, count, components, normalized); + } + return result; + } + + private static byte[][] parseImages( + JSONArray jsonImages, BufferView[] views, byte[] bin) + throws IOException, JSONException { + if (jsonImages == null) return new byte[0][]; + byte[][] result = new byte[jsonImages.length()][]; + for (int i = 0; i < result.length; i++) { + JSONObject item = jsonImages.getJSONObject(i); + if (item.has("uri")) { + throw new IOException("Vehicle GLB images must be embedded bufferViews"); + } + int viewIndex = item.optInt("bufferView", -1); + if (viewIndex < 0 || viewIndex >= views.length) { + throw new IOException("Vehicle GLB image references an absent bufferView"); + } + String mimeType = item.optString("mimeType", ""); + if (!"image/png".equals(mimeType) && !"image/jpeg".equals(mimeType)) { + throw new IOException("Unsupported embedded vehicle image type: " + mimeType); + } + BufferView view = views[viewIndex]; + result[i] = Arrays.copyOfRange(bin, view.offset, view.offset + view.length); + } + return result; + } + + private static ParsedTextures parseTextures( + JSONArray jsonTextures, JSONArray jsonSamplers, byte[][] images) + throws IOException, JSONException { + if (jsonTextures == null || jsonTextures.length() == 0) { + return new ParsedTextures(new VehicleGlTexture[0], new VehicleGlTexture[0]); + } + Sampler[] samplers = new Sampler[jsonSamplers == null ? 0 : jsonSamplers.length()]; + for (int i = 0; i < samplers.length; i++) { + JSONObject item = jsonSamplers.getJSONObject(i); + samplers[i] = new Sampler( + item.optInt("magFilter", 9729), + item.optInt("minFilter", 9987), + item.optInt("wrapS", 10497), + item.optInt("wrapT", 10497)); + } + + VehicleGlTexture[] byIndex = new VehicleGlTexture[jsonTextures.length()]; + Map deduplicated = new HashMap<>(); + ArrayList unique = new ArrayList<>(); + for (int i = 0; i < byIndex.length; i++) { + JSONObject item = jsonTextures.getJSONObject(i); + int source = item.optInt("source", -1); + int samplerIndex = item.optInt("sampler", -1); + if (source < 0 || source >= images.length) { + throw new IOException("Vehicle texture references an absent image"); + } + if (samplerIndex >= samplers.length) { + throw new IOException("Vehicle texture references an absent sampler"); + } + Sampler sampler = samplerIndex >= 0 + ? samplers[samplerIndex] : new Sampler(9729, 9987, 10497, 10497); + String key = source + ":" + sampler.magFilter + ":" + sampler.minFilter + + ":" + sampler.wrapS + ":" + sampler.wrapT; + VehicleGlTexture texture = deduplicated.get(key); + if (texture == null) { + texture = new VehicleGlTexture( + images[source], sampler.magFilter, sampler.minFilter, + sampler.wrapS, sampler.wrapT); + deduplicated.put(key, texture); + unique.add(texture); + } + byIndex[i] = texture; + } + return new ParsedTextures( + byIndex, unique.toArray(new VehicleGlTexture[0])); + } + + private static Material[] parseMaterials( + JSONArray jsonMaterials, VehicleGlTexture[] textures) throws IOException, JSONException { + if (jsonMaterials == null || jsonMaterials.length() == 0) { + return new Material[] {new Material("Default")}; + } + Material[] result = new Material[jsonMaterials.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject item = jsonMaterials.getJSONObject(i); + Material material = new Material(item.optString("name", "Material_" + i)); + JSONObject pbr = item.optJSONObject("pbrMetallicRoughness"); + if (pbr != null) { + readFloatArray(pbr.optJSONArray("baseColorFactor"), material.baseColor, 1f); + material.metallic = unit((float) pbr.optDouble("metallicFactor", 1.0)); + material.roughness = unit((float) pbr.optDouble("roughnessFactor", 1.0)); + JSONObject baseColorTexture = pbr.optJSONObject("baseColorTexture"); + if (baseColorTexture != null) { + int textureIndex = baseColorTexture.optInt("index", -1); + if (textureIndex < 0 || textureIndex >= textures.length) { + throw new IOException("Material references an absent base-color texture"); + } + if (baseColorTexture.optInt("texCoord", 0) != 0) { + throw new IOException("Only vehicle TEXCOORD_0 is supported"); + } + material.baseColorTexture = textures[textureIndex]; + } + } + readFloatArray(item.optJSONArray("emissiveFactor"), material.emissive, 0f); + String alphaMode = item.optString("alphaMode", "OPAQUE"); + material.transparent = "BLEND".equals(alphaMode) || material.baseColor[3] < 0.999f; + material.doubleSided = item.optBoolean("doubleSided", false); + result[i] = material; + } + return result; + } + + private static MeshData[] parseMeshes( + JSONArray jsonMeshes, + BufferView[] views, + Accessor[] accessors, + Material[] materials, + byte[] bin) throws IOException, JSONException { + if (jsonMeshes == null || jsonMeshes.length() == 0) { + throw new IOException("Vehicle GLB contains no meshes"); + } + MeshData[] result = new MeshData[jsonMeshes.length()]; + ByteBuffer data = ByteBuffer.wrap(bin).order(ByteOrder.LITTLE_ENDIAN); + for (int meshIndex = 0; meshIndex < result.length; meshIndex++) { + JSONObject meshJson = jsonMeshes.getJSONObject(meshIndex); + JSONArray jsonPrimitives = meshJson.getJSONArray("primitives"); + Primitive[] primitives = new Primitive[jsonPrimitives.length()]; + for (int primitiveIndex = 0; primitiveIndex < primitives.length; primitiveIndex++) { + JSONObject primitiveJson = jsonPrimitives.getJSONObject(primitiveIndex); + if (primitiveJson.optInt("mode", 4) != 4) { + throw new IOException("Only GLB triangle primitives are supported"); + } + JSONObject attributes = primitiveJson.getJSONObject("attributes"); + Accessor positions = accessor(accessors, attributes.getInt("POSITION")); + Accessor normals = attributes.has("NORMAL") + ? accessor(accessors, attributes.getInt("NORMAL")) : null; + Accessor texCoords = attributes.has("TEXCOORD_0") + ? accessor(accessors, attributes.getInt("TEXCOORD_0")) : null; + if (positions.components != 3 || positions.componentType != COMPONENT_FLOAT) { + throw new IOException("GLB POSITION must be float VEC3"); + } + if (positions.count >= 65_535) { + throw new IOException("GLB primitive exceeds GLES2 ushort vertex budget"); + } + if (normals != null && (normals.components != 3 + || normals.count != positions.count)) { + throw new IOException("GLB NORMAL does not match POSITION"); + } + if (texCoords != null && (texCoords.components != 2 + || texCoords.count != positions.count + || (texCoords.componentType != COMPONENT_FLOAT + && !texCoords.normalized))) { + throw new IOException("GLB TEXCOORD_0 does not match POSITION"); + } + + short[] indices; + if (primitiveJson.has("indices")) { + Accessor indexAccessor = accessor( + accessors, primitiveJson.getInt("indices")); + if (indexAccessor.components != 1 || indexAccessor.count % 3 != 0) { + throw new IOException("GLB indices must contain triangle scalars"); + } + indices = readIndices(data, views, indexAccessor, positions.count); + } else { + if (positions.count % 3 != 0) { + throw new IOException("Non-indexed GLB triangle count is incomplete"); + } + indices = new short[positions.count]; + for (int i = 0; i < indices.length; i++) indices[i] = (short) i; + } + + int materialIndex = primitiveJson.optInt("material", 0); + Material material = materialIndex >= 0 && materialIndex < materials.length + ? materials[materialIndex] : materials[0]; + if (material.baseColorTexture != null && texCoords == null) { + throw new IOException("Textured vehicle primitive has no TEXCOORD_0"); + } + + int vertexStride = texCoords == null ? 6 : 8; + float[] interleaved = new float[positions.count * vertexStride]; + readVec3(data, views, positions, interleaved, 0, vertexStride); + if (normals != null) { + readVec3(data, views, normals, interleaved, 3, vertexStride); + } else { + generateNormals(interleaved, indices, vertexStride); + } + if (texCoords != null) { + readVec2(data, views, texCoords, interleaved, 6, vertexStride); + } + primitives[primitiveIndex] = new Primitive( + VehicleGlMesh.fromInterleaved( + interleaved, indices, texCoords != null), material); + } + result[meshIndex] = new MeshData( + meshJson.optString("name", "Mesh_" + meshIndex), primitives); + } + return result; + } + + private static Node[] parseNodes(JSONArray jsonNodes, int meshCount) + throws IOException, JSONException { + if (jsonNodes == null || jsonNodes.length() == 0) { + throw new IOException("Vehicle GLB contains no nodes"); + } + Node[] result = new Node[jsonNodes.length()]; + for (int i = 0; i < result.length; i++) { + JSONObject item = jsonNodes.getJSONObject(i); + int mesh = item.optInt("mesh", -1); + if (mesh >= meshCount) throw new IOException("GLB node references absent mesh"); + int[] children = jsonIntArray(item.optJSONArray("children")); + float[] local = parseNodeTransform(item); + result[i] = new Node(item.optString("name", "Node_" + i), mesh, children, local); + } + return result; + } + + private static int[] parseSceneRoots(JSONObject json, Node[] nodes) + throws IOException, JSONException { + JSONArray scenes = json.optJSONArray("scenes"); + if (scenes == null || scenes.length() == 0) { + int[] implicit = new int[nodes.length]; + for (int i = 0; i < implicit.length; i++) implicit[i] = i; + return implicit; + } + int sceneIndex = json.optInt("scene", 0); + if (sceneIndex < 0 || sceneIndex >= scenes.length()) { + throw new IOException("Default GLB scene is absent"); + } + int[] roots = jsonIntArray(scenes.getJSONObject(sceneIndex).optJSONArray("nodes")); + if (roots.length == 0) throw new IOException("Default GLB scene has no roots"); + for (int root : roots) { + if (root < 0 || root >= nodes.length) throw new IOException("Invalid GLB scene root"); + } + return roots; + } + + private static float[] parseNodeTransform(JSONObject item) throws JSONException { + float[] result = new float[16]; + JSONArray matrix = item.optJSONArray("matrix"); + if (matrix != null) { + if (matrix.length() != 16) throw new JSONException("GLB node matrix must have 16 values"); + for (int i = 0; i < 16; i++) result[i] = (float) matrix.getDouble(i); + return result; + } + + float tx = 0f; + float ty = 0f; + float tz = 0f; + JSONArray translation = item.optJSONArray("translation"); + if (translation != null && translation.length() == 3) { + tx = (float) translation.getDouble(0); + ty = (float) translation.getDouble(1); + tz = (float) translation.getDouble(2); + } + float qx = 0f; + float qy = 0f; + float qz = 0f; + float qw = 1f; + JSONArray rotation = item.optJSONArray("rotation"); + if (rotation != null && rotation.length() == 4) { + qx = (float) rotation.getDouble(0); + qy = (float) rotation.getDouble(1); + qz = (float) rotation.getDouble(2); + qw = (float) rotation.getDouble(3); + float length = (float) Math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw); + if (length > 0.000001f) { + qx /= length; + qy /= length; + qz /= length; + qw /= length; + } else { + qx = qy = qz = 0f; + qw = 1f; + } + } + float sx = 1f; + float sy = 1f; + float sz = 1f; + JSONArray scale = item.optJSONArray("scale"); + if (scale != null && scale.length() == 3) { + sx = (float) scale.getDouble(0); + sy = (float) scale.getDouble(1); + sz = (float) scale.getDouble(2); + } + quaternionTrs(result, tx, ty, tz, qx, qy, qz, qw, sx, sy, sz); + return result; + } + + private static void quaternionTrs(float[] out, + float tx, float ty, float tz, + float x, float y, float z, float w, + float sx, float sy, float sz) { + float xx = x * x; + float yy = y * y; + float zz = z * z; + float xy = x * y; + float xz = x * z; + float yz = y * z; + float wx = w * x; + float wy = w * y; + float wz = w * z; + out[0] = (1f - 2f * (yy + zz)) * sx; + out[1] = (2f * (xy + wz)) * sx; + out[2] = (2f * (xz - wy)) * sx; + out[3] = 0f; + out[4] = (2f * (xy - wz)) * sy; + out[5] = (1f - 2f * (xx + zz)) * sy; + out[6] = (2f * (yz + wx)) * sy; + out[7] = 0f; + out[8] = (2f * (xz + wy)) * sz; + out[9] = (2f * (yz - wx)) * sz; + out[10] = (1f - 2f * (xx + yy)) * sz; + out[11] = 0f; + out[12] = tx; + out[13] = ty; + out[14] = tz; + out[15] = 1f; + } + + private static void readVec3(ByteBuffer data, BufferView[] views, Accessor accessor, + float[] destination, int destinationOffset, int destinationStride) + throws IOException { + BufferView view = views[accessor.bufferView]; + int elementBytes = bytesForComponent(accessor.componentType) * accessor.components; + int stride = view.stride == 0 ? elementBytes : view.stride; + int start = view.offset + accessor.offset; + for (int i = 0; i < accessor.count; i++) { + int source = start + i * stride; + int target = i * destinationStride + destinationOffset; + destination[target] = readComponent(data, source, + accessor.componentType, accessor.normalized); + destination[target + 1] = readComponent(data, + source + bytesForComponent(accessor.componentType), + accessor.componentType, accessor.normalized); + destination[target + 2] = readComponent(data, + source + 2 * bytesForComponent(accessor.componentType), + accessor.componentType, accessor.normalized); + } + } + + private static void readVec2(ByteBuffer data, BufferView[] views, Accessor accessor, + float[] destination, int destinationOffset, int destinationStride) + throws IOException { + BufferView view = views[accessor.bufferView]; + int componentBytes = bytesForComponent(accessor.componentType); + int elementBytes = componentBytes * accessor.components; + int stride = view.stride == 0 ? elementBytes : view.stride; + int start = view.offset + accessor.offset; + for (int i = 0; i < accessor.count; i++) { + int source = start + i * stride; + int target = i * destinationStride + destinationOffset; + destination[target] = readComponent( + data, source, accessor.componentType, accessor.normalized); + destination[target + 1] = readComponent( + data, source + componentBytes, + accessor.componentType, accessor.normalized); + } + } + + private static short[] readIndices(ByteBuffer data, BufferView[] views, + Accessor accessor, int vertexCount) throws IOException { + if (accessor.componentType != COMPONENT_UNSIGNED_BYTE + && accessor.componentType != COMPONENT_UNSIGNED_SHORT + && accessor.componentType != COMPONENT_UNSIGNED_INT) { + throw new IOException("GLB indices must use an unsigned integer component"); + } + BufferView view = views[accessor.bufferView]; + int componentBytes = bytesForComponent(accessor.componentType); + int stride = view.stride == 0 ? componentBytes : view.stride; + int start = view.offset + accessor.offset; + short[] result = new short[accessor.count]; + for (int i = 0; i < result.length; i++) { + int offset = start + i * stride; + long index; + switch (accessor.componentType) { + case COMPONENT_UNSIGNED_BYTE: + index = data.get(offset) & 0xffL; + break; + case COMPONENT_UNSIGNED_SHORT: + index = data.getShort(offset) & 0xffffL; + break; + case COMPONENT_UNSIGNED_INT: + index = Integer.toUnsignedLong(data.getInt(offset)); + break; + default: + throw new IOException("Unsupported GLB index component"); + } + if (index >= vertexCount || index >= 65_535L) { + throw new IOException("GLB index exceeds GLES2 ushort budget"); + } + result[i] = (short) index; + } + return result; + } + + private static float readComponent( + ByteBuffer data, int offset, int componentType, boolean normalized) + throws IOException { + switch (componentType) { + case COMPONENT_FLOAT: + return data.getFloat(offset); + case COMPONENT_BYTE: { + byte value = data.get(offset); + return normalized ? Math.max(-1f, value / 127f) : value; + } + case COMPONENT_UNSIGNED_BYTE: { + int value = data.get(offset) & 0xff; + return normalized ? value / 255f : value; + } + case COMPONENT_SHORT: { + short value = data.getShort(offset); + return normalized ? Math.max(-1f, value / 32767f) : value; + } + case COMPONENT_UNSIGNED_SHORT: { + int value = data.getShort(offset) & 0xffff; + return normalized ? value / 65535f : value; + } + default: + throw new IOException("Unsupported GLB vertex component type"); + } + } + + private static void generateNormals( + float[] vertices, short[] indices, int vertexStride) { + for (int i = 0; i < indices.length; i += 3) { + int ia = (indices[i] & 0xffff) * vertexStride; + int ib = (indices[i + 1] & 0xffff) * vertexStride; + int ic = (indices[i + 2] & 0xffff) * vertexStride; + float abx = vertices[ib] - vertices[ia]; + float aby = vertices[ib + 1] - vertices[ia + 1]; + float abz = vertices[ib + 2] - vertices[ia + 2]; + float acx = vertices[ic] - vertices[ia]; + float acy = vertices[ic + 1] - vertices[ia + 1]; + float acz = vertices[ic + 2] - vertices[ia + 2]; + float nx = aby * acz - abz * acy; + float ny = abz * acx - abx * acz; + float nz = abx * acy - aby * acx; + accumulateNormal(vertices, ia, nx, ny, nz); + accumulateNormal(vertices, ib, nx, ny, nz); + accumulateNormal(vertices, ic, nx, ny, nz); + } + for (int i = 0; i < vertices.length; i += vertexStride) { + float nx = vertices[i + 3]; + float ny = vertices[i + 4]; + float nz = vertices[i + 5]; + float length = (float) Math.sqrt(nx * nx + ny * ny + nz * nz); + if (length > 0.000001f) { + vertices[i + 3] = nx / length; + vertices[i + 4] = ny / length; + vertices[i + 5] = nz / length; + } else { + vertices[i + 3] = 0f; + vertices[i + 4] = 1f; + vertices[i + 5] = 0f; + } + } + } + + private static void accumulateNormal( + float[] vertices, int offset, float nx, float ny, float nz) { + vertices[offset + 3] += nx; + vertices[offset + 4] += ny; + vertices[offset + 5] += nz; + } + + private static Accessor accessor(Accessor[] accessors, int index) throws IOException { + if (index < 0 || index >= accessors.length) throw new IOException("Absent GLB accessor"); + return accessors[index]; + } + + private static int componentsForType(String type) throws IOException { + switch (type) { + case "SCALAR": return 1; + case "VEC2": return 2; + case "VEC3": return 3; + case "VEC4": return 4; + case "MAT2": return 4; + case "MAT3": return 9; + case "MAT4": return 16; + default: throw new IOException("Unknown GLB accessor type: " + type); + } + } + + private static int bytesForComponent(int componentType) throws IOException { + switch (componentType) { + case COMPONENT_BYTE: + case COMPONENT_UNSIGNED_BYTE: + return 1; + case COMPONENT_SHORT: + case COMPONENT_UNSIGNED_SHORT: + return 2; + case COMPONENT_UNSIGNED_INT: + case COMPONENT_FLOAT: + return 4; + default: + throw new IOException("Unknown GLB component type: " + componentType); + } + } + + private static int[] jsonIntArray(JSONArray array) throws JSONException { + if (array == null) return new int[0]; + int[] result = new int[array.length()]; + for (int i = 0; i < result.length; i++) result[i] = array.getInt(i); + return result; + } + + private static void readFloatArray(JSONArray array, float[] destination, float fallback) + throws JSONException { + for (int i = 0; i < destination.length; i++) { + destination[i] = array != null && i < array.length() + ? (float) array.getDouble(i) : fallback; + } + } + + private static byte[] readAll(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(256 * 1024); + byte[] buffer = new byte[16 * 1024]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) output.write(buffer, 0, read); + } + return output.toByteArray(); + } + + private static float unit(float value) { + return Math.max(0f, Math.min(1f, value)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleRenderer3D.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleRenderer3D.java new file mode 100644 index 0000000..b81658c --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleRenderer3D.java @@ -0,0 +1,1200 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.opengl.GLES20; +import android.opengl.GLSurfaceView; +import android.opengl.Matrix; +import android.os.SystemClock; +import android.util.Log; + +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +/** + * GLES 2.0 renderer for the interactive cockpit vehicle. + * + *

The vehicle is deliberately assembled from independent procedural parts instead of using a + * flattened silhouette. All state crossing the UI/GL thread boundary is kept in volatile scalar + * targets; only the GL thread owns animated values and matrices.

+ */ +final class VehicleRenderer3D implements GLSurfaceView.Renderer { + private static final String TAG = "VehicleRenderer3D"; + private static final String AUTHORED_MODEL_ASSET = "models/n7_crossover.glb"; + private static final int DOOR_COUNT = 5; + private static final int WINDOW_COUNT = 4; + private static final int MATRIX_STACK_SIZE = 12; + + private static final float[] BODY = {0.30f, 0.39f, 0.47f, 1f}; + private static final float[] BODY_LIGHT = {0.52f, 0.63f, 0.70f, 1f}; + private static final float[] BODY_DARK = {0.095f, 0.135f, 0.17f, 1f}; + private static final float[] CHROME = {0.72f, 0.82f, 0.86f, 1f}; + private static final float[] TRIM = {0.035f, 0.050f, 0.060f, 1f}; + private static final float[] TYRE = {0.012f, 0.015f, 0.018f, 1f}; + private static final float[] RIM = {0.36f, 0.42f, 0.45f, 1f}; + private static final float[] GLASS = {0.035f, 0.20f, 0.27f, 1f}; + private static final float[] GLASS_EDGE = {0.18f, 0.55f, 0.67f, 1f}; + private static final float[] INTERIOR = {0.035f, 0.044f, 0.052f, 1f}; + private static final float[] SEAT = {0.15f, 0.18f, 0.19f, 1f}; + private static final float[] HEADLIGHT = {0.55f, 0.91f, 1.0f, 1f}; + private static final float[] TAILLIGHT = {1.0f, 0.055f, 0.025f, 1f}; + private static final float[] SHADE = {0.16f, 0.18f, 0.19f, 1f}; + private static final float[] COLD_AIR = {0.12f, 0.76f, 1.0f, 1f}; + private static final float[] WARM_AIR = {1.0f, 0.39f, 0.10f, 1f}; + + private static final String VERTEX_SHADER = + "uniform mat4 uMvp;\n" + + "uniform mat4 uModel;\n" + + "uniform mat4 uNormalMatrix;\n" + + "attribute vec3 aPosition;\n" + + "attribute vec3 aNormal;\n" + + "attribute vec2 aTexCoord;\n" + + "varying vec3 vNormal;\n" + + "varying vec3 vWorld;\n" + + "varying vec2 vTexCoord;\n" + + "void main() {\n" + + " vec4 world = uModel * vec4(aPosition, 1.0);\n" + + " vWorld = world.xyz;\n" + + " vNormal = normalize(mat3(uNormalMatrix) * aNormal);\n" + + " vTexCoord = aTexCoord;\n" + + " gl_Position = uMvp * vec4(aPosition, 1.0);\n" + + "}\n"; + + private static final String FRAGMENT_SHADER = + "precision mediump float;\n" + + "uniform vec4 uColor;\n" + + "uniform sampler2D uBaseColorTexture;\n" + + "uniform float uHasBaseColorTexture;\n" + + "uniform float uEmission;\n" + + "uniform float uAlpha;\n" + + "uniform float uMetallic;\n" + + "uniform float uRoughness;\n" + + "uniform float uClearcoat;\n" + + "uniform float uTransmission;\n" + + "uniform float uEffectMode;\n" + + "uniform vec3 uCamera;\n" + + "varying vec3 vNormal;\n" + + "varying vec3 vWorld;\n" + + "varying vec2 vTexCoord;\n" + + "const float PI = 3.14159265;\n" + + "float sat(float value) { return clamp(value, 0.0, 1.0); }\n" + + "vec3 fresnelSchlick(float cosine, vec3 f0) {\n" + + " return f0 + (vec3(1.0) - f0) * pow(1.0 - sat(cosine), 5.0);\n" + + "}\n" + + "float distributionGgx(float noh, float roughness) {\n" + + " float a = roughness * roughness;\n" + + " float a2 = a * a;\n" + + " float d = noh * noh * (a2 - 1.0) + 1.0;\n" + + " return a2 / max(PI * d * d, 0.0001);\n" + + "}\n" + + "float geometrySchlick(float nov, float roughness) {\n" + + " float r = roughness + 1.0;\n" + + " float k = r * r * 0.125;\n" + + " return nov / max(nov * (1.0 - k) + k, 0.0001);\n" + + "}\n" + + "vec3 directLight(vec3 n, vec3 v, vec3 l, vec3 radiance,\n" + + " vec3 albedo, vec3 f0, float metallic, float roughness) {\n" + + " vec3 h = normalize(v + l);\n" + + " float nol = sat(dot(n, l));\n" + + " float nov = sat(dot(n, v));\n" + + " float noh = sat(dot(n, h));\n" + + " float voh = sat(dot(v, h));\n" + + " vec3 f = fresnelSchlick(voh, f0);\n" + + " float d = distributionGgx(noh, roughness);\n" + + " float g = geometrySchlick(nov, roughness) * geometrySchlick(nol, roughness);\n" + + " vec3 specular = d * g * f / max(4.0 * nov * nol, 0.001);\n" + + " vec3 kd = (vec3(1.0) - f) * (1.0 - metallic);\n" + + " return (kd * albedo / PI + specular) * radiance * nol;\n" + + "}\n" + + "vec3 studioEnvironment(vec3 direction, float roughness) {\n" + + " float sky = smoothstep(-0.25, 0.92, direction.y);\n" + + " vec3 environment = mix(vec3(0.006, 0.011, 0.018),\n" + + " vec3(0.18, 0.29, 0.40), sky);\n" + + " float horizon = exp2(-abs(direction.y - 0.04) * mix(18.0, 5.0, roughness));\n" + + " environment += vec3(0.16, 0.25, 0.34) * horizon;\n" + + " float key = pow(sat(dot(direction, normalize(vec3(-0.45, 0.78, -0.44)))),\n" + + " mix(96.0, 5.0, roughness));\n" + + " float fill = pow(sat(dot(direction, normalize(vec3(0.72, 0.40, 0.56)))),\n" + + " mix(56.0, 4.0, roughness));\n" + + " float rim = pow(sat(dot(direction, normalize(vec3(0.12, 0.52, -0.85)))),\n" + + " mix(72.0, 5.0, roughness));\n" + + " environment += vec3(2.35, 2.60, 2.85) * key;\n" + + " environment += vec3(0.48, 0.78, 1.18) * fill;\n" + + " environment += vec3(0.82, 1.02, 1.28) * rim;\n" + + " return environment;\n" + + "}\n" + + "vec3 toneMap(vec3 color) {\n" + + " color = max(color, vec3(0.0));\n" + + " color = (color * (2.51 * color + 0.03))\n" + + " / (color * (2.43 * color + 0.59) + 0.14);\n" + + " float luma = dot(color, vec3(0.2126, 0.7152, 0.0722));\n" + + " color = mix(vec3(luma), color, 1.08);\n" + + " return pow(clamp(color, 0.0, 1.0), vec3(0.4545));\n" + + "}\n" + + "void main() {\n" + + " vec3 surfaceColor = uColor.rgb;\n" + + " float surfaceAlpha = uColor.a;\n" + + " if (uHasBaseColorTexture > 0.5) {\n" + + " vec4 textureColor = texture2D(uBaseColorTexture, vTexCoord);\n" + + " surfaceColor *= pow(max(textureColor.rgb, vec3(0.0)), vec3(2.2));\n" + + " surfaceAlpha *= textureColor.a;\n" + + " }\n" + + " vec3 n = normalize(vNormal);\n" + + " float alpha = clamp(surfaceAlpha * uAlpha, 0.0, 1.0);\n" + + " if (uEffectMode > 0.5) {\n" + + " if (uEffectMode < 1.5) {\n" + + " alpha *= pow(sat(n.y), 1.65);\n" + + " gl_FragColor = vec4(surfaceColor * alpha, alpha);\n" + + " } else {\n" + + " vec3 effect = toneMap(surfaceColor * (0.42 + 1.35 * uEmission));\n" + + " gl_FragColor = vec4(effect * alpha, alpha);\n" + + " }\n" + + " return;\n" + + " }\n" + + " vec3 v = normalize(uCamera - vWorld);\n" + + " float metallic = sat(uMetallic);\n" + + " float roughness = clamp(uRoughness, 0.055, 1.0);\n" + + " vec3 f0 = mix(vec3(0.045), surfaceColor, metallic);\n" + + " f0 = mix(f0, vec3(0.075), sat(uTransmission));\n" + + " vec3 keyDirection = normalize(vec3(-0.42, 0.82, -0.38));\n" + + " vec3 fillDirection = normalize(vec3(0.62, 0.30, 0.58));\n" + + " vec3 rimDirection = normalize(vec3(-0.20, 0.38, 0.90));\n" + + " vec3 rgb = directLight(n, v, keyDirection, vec3(3.10, 3.35, 3.65),\n" + + " surfaceColor, f0, metallic, roughness);\n" + + " rgb += directLight(n, v, fillDirection, vec3(0.62, 0.94, 1.34),\n" + + " surfaceColor, f0, metallic, roughness);\n" + + " rgb += directLight(n, v, rimDirection, vec3(0.38, 0.57, 0.88),\n" + + " surfaceColor, f0, metallic, roughness);\n" + + " float nov = sat(dot(n, v));\n" + + " vec3 reflection = reflect(-v, n);\n" + + " vec3 environment = studioEnvironment(reflection, roughness);\n" + + " vec3 environmentFresnel = f0 +\n" + + " (max(vec3(1.0 - roughness), f0) - f0) * pow(1.0 - nov, 5.0);\n" + + " vec3 diffuseAmbient = surfaceColor * (1.0 - metallic)\n" + + " * mix(vec3(0.050, 0.063, 0.078), vec3(0.11, 0.15, 0.19), sat(n.y));\n" + + " rgb += diffuseAmbient * mix(1.0, 0.20, sat(uTransmission));\n" + + " rgb += environment * environmentFresnel\n" + + " * mix(0.88, 1.28, sat(uTransmission)) * (1.0 - 0.36 * roughness);\n" + + " if (uClearcoat > 0.001) {\n" + + " float coatFresnel = 0.04 + 0.96 * pow(1.0 - nov, 5.0);\n" + + " vec3 coatEnvironment = studioEnvironment(reflection, 0.08 + 0.16 * roughness);\n" + + " vec3 coatHalf = normalize(keyDirection + v);\n" + + " float coatHighlight = pow(sat(dot(n, coatHalf)),\n" + + " mix(176.0, 42.0, roughness)) * sat(dot(n, keyDirection));\n" + + " rgb *= 1.0 - 0.10 * sat(uClearcoat);\n" + + " rgb += sat(uClearcoat) * (coatEnvironment * coatFresnel * 0.72\n" + + " + vec3(1.28, 1.40, 1.55) * coatHighlight);\n" + + " }\n" + + " rgb += surfaceColor * uEmission * 1.25;\n" + + " rgb = toneMap(rgb * 1.08);\n" + + " gl_FragColor = vec4(rgb * alpha, alpha);\n" + + "}\n"; + + private final Context applicationContext; + + private volatile int targetDoorMask; + private volatile int targetWindow0; + private volatile int targetWindow1; + private volatile int targetWindow2; + private volatile int targetWindow3; + private volatile boolean targetHeadlights; + private volatile boolean targetBrakeLights; + private volatile boolean targetAmbientLights; + private volatile boolean targetClimateOn; + private volatile float targetClimateTemperature = 22f; + private volatile int targetClimateFan; + private volatile int targetSunshade; + private volatile float targetYaw = -8f; + private volatile boolean animationEnabled = true; + + private final float[] doorAmount = new float[DOOR_COUNT]; + private final float[] windowAmount = new float[WINDOW_COUNT]; + private final float[] projection = new float[16]; + private final float[] view = new float[16]; + private final float[] viewProjection = new float[16]; + private final float[] model = new float[16]; + private final float[] modelViewProjection = new float[16]; + private final float[] inverseModel = new float[16]; + private final float[] normalMatrix = new float[16]; + private final float[][] matrixStack = new float[MATRIX_STACK_SIZE][16]; + private final float[] climateColor = new float[4]; + private final float[] assetRoot = new float[16]; + private final float[] cameraPosition = {5.8f, 4.2f, -8.0f}; + + private int stackDepth; + private float animatedSunshade; + private float animatedYaw = -8f; + private float animatedClimateStrength; + private long previousFrameNanos; + private long sceneStartMs; + + private int program; + private int aPosition; + private int aNormal; + private int aTexCoord; + private int uMvp; + private int uModel; + private int uNormalMatrix; + private int uColor; + private int uBaseColorTexture; + private int uHasBaseColorTexture; + private int uEmission; + private int uAlpha; + private int uMetallic; + private int uRoughness; + private int uClearcoat; + private int uTransmission; + private int uEffectMode; + private int uCamera; + + private VehicleGlMesh unitBox; + private VehicleGlMesh beveledBox; + private VehicleGlMesh wedge; + private VehicleGlMesh crossoverBody; + private VehicleGlMesh sphere; + private VehicleGlMesh cylinder; + private VehicleGlMesh torus; + private VehicleGlMesh quad; + private VehicleGlbModel authoredModel; + private VehicleGlbModel.Node authoredDoorFl; + private VehicleGlbModel.Node authoredDoorFr; + private VehicleGlbModel.Node authoredDoorRl; + private VehicleGlbModel.Node authoredDoorRr; + private VehicleGlbModel.Node authoredTailgate; + private VehicleGlbModel.Node authoredWindowFl; + private VehicleGlbModel.Node authoredWindowFr; + private VehicleGlbModel.Node authoredWindowRl; + private VehicleGlbModel.Node authoredWindowRr; + private VehicleGlbModel.Node authoredSunshade; + + VehicleRenderer3D(Context context) { + if (context == null) { + throw new IllegalArgumentException("context == null"); + } + applicationContext = context.getApplicationContext(); + Matrix.setIdentityM(model, 0); + climateColor[3] = 1f; + } + + synchronized void setDoorTarget(int index, boolean open) { + if (index < 0 || index >= DOOR_COUNT) return; + int bit = 1 << index; + if (open) targetDoorMask |= bit; + else targetDoorMask &= ~bit; + } + + void setDoorOpen(int index, boolean open) { + setDoorTarget(index, open); + } + + void setDoorTarget(VehicleSilhouetteView.Door door, boolean open) { + if (door != null) setDoorTarget(door.ordinal(), open); + } + + void setDoorOpen(VehicleSilhouetteView.Door door, boolean open) { + setDoorTarget(door, open); + } + + void setWindowTarget(int index, int percent) { + int safe = clamp(percent, 0, 100); + switch (index) { + case 0: targetWindow0 = safe; break; + case 1: targetWindow1 = safe; break; + case 2: targetWindow2 = safe; break; + case 3: targetWindow3 = safe; break; + default: break; + } + } + + void setWindowPercent(int index, int percent) { + setWindowTarget(index, percent); + } + + void setWindowTarget(VehicleSilhouetteView.WindowPosition position, int percent) { + if (position != null) setWindowTarget(position.ordinal(), percent); + } + + void setWindowPercent(VehicleSilhouetteView.WindowPosition position, int percent) { + setWindowTarget(position, percent); + } + + void setLights(boolean headlights, boolean brakeLights, boolean ambientLights) { + targetHeadlights = headlights; + targetBrakeLights = brakeLights; + targetAmbientLights = ambientLights; + } + + void setClimate(boolean on, float temperature, int fanLevel) { + targetClimateOn = on; + targetClimateTemperature = finite(temperature) ? clamp(temperature, 16f, 32f) : 22f; + targetClimateFan = clamp(fanLevel, 0, 7); + } + + void setClimateState(boolean on, float temperature, int fanLevel) { + setClimate(on, temperature, fanLevel); + } + + void setSunshade(int percent) { + targetSunshade = clamp(percent, 0, 100); + } + + void setSunshadePercent(int percent) { + setSunshade(percent); + } + + synchronized void setUserRotation(float degrees) { + targetYaw = finite(degrees) ? normalizeDegrees(degrees) : 0f; + } + + synchronized void addUserRotation(float deltaDegrees) { + if (finite(deltaDegrees)) setUserRotation(targetYaw + deltaDegrees); + } + + void setAnimationEnabled(boolean enabled) { + animationEnabled = enabled; + } + + @Override + public void onSurfaceCreated(GL10 ignored, EGLConfig config) { + markContextLost(); + program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER); + if (program == 0) return; + + aPosition = GLES20.glGetAttribLocation(program, "aPosition"); + aNormal = GLES20.glGetAttribLocation(program, "aNormal"); + aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord"); + uMvp = GLES20.glGetUniformLocation(program, "uMvp"); + uModel = GLES20.glGetUniformLocation(program, "uModel"); + uNormalMatrix = GLES20.glGetUniformLocation(program, "uNormalMatrix"); + uColor = GLES20.glGetUniformLocation(program, "uColor"); + uBaseColorTexture = GLES20.glGetUniformLocation(program, "uBaseColorTexture"); + uHasBaseColorTexture = GLES20.glGetUniformLocation(program, "uHasBaseColorTexture"); + uEmission = GLES20.glGetUniformLocation(program, "uEmission"); + uAlpha = GLES20.glGetUniformLocation(program, "uAlpha"); + uMetallic = GLES20.glGetUniformLocation(program, "uMetallic"); + uRoughness = GLES20.glGetUniformLocation(program, "uRoughness"); + uClearcoat = GLES20.glGetUniformLocation(program, "uClearcoat"); + uTransmission = GLES20.glGetUniformLocation(program, "uTransmission"); + uEffectMode = GLES20.glGetUniformLocation(program, "uEffectMode"); + uCamera = GLES20.glGetUniformLocation(program, "uCamera"); + GLES20.glUseProgram(program); + GLES20.glUniform1i(uBaseColorTexture, 0); + GLES20.glUniform1f(uHasBaseColorTexture, 0f); + + unitBox = VehicleGlMesh.unitBox(); + beveledBox = VehicleGlMesh.beveledBox(); + wedge = VehicleGlMesh.wedge(); + crossoverBody = VehicleGlMesh.crossoverBody(); + sphere = VehicleGlMesh.uvSphere(12, 18); + cylinder = VehicleGlMesh.cylinder(24); + torus = VehicleGlMesh.torus(24, 8); + quad = VehicleGlMesh.quad(); + upload(unitBox); + upload(beveledBox); + upload(wedge); + upload(crossoverBody); + upload(sphere); + upload(cylinder); + upload(torus); + upload(quad); + + try { + authoredModel = VehicleGlbModel.load(applicationContext, AUTHORED_MODEL_ASSET); + authoredModel.upload(); + cacheAuthoredAnimationNodes(); + Log.i(TAG, "Loaded Blender vehicle: " + AUTHORED_MODEL_ASSET); + } catch (Exception error) { + if (authoredModel != null) authoredModel.release(); + authoredModel = null; + clearAuthoredAnimationNodes(); + Log.w(TAG, "Blender vehicle unavailable; using procedural fallback", error); + } + + // The RGBA Surface is genuinely transparent so the card gradient remains continuous. + GLES20.glClearColor(0f, 0f, 0f, 0f); + GLES20.glEnable(GLES20.GL_DEPTH_TEST); + GLES20.glDepthFunc(GLES20.GL_LEQUAL); + GLES20.glEnable(GLES20.GL_BLEND); + // The shader writes premultiplied RGB, which avoids dark fringes on the RGBA surface. + GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA); + GLES20.glEnable(GLES20.GL_CULL_FACE); + GLES20.glCullFace(GLES20.GL_BACK); + previousFrameNanos = 0L; + sceneStartMs = SystemClock.uptimeMillis(); + } + + @Override + public void onSurfaceChanged(GL10 ignored, int width, int height) { + int safeWidth = Math.max(1, width); + int safeHeight = Math.max(1, height); + GLES20.glViewport(0, 0, safeWidth, safeHeight); + float aspect = safeWidth / (float) safeHeight; + Matrix.perspectiveM(projection, 0, 30f, aspect, 1.0f, 30f); + cameraPosition[0] = 5.35f; + cameraPosition[1] = 3.55f; + cameraPosition[2] = -7.35f; + Matrix.setLookAtM(view, 0, + cameraPosition[0], cameraPosition[1], cameraPosition[2], + 0f, 1.05f, 0.05f, + 0f, 1f, 0f); + Matrix.multiplyMM(viewProjection, 0, projection, 0, view, 0); + } + + @Override + public void onDrawFrame(GL10 ignored) { + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT); + if (program == 0 || unitBox == null) return; + + long nowNanos = System.nanoTime(); + float dt = previousFrameNanos == 0L + ? 1f / 60f + : Math.min(0.05f, (nowNanos - previousFrameNanos) * 0.000000001f); + previousFrameNanos = nowNanos; + updateAnimations(dt); + + GLES20.glUseProgram(program); + GLES20.glUniform3fv(uCamera, 1, cameraPosition, 0); + Matrix.setIdentityM(model, 0); + Matrix.rotateM(model, 0, animatedYaw, 0f, 1f, 0f); + stackDepth = 0; + double seconds = (SystemClock.uptimeMillis() - sceneStartMs) * 0.001d; + drawScene(seconds); + } + + private void updateAnimations(float dt) { + int mask = targetDoorMask; + for (int i = 0; i < DOOR_COUNT; i++) { + float target = (mask & (1 << i)) != 0 ? 1f : 0f; + doorAmount[i] = animate(doorAmount[i], target, 5.2f, dt); + } + windowAmount[0] = animate(windowAmount[0], targetWindow0 * 0.01f, 5.6f, dt); + windowAmount[1] = animate(windowAmount[1], targetWindow1 * 0.01f, 5.6f, dt); + windowAmount[2] = animate(windowAmount[2], targetWindow2 * 0.01f, 5.6f, dt); + windowAmount[3] = animate(windowAmount[3], targetWindow3 * 0.01f, 5.6f, dt); + animatedSunshade = animate(animatedSunshade, targetSunshade * 0.01f, 3.8f, dt); + animatedYaw = animateAngle(animatedYaw, targetYaw, 7.0f, dt); + float climateTarget = targetClimateOn && targetClimateFan > 0 ? 1f : 0f; + animatedClimateStrength = animate(animatedClimateStrength, climateTarget, 4.4f, dt); + } + + private float animate(float current, float target, float speed, float dt) { + if (!animationEnabled) return target; + return current + (target - current) * Math.min(1f, speed * dt); + } + + private float animateAngle(float current, float target, float speed, float dt) { + if (!animationEnabled) return normalizeDegrees(target); + float shortestDelta = normalizeDegrees(target - current); + return normalizeDegrees(current + shortestDelta * Math.min(1f, speed * dt)); + } + + private void drawScene(double seconds) { + if (authoredModel != null) { + drawAuthoredVehicle(seconds); + return; + } + + drawWheels(); + drawUnderbodyAndBody(); + drawCabinInterior(); + drawDoors(); + drawTailgate(); + drawPillarsAndRoof(); + + GLES20.glDepthMask(false); + GLES20.glDisable(GLES20.GL_CULL_FACE); + drawGlassAndSunshade(); + drawLampsAndBeams(seconds); + drawClimate(seconds); + GLES20.glEnable(GLES20.GL_CULL_FACE); + GLES20.glDepthMask(true); + } + + private void drawAuthoredVehicle(double seconds) { + authoredModel.resetPose(); + rotateAuthoredNode(authoredDoorFl, -67f * doorAmount[0], 0f, 1f, 0f); + rotateAuthoredNode(authoredDoorFr, 67f * doorAmount[1], 0f, 1f, 0f); + rotateAuthoredNode(authoredDoorRl, -67f * doorAmount[2], 0f, 1f, 0f); + rotateAuthoredNode(authoredDoorRr, 67f * doorAmount[3], 0f, 1f, 0f); + // Blender's local +Y tailgate hinge is exported as the GLB local Z axis. + rotateAuthoredNode(authoredTailgate, 72f * doorAmount[4], 0f, 0f, 1f); + lowerAuthoredWindow(authoredWindowFl, windowAmount[0], 0.48f); + lowerAuthoredWindow(authoredWindowFr, windowAmount[1], 0.48f); + lowerAuthoredWindow(authoredWindowRl, windowAmount[2], 0.43f); + lowerAuthoredWindow(authoredWindowRr, windowAmount[3], 0.43f); + if (authoredSunshade != null) { + Matrix.translateM(authoredSunshade.local, 0, + animatedSunshade * 1.05f, 0f, 0f); + } + + // glTF is Y-up with the authored nose along -X. Rotate it to the renderer's -Z forward + // convention and enlarge uniformly; every animated node keeps its Blender pivot. + System.arraycopy(model, 0, assetRoot, 0, 16); + Matrix.scaleM(assetRoot, 0, 1.60f, 1.60f, 1.60f); + Matrix.rotateM(assetRoot, 0, -90f, 0f, 1f, 0f); + authoredModel.updateWorld(assetRoot); + + GLES20.glDisable(GLES20.GL_CULL_FACE); + GLES20.glDepthMask(true); + for (VehicleGlbModel.DrawRef ref : authoredModel.opaqueDraws) { + drawAuthoredPrimitive(ref); + } + + GLES20.glDepthMask(false); + sortTransparentDraws(); + for (VehicleGlbModel.DrawRef ref : authoredModel.transparentDraws) { + drawAuthoredPrimitive(ref); + } + GLES20.glDepthMask(true); + GLES20.glEnable(GLES20.GL_CULL_FACE); + + // Programmatic particles and light volumes remain true live geometry and use the same + // authored coordinate frame, so they align with the modeled vents and lamp housings. + System.arraycopy(assetRoot, 0, model, 0, 16); + GLES20.glDepthMask(false); + GLES20.glDisable(GLES20.GL_CULL_FACE); + drawAuthoredLampEffects(seconds); + drawAuthoredClimate(seconds); + GLES20.glDepthMask(true); + GLES20.glEnable(GLES20.GL_CULL_FACE); + } + + private void drawAuthoredPrimitive(VehicleGlbModel.DrawRef ref) { + VehicleGlbModel.Material material = ref.primitive.material; + System.arraycopy(ref.node.world, 0, model, 0, 16); + Matrix.multiplyMM(modelViewProjection, 0, viewProjection, 0, model, 0); + // The checked Blender pipeline applies object scales and the renderer adds only a uniform + // root scale, so the normalized model 3x3 is exactly the required normal transform. + System.arraycopy(model, 0, normalMatrix, 0, 16); + + float emission = Math.max(material.emissive[0], + Math.max(material.emissive[1], material.emissive[2])); + String nodeName = ref.node.name; + if (containsIgnoreCase(nodeName, "headlight") || containsIgnoreCase(nodeName, "drl")) { + emission = targetHeadlights ? Math.max(1.55f, emission * 1.9f) : emission * 0.10f; + } else if (containsIgnoreCase(nodeName, "taillight") + || containsIgnoreCase(nodeName, "lightbar")) { + emission = targetBrakeLights ? Math.max(1.85f, emission * 2.1f) : emission * 0.22f; + } else if (targetAmbientLights && (containsIgnoreCase(nodeName, "beltline") + || containsIgnoreCase(nodeName, "sillchrome"))) { + emission = Math.max(emission, 0.32f); + } + + GLES20.glUniformMatrix4fv(uModel, 1, false, model, 0); + GLES20.glUniformMatrix4fv(uNormalMatrix, 1, false, normalMatrix, 0); + GLES20.glUniformMatrix4fv(uMvp, 1, false, modelViewProjection, 0); + GLES20.glUniform4fv(uColor, 1, material.baseColor, 0); + GLES20.glUniform1f(uEmission, emission); + GLES20.glUniform1f(uAlpha, 1f); + GLES20.glUniform1f(uMetallic, material.metallic); + GLES20.glUniform1f(uRoughness, material.roughness); + GLES20.glUniform1f(uClearcoat, materialClearcoat(material)); + GLES20.glUniform1f(uTransmission, material.transparent ? 1f : 0f); + GLES20.glUniform1f(uEffectMode, 0f); + VehicleGlTexture texture = material.baseColorTexture; + if (texture != null && texture.id() != 0) { + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture.id()); + GLES20.glUniform1f(uHasBaseColorTexture, 1f); + } else { + GLES20.glUniform1f(uHasBaseColorTexture, 0f); + } + ref.primitive.mesh.draw(aPosition, aNormal, aTexCoord); + } + + private void sortTransparentDraws() { + VehicleGlbModel.DrawRef[] draws = authoredModel.transparentDraws; + for (VehicleGlbModel.DrawRef draw : draws) { + float dx = draw.node.world[12] - cameraPosition[0]; + float dy = draw.node.world[13] - cameraPosition[1]; + float dz = draw.node.world[14] - cameraPosition[2]; + draw.cameraDistanceSquared = dx * dx + dy * dy + dz * dz; + } + // Transparent count is small; allocation-free insertion sort is faster than creating a + // Comparator/collection and gives the required far-to-near source-over order. + for (int i = 1; i < draws.length; i++) { + VehicleGlbModel.DrawRef value = draws[i]; + int destination = i; + while (destination > 0 + && draws[destination - 1].cameraDistanceSquared + < value.cameraDistanceSquared) { + draws[destination] = draws[destination - 1]; + destination--; + } + draws[destination] = value; + } + } + + private void drawAuthoredLampEffects(double seconds) { + float pulse = 0.90f + 0.10f * sin(seconds * 4.2f); + if (targetHeadlights) { + for (int band = 0; band < 3; band++) { + float x = -2.72f - band * 0.82f; + float length = 1.18f + band * 0.30f; + float width = 0.30f + band * 0.12f; + float alpha = (0.13f - band * 0.025f) * pulse; + drawPart(beveledBox, x, 0.84f, -0.64f, + length, 0.028f, width, HEADLIGHT, 1.2f, alpha); + drawPart(beveledBox, x, 0.84f, 0.64f, + length, 0.028f, width, HEADLIGHT, 1.2f, alpha); + } + } + if (targetBrakeLights) { + drawPart(sphere, 2.30f, 1.05f, -0.70f, + 0.24f, 0.16f, 0.52f, TAILLIGHT, 1.35f, 0.18f); + drawPart(sphere, 2.30f, 1.05f, 0.70f, + 0.24f, 0.16f, 0.52f, TAILLIGHT, 1.35f, 0.18f); + } + } + + private void drawAuthoredClimate(double seconds) { + if (animatedClimateStrength < 0.012f) return; + float heat = clamp((targetClimateTemperature - 16f) / 16f, 0f, 1f); + climateColor[0] = mix(COLD_AIR[0], WARM_AIR[0], heat); + climateColor[1] = mix(COLD_AIR[1], WARM_AIR[1], heat); + climateColor[2] = mix(COLD_AIR[2], WARM_AIR[2], heat); + climateColor[3] = 1f; + + int count = 4 + targetClimateFan; + float speed = 0.22f + targetClimateFan * 0.075f; + for (int stream = -1; stream <= 1; stream++) { + for (int particle = 0; particle < count; particle++) { + float phase = fract(seconds * speed + + particle / (float) count + (stream + 1) * 0.117f); + float x = -0.85f + phase * 2.20f; + float y = 1.26f + 0.16f * sin(phase * 3.1416f); + float z = stream * 0.45f + + 0.045f * sin(phase * 12.566f + stream); + float radius = 0.030f + phase * 0.036f; + float alpha = animatedClimateStrength * (1f - phase) * 0.72f; + drawPart(sphere, x, y, z, radius, radius, radius, + climateColor, 1.55f, alpha); + } + } + } + + private static void rotateAuthoredNode( + VehicleGlbModel.Node node, float angle, float x, float y, float z) { + if (node != null && Math.abs(angle) > 0.001f) { + Matrix.rotateM(node.local, 0, angle, x, y, z); + } + } + + private static void lowerAuthoredWindow( + VehicleGlbModel.Node node, float amount, float travel) { + if (node != null && amount > 0.001f) { + Matrix.translateM(node.local, 0, 0f, -amount * travel, 0f); + } + } + + private static boolean containsIgnoreCase(String value, String needle) { + return value != null && value.toLowerCase(java.util.Locale.ROOT).contains(needle); + } + + private void cacheAuthoredAnimationNodes() { + if (authoredModel == null) return; + authoredDoorFl = authoredModel.findNode("Door_FL"); + authoredDoorFr = authoredModel.findNode("Door_FR"); + authoredDoorRl = authoredModel.findNode("Door_RL"); + authoredDoorRr = authoredModel.findNode("Door_RR"); + authoredTailgate = authoredModel.findNode("Tailgate"); + authoredWindowFl = authoredModel.findNode("Window_FL"); + authoredWindowFr = authoredModel.findNode("Window_FR"); + authoredWindowRl = authoredModel.findNode("Window_RL"); + authoredWindowRr = authoredModel.findNode("Window_RR"); + authoredSunshade = authoredModel.findNode("Sunshade"); + if (authoredDoorFl == null || authoredDoorFr == null + || authoredDoorRl == null || authoredDoorRr == null + || authoredTailgate == null || authoredWindowFl == null + || authoredWindowFr == null || authoredWindowRl == null + || authoredWindowRr == null || authoredSunshade == null) { + throw new IllegalStateException("Blender vehicle is missing required animation nodes"); + } + } + + private void clearAuthoredAnimationNodes() { + authoredDoorFl = null; + authoredDoorFr = null; + authoredDoorRl = null; + authoredDoorRr = null; + authoredTailgate = null; + authoredWindowFl = null; + authoredWindowFr = null; + authoredWindowRl = null; + authoredWindowRr = null; + authoredSunshade = null; + } + + private void drawWheels() { + drawWheel(-1.86f, 0.62f, -2.18f, -1f); + drawWheel(1.86f, 0.62f, -2.18f, 1f); + drawWheel(-1.86f, 0.62f, 2.20f, -1f); + drawWheel(1.86f, 0.62f, 2.20f, 1f); + } + + private void drawWheel(float x, float y, float z, float side) { + push(); + Matrix.translateM(model, 0, x, y, z); + Matrix.rotateM(model, 0, 90f, 0f, 0f, 1f); + scaleAndDraw(cylinder, 0.66f, 0.40f, 0.66f, TYRE, 0f, 1f); + pop(); + + float outerX = x + side * 0.225f; + push(); + Matrix.translateM(model, 0, outerX, y, z); + Matrix.rotateM(model, 0, 90f, 0f, 0f, 1f); + scaleAndDraw(cylinder, 0.39f, 0.055f, 0.39f, RIM, 0.10f, 1f); + scaleAndDraw(torus, 0.48f, 0.07f, 0.48f, CHROME, 0.13f, 1f); + pop(); + + for (int spoke = 0; spoke < 6; spoke++) { + push(); + Matrix.translateM(model, 0, outerX + side * 0.035f, y, z); + Matrix.rotateM(model, 0, spoke * 60f, 1f, 0f, 0f); + scaleAndDraw(beveledBox, 0.052f, 0.085f, 0.57f, CHROME, 0.09f, 1f); + pop(); + } + drawPart(cylinder, outerX + side * 0.045f, y, z, + 0.12f, 0.07f, 0.12f, BODY_DARK, 0f, 1f, 90f, 0f, 0f, 1f); + } + + private void drawUnderbodyAndBody() { + drawPart(beveledBox, 0f, 0.39f, 0.12f, 3.02f, 0.38f, 5.88f, + BODY_DARK, 0f, 1f); + // A continuous twelve-section loft supplies the shoulder, rocker and tapered ends. The + // previous full-length cuboid was the main source of the pickup/box silhouette. + drawPart(crossoverBody, 0f, 0.86f, 0.12f, 3.55f, 1.10f, 6.18f, + BODY, 0f, 1f); + drawPart(wedge, 0f, 1.28f, -2.08f, 2.78f, 0.54f, 1.88f, + BODY_LIGHT, 0f, 1f); + drawPart(beveledBox, 0f, 0.65f, -3.05f, 2.42f, 0.42f, 0.30f, + BODY_DARK, 0f, 1f); + drawPart(beveledBox, 0f, 0.69f, 3.23f, 2.72f, 0.46f, 0.32f, + BODY_DARK, 0f, 1f); + + // Slim character lines keep separate door/fender surfaces readable without boxing the hull. + drawPart(beveledBox, -1.65f, 1.10f, 0.10f, 0.10f, 0.15f, 4.62f, + BODY_LIGHT, 0f, 1f); + drawPart(beveledBox, 1.65f, 1.10f, 0.10f, 0.10f, 0.15f, 4.62f, + BODY_LIGHT, 0f, 1f); + drawArchTrim(-1.72f, -2.18f); + drawArchTrim(1.72f, -2.18f); + drawArchTrim(-1.72f, 2.20f); + drawArchTrim(1.72f, 2.20f); + } + + private void drawArchTrim(float x, float z) { + push(); + Matrix.translateM(model, 0, x, 0.79f, z); + Matrix.rotateM(model, 0, 90f, 0f, 0f, 1f); + scaleAndDraw(torus, 0.77f, 0.11f, 0.77f, TRIM, 0f, 1f); + pop(); + } + + private void drawCabinInterior() { + drawPart(beveledBox, 0f, 1.18f, 0.30f, 2.66f, 0.20f, 3.72f, + INTERIOR, 0f, 1f); + drawPart(wedge, 0f, 1.48f, -1.04f, 2.42f, 0.45f, 0.62f, + INTERIOR, 0f, 1f); + drawPart(beveledBox, -0.69f, 1.42f, -0.48f, 0.79f, 0.30f, 0.88f, + SEAT, 0f, 1f); + drawPart(beveledBox, 0.69f, 1.42f, -0.48f, 0.79f, 0.30f, 0.88f, + SEAT, 0f, 1f); + drawSeatBack(-0.69f, -0.08f); + drawSeatBack(0.69f, -0.08f); + drawPart(beveledBox, -0.69f, 1.42f, 1.27f, 0.79f, 0.30f, 0.88f, + SEAT, 0f, 1f); + drawPart(beveledBox, 0.69f, 1.42f, 1.27f, 0.79f, 0.30f, 0.88f, + SEAT, 0f, 1f); + drawSeatBack(-0.69f, 1.67f); + drawSeatBack(0.69f, 1.67f); + drawPart(beveledBox, 0f, 1.50f, 0.57f, 0.30f, 0.34f, 2.42f, + BODY_DARK, 0f, 1f); + + // Dashboard vents are the origins of the animated climate streams. + for (int i = -1; i <= 1; i++) { + drawPart(beveledBox, i * 0.68f, 1.73f, -1.22f, + 0.34f, 0.12f, 0.08f, TRIM, 0.02f, 1f); + } + } + + private void drawSeatBack(float x, float z) { + push(); + Matrix.translateM(model, 0, x, 1.82f, z + 0.34f); + Matrix.rotateM(model, 0, -9f, 1f, 0f, 0f); + scaleAndDraw(beveledBox, 0.74f, 0.92f, 0.25f, SEAT, 0f, 1f); + pop(); + drawPart(beveledBox, x, 2.23f, z + 0.42f, 0.46f, 0.29f, 0.20f, + SEAT, 0f, 1f); + } + + private void drawDoors() { + drawSideDoor(0, -1f, -1.63f, true); + drawSideDoor(1, 1f, -1.63f, true); + drawSideDoor(2, -1f, 0.18f, false); + drawSideDoor(3, 1f, 0.18f, false); + } + + private void drawSideDoor(int index, float side, float hingeZ, boolean front) { + float angle = side * doorAmount[index] * 64f; + push(); + Matrix.translateM(model, 0, side * 1.80f, 1.18f, hingeZ); + Matrix.rotateM(model, 0, angle, 0f, 1f, 0f); + float length = front ? 1.70f : 1.60f; + float centerZ = length * 0.50f; + drawPart(beveledBox, side * 0.035f, 0f, centerZ, + 0.14f, 1.10f, length, BODY, 0f, 1f); + drawPart(beveledBox, side * 0.085f, -0.24f, centerZ, + 0.045f, 0.10f, length * 0.82f, BODY_LIGHT, 0f, 1f); + drawPart(beveledBox, side * 0.095f, 0.06f, + centerZ + (front ? 0.27f : -0.24f), + 0.045f, 0.075f, 0.30f, CHROME, 0.10f, 1f); + + float open = windowAmount[index]; + float visible = Math.max(0.025f, 1f - open); + float glassHeight = 0.82f * visible; + float glassY = 0.89f - open * 0.42f; + if (visible > 0.035f) { + drawPart(beveledBox, side * 0.015f, glassY, centerZ, + 0.07f, glassHeight, length * 0.82f, + GLASS, 0.10f, 0.55f); + } + pop(); + } + + private void drawTailgate() { + float lift = doorAmount[4]; + push(); + Matrix.translateM(model, 0, 0f, 2.15f, 2.42f); + Matrix.rotateM(model, 0, 55f - lift * 82f, 1f, 0f, 0f); + drawPart(beveledBox, 0f, 0f, 0.69f, 3.08f, 0.14f, 1.42f, + BODY, 0f, 1f); + drawPart(beveledBox, 0f, 0.075f, 0.33f, 2.44f, 0.055f, 0.56f, + GLASS, 0.08f, 0.54f); + drawPart(beveledBox, 0f, -0.02f, 1.27f, 2.72f, 0.15f, 0.16f, + BODY_DARK, 0f, 1f); + pop(); + } + + private void drawPillarsAndRoof() { + drawPillar(-1.18f, -1.30f, -27f); + drawPillar(1.18f, -1.30f, -27f); + drawPillar(-1.23f, 0.15f, 0f); + drawPillar(1.23f, 0.15f, 0f); + drawPillar(-1.14f, 1.84f, 27f); + drawPillar(1.14f, 1.84f, 27f); + // A shallow ellipsoidal shell gives the roof a continuous crown instead of slab edges. + drawPart(sphere, 0f, 2.47f, 0.32f, 2.47f, 0.25f, 3.18f, + BODY_DARK, 0f, 1f); + drawPart(beveledBox, -1.10f, 2.52f, 0.34f, 0.075f, 0.075f, 2.72f, + CHROME, 0f, 1f); + drawPart(beveledBox, 1.10f, 2.52f, 0.34f, 0.075f, 0.075f, 2.72f, + CHROME, 0f, 1f); + } + + private void drawPillar(float x, float z, float tilt) { + push(); + Matrix.translateM(model, 0, x, 1.94f, z); + Matrix.rotateM(model, 0, tilt, 1f, 0f, 0f); + scaleAndDraw(beveledBox, 0.11f, 0.98f, 0.15f, BODY_DARK, 0f, 1f); + pop(); + } + + private void drawGlassAndSunshade() { + push(); + Matrix.translateM(model, 0, 0f, 1.94f, -1.46f); + Matrix.rotateM(model, 0, -29f, 1f, 0f, 0f); + scaleAndDraw(quad, 2.43f, 1.06f, 1f, GLASS, 0.12f, 0.56f); + pop(); + + push(); + Matrix.translateM(model, 0, 0f, 1.98f, 2.02f); + Matrix.rotateM(model, 0, 180f, 0f, 1f, 0f); + Matrix.rotateM(model, 0, 28f, 1f, 0f, 0f); + scaleAndDraw(quad, 2.28f, 0.82f, 1f, GLASS, 0.08f, 0.48f); + pop(); + + // Panoramic roof: quad's +Z face is rotated onto the horizontal roof plane. + push(); + Matrix.translateM(model, 0, 0f, 2.605f, 0.30f); + Matrix.rotateM(model, 0, -90f, 1f, 0f, 0f); + scaleAndDraw(quad, 2.10f, 2.52f, 1f, GLASS, 0.18f, 0.64f); + pop(); + + float shadeCoverage = 1f - animatedSunshade; + if (shadeCoverage > 0.002f) { + float length = 2.62f * shadeCoverage; + float centerZ = 1.61f - length * 0.5f; + drawPart(beveledBox, 0f, 2.575f, centerZ, 2.04f, 0.035f, length, + SHADE, 0f, 0.58f); + } + + // Thin luminous borders make four independently moving side windows legible. + drawPart(beveledBox, -1.33f, 2.26f, 0.31f, 0.040f, 0.040f, 2.86f, + GLASS_EDGE, 0.35f, 0.72f); + drawPart(beveledBox, 1.33f, 2.26f, 0.31f, 0.040f, 0.040f, 2.86f, + GLASS_EDGE, 0.35f, 0.72f); + } + + private void drawLampsAndBeams(double seconds) { + float headPulse = 0.92f + 0.08f * sin(seconds * 4.2f); + float headEmission = targetHeadlights ? 1.85f * headPulse : 0.10f; + float headAlpha = targetHeadlights ? 0.98f : 0.44f; + drawPart(beveledBox, -0.78f, 1.03f, -3.18f, 0.62f, 0.16f, 0.10f, + HEADLIGHT, headEmission, headAlpha); + drawPart(beveledBox, 0.78f, 1.03f, -3.18f, 0.62f, 0.16f, 0.10f, + HEADLIGHT, headEmission, headAlpha); + + float tailEmission = targetBrakeLights ? 2.25f : 0.30f; + float tailAlpha = targetBrakeLights ? 1f : 0.58f; + drawPart(beveledBox, -1.16f, 1.16f, 3.31f, 0.88f, 0.17f, 0.10f, + TAILLIGHT, tailEmission, tailAlpha); + drawPart(beveledBox, 1.16f, 1.16f, 3.31f, 0.88f, 0.17f, 0.10f, + TAILLIGHT, tailEmission, tailAlpha); + + if (targetHeadlights) { + for (int band = 0; band < 3; band++) { + float z = -3.75f - band * 1.12f; + float width = 0.54f + band * 0.31f; + float alpha = (0.15f - band * 0.025f) * headPulse; + drawPart(beveledBox, -0.78f, 0.10f, z, width, 0.025f, 1.18f, + HEADLIGHT, 1.25f, alpha); + drawPart(beveledBox, 0.78f, 0.10f, z, width, 0.025f, 1.18f, + HEADLIGHT, 1.25f, alpha); + } + } + if (targetBrakeLights) { + drawPart(sphere, -1.16f, 1.16f, 3.41f, 1.10f, 0.55f, 0.20f, + TAILLIGHT, 1.4f, 0.18f); + drawPart(sphere, 1.16f, 1.16f, 3.41f, 1.10f, 0.55f, 0.20f, + TAILLIGHT, 1.4f, 0.18f); + } + } + + private void drawClimate(double seconds) { + if (animatedClimateStrength < 0.012f) return; + float heat = clamp((targetClimateTemperature - 16f) / 16f, 0f, 1f); + climateColor[0] = mix(COLD_AIR[0], WARM_AIR[0], heat); + climateColor[1] = mix(COLD_AIR[1], WARM_AIR[1], heat); + climateColor[2] = mix(COLD_AIR[2], WARM_AIR[2], heat); + climateColor[3] = 1f; + + int count = 4 + targetClimateFan; + float speed = 0.22f + targetClimateFan * 0.075f; + for (int stream = -1; stream <= 1; stream++) { + for (int particle = 0; particle < count; particle++) { + float phase = fract(seconds * speed + + particle / (float) count + (stream + 1) * 0.117f); + float x = stream * 0.67f + + 0.065f * sin(phase * 12.566f + stream); + float y = 1.72f + 0.20f * sin(phase * 3.1416f); + float z = -1.19f + phase * 2.75f; + float radius = 0.038f + phase * 0.040f; + float alpha = animatedClimateStrength * (1f - phase) * 0.70f; + drawPart(sphere, x, y, z, radius, radius, radius, + climateColor, 1.55f, alpha); + } + } + } + + private void drawPart(VehicleGlMesh mesh, float x, float y, float z, + float sx, float sy, float sz, float[] color, float emission, float alpha) { + push(); + Matrix.translateM(model, 0, x, y, z); + scaleAndDraw(mesh, sx, sy, sz, color, emission, alpha); + pop(); + } + + private void drawPart(VehicleGlMesh mesh, float x, float y, float z, + float sx, float sy, float sz, float[] color, float emission, float alpha, + float angle, float ax, float ay, float az) { + push(); + Matrix.translateM(model, 0, x, y, z); + Matrix.rotateM(model, 0, angle, ax, ay, az); + scaleAndDraw(mesh, sx, sy, sz, color, emission, alpha); + pop(); + } + + private void scaleAndDraw(VehicleGlMesh mesh, float sx, float sy, float sz, + float[] color, float emission, float alpha) { + push(); + Matrix.scaleM(model, 0, sx, sy, sz); + Matrix.multiplyMM(modelViewProjection, 0, viewProjection, 0, model, 0); + if (Matrix.invertM(inverseModel, 0, model, 0)) { + Matrix.transposeM(normalMatrix, 0, inverseModel, 0); + } else { + Matrix.setIdentityM(normalMatrix, 0); + } + GLES20.glUniformMatrix4fv(uModel, 1, false, model, 0); + GLES20.glUniformMatrix4fv(uNormalMatrix, 1, false, normalMatrix, 0); + GLES20.glUniformMatrix4fv(uMvp, 1, false, modelViewProjection, 0); + GLES20.glUniform4fv(uColor, 1, color, 0); + GLES20.glUniform1f(uEmission, emission); + GLES20.glUniform1f(uAlpha, alpha); + GLES20.glUniform1f(uMetallic, 0.08f); + GLES20.glUniform1f(uRoughness, 0.34f); + GLES20.glUniform1f(uClearcoat, proceduralClearcoat(color)); + GLES20.glUniform1f(uTransmission, + color == GLASS || color == GLASS_EDGE ? 1f : 0f); + GLES20.glUniform1f(uEffectMode, emission > 0.50f ? 2f : 0f); + GLES20.glUniform1f(uHasBaseColorTexture, 0f); + mesh.draw(aPosition, aNormal, aTexCoord); + pop(); + } + + private static float materialClearcoat(VehicleGlbModel.Material material) { + String name = material.name; + if (containsIgnoreCase(name, "carpaint")) return 0.58f; + if (containsIgnoreCase(name, "window") || containsIgnoreCase(name, "glass")) { + return 0.32f; + } + if (containsIgnoreCase(name, "chrome") || containsIgnoreCase(name, "metal") + || containsIgnoreCase(name, "rim")) { + return 0.20f; + } + return 0.06f; + } + + private static float proceduralClearcoat(float[] color) { + if (color == BODY || color == BODY_LIGHT || color == BODY_DARK) return 0.52f; + if (color == GLASS || color == GLASS_EDGE) return 0.30f; + if (color == CHROME || color == RIM) return 0.18f; + return 0.05f; + } + + private void push() { + if (stackDepth >= MATRIX_STACK_SIZE) { + throw new IllegalStateException("Vehicle matrix stack overflow"); + } + System.arraycopy(model, 0, matrixStack[stackDepth], 0, 16); + stackDepth++; + } + + private void pop() { + if (stackDepth <= 0) { + throw new IllegalStateException("Vehicle matrix stack underflow"); + } + stackDepth--; + System.arraycopy(matrixStack[stackDepth], 0, model, 0, 16); + } + + void releaseGlObjects() { + if (authoredModel != null) authoredModel.release(); + authoredModel = null; + clearAuthoredAnimationNodes(); + release(unitBox); + release(beveledBox); + release(wedge); + release(crossoverBody); + release(sphere); + release(cylinder); + release(torus); + release(quad); + unitBox = null; + beveledBox = null; + wedge = null; + crossoverBody = null; + sphere = null; + cylinder = null; + torus = null; + quad = null; + if (program != 0) GLES20.glDeleteProgram(program); + program = 0; + } + + /** Compatibility alias used by the owning GLSurfaceView on its GL thread. */ + void release() { + releaseGlObjects(); + } + + void markContextLost() { + program = 0; + authoredModel = null; + clearAuthoredAnimationNodes(); + unitBox = null; + beveledBox = null; + wedge = null; + crossoverBody = null; + sphere = null; + cylinder = null; + torus = null; + quad = null; + previousFrameNanos = 0L; + } + + private static void upload(VehicleGlMesh mesh) { + if (mesh != null) mesh.upload(); + } + + private static void release(VehicleGlMesh mesh) { + if (mesh != null) mesh.release(); + } + + private static int createProgram(String vertexSource, String fragmentSource) { + int vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource); + int fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + if (vertex == 0 || fragment == 0) { + if (vertex != 0) GLES20.glDeleteShader(vertex); + if (fragment != 0) GLES20.glDeleteShader(fragment); + return 0; + } + int result = GLES20.glCreateProgram(); + GLES20.glAttachShader(result, vertex); + GLES20.glAttachShader(result, fragment); + GLES20.glLinkProgram(result); + int[] status = new int[1]; + GLES20.glGetProgramiv(result, GLES20.GL_LINK_STATUS, status, 0); + GLES20.glDeleteShader(vertex); + GLES20.glDeleteShader(fragment); + if (status[0] == 0) { + Log.e(TAG, "Program link failed: " + GLES20.glGetProgramInfoLog(result)); + GLES20.glDeleteProgram(result); + return 0; + } + return result; + } + + private static int compileShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] status = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0); + if (status[0] == 0) { + Log.e(TAG, "Shader compile failed: " + GLES20.glGetShaderInfoLog(shader)); + GLES20.glDeleteShader(shader); + return 0; + } + return shader; + } + + private static float sin(double value) { + return (float) Math.sin(value); + } + + private static float fract(double value) { + return (float) (value - Math.floor(value)); + } + + private static float mix(float a, float b, float amount) { + return a + (b - a) * amount; + } + + private static boolean finite(float value) { + return !Float.isNaN(value) && !Float.isInfinite(value); + } + + private static float normalizeDegrees(float degrees) { + float normalized = degrees % 360f; + if (normalized > 180f) normalized -= 360f; + if (normalized <= -180f) normalized += 360f; + return normalized; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static float clamp(float value, float min, float max) { + return Math.max(min, Math.min(max, value)); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleResponseParser.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleResponseParser.java new file mode 100644 index 0000000..185e889 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleResponseParser.java @@ -0,0 +1,59 @@ +package com.seeker.n7cockpit; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; + +/** Pure-Java parser for the helper's {@code OK VEHICLE/CLIMATE key=value} protocol. */ +public final class VehicleResponseParser { + private static final Pattern KEY = Pattern.compile("[A-Za-z][A-Za-z0-9_]*"); + + private VehicleResponseParser() {} + + public static Map parseFields(String expectedSection, String response) { + if (expectedSection == null || expectedSection.trim().isEmpty()) { + throw new IllegalArgumentException("expected section is empty"); + } + if (response == null) throw new IllegalArgumentException("helper response is null"); + + String trimmed = response.trim(); + String prefix = "OK " + expectedSection.trim().toUpperCase(Locale.US); + if (!(trimmed.equals(prefix) || trimmed.startsWith(prefix + " "))) { + throw new IllegalArgumentException("unexpected helper response: " + safeExcerpt(trimmed)); + } + + Map fields = new LinkedHashMap<>(); + if (trimmed.length() == prefix.length()) return fields; + String payload = trimmed.substring(prefix.length() + 1).trim(); + if (payload.isEmpty()) return fields; + for (String token : payload.split("\\s+")) { + int equals = token.indexOf('='); + if (equals <= 0 || equals == token.length() - 1 || token.indexOf('=', equals + 1) >= 0) { + throw new IllegalArgumentException("malformed field: " + safeExcerpt(token)); + } + String key = token.substring(0, equals); + String value = token.substring(equals + 1); + if (!KEY.matcher(key).matches()) { + throw new IllegalArgumentException("invalid field name: " + safeExcerpt(key)); + } + if (fields.put(key, value) != null) { + throw new IllegalArgumentException("duplicate field: " + key); + } + } + return fields; + } + + public static VehicleSnapshot parseSnapshot(String vehicleResponse, String climateResponse, + boolean simulated, String source) { + Map vehicle = parseFields("VEHICLE", vehicleResponse); + Map climate = parseFields("CLIMATE", climateResponse); + return VehicleSnapshot.create(vehicle, climate, simulated, source); + } + + private static String safeExcerpt(String value) { + if (value == null) return "null"; + String printable = value.replace('\n', ' ').replace('\r', ' '); + return printable.length() <= 120 ? printable : printable.substring(0, 120) + "…"; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSilhouetteView.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSilhouetteView.java new file mode 100644 index 0000000..a752959 --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSilhouetteView.java @@ -0,0 +1,610 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.opengl.GLSurfaceView; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewParent; + +import java.util.EnumMap; + +/** + * Transparent OpenGL ES host for the real-time three-dimensional vehicle model. + * + *

This class deliberately owns only Android view concerns: the public vehicle-state API, + * transparent EGL surface, visibility lifecycle, accessibility, and touch interaction. All GPU + * resources and scene rendering live in {@link VehicleRenderer3D}. Keeping those responsibilities + * separate lets the renderer rebuild its resources whenever Android recreates the EGL context.

+ */ +public class VehicleSilhouetteView extends GLSurfaceView { + public enum Door { + FRONT_LEFT, + FRONT_RIGHT, + REAR_LEFT, + REAR_RIGHT, + TRUNK + } + + public enum WindowPosition { + FRONT_LEFT, + FRONT_RIGHT, + REAR_LEFT, + REAR_RIGHT + } + + public interface OnVehicleZoneClickListener { + void onDoorSelected(Door door); + } + + private static final float MIN_TEMPERATURE_C = 16f; + private static final float MAX_TEMPERATURE_C = 32f; + private static final int MAX_FAN_LEVEL = 7; + private static final float TOUCH_ROTATION_DEGREES_PER_PIXEL = 0.28f; + + private final Object stateLock = new Object(); + private final EnumMap doorStates = new EnumMap<>(Door.class); + private final EnumMap windowStates = + new EnumMap<>(WindowPosition.class); + + private VehicleRenderer3D renderer; + private OnVehicleZoneClickListener zoneClickListener; + + private boolean headlightsOn; + private boolean brakeLightsOn; + private boolean ambientLightOn; + private boolean climateOn; + private float climateTargetTemperatureC = 24f; + private int climateFanLevel; + private int sunshadePercent; + private float userRotationDegrees; + private boolean animationEnabled = true; + + private int activePointerId = MotionEvent.INVALID_POINTER_ID; + private float touchDownX; + private float touchDownY; + private float lastTouchX; + private boolean dragging; + private int touchSlop; + + private boolean rendererPaused; + private boolean detached; + + public VehicleSilhouetteView(Context context) { + super(context); + init(context); + } + + public VehicleSilhouetteView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public VehicleSilhouetteView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + for (Door door : Door.values()) { + doorStates.put(door, false); + } + for (WindowPosition position : WindowPosition.values()) { + windowStates.put(position, 0); + } + + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + setMinimumWidth(CockpitTheme.dpInt(context, 260f)); + setMinimumHeight(CockpitTheme.dpInt(context, 260f)); + setFocusable(true); + setFocusableInTouchMode(true); + setBackgroundColor(Color.TRANSPARENT); + + // An alpha EGL buffer and translucent Surface are both required; either one alone leaves a + // black rectangle on several Android automotive and emulator GPU implementations. + setEGLContextClientVersion(2); + setEGLConfigChooser(8, 8, 8, 8, 16, 0); + getHolder().setFormat(PixelFormat.TRANSLUCENT); + // Android 8/9 only composites the RGBA Surface with its parent when it is promoted to an + // overlay layer. The View itself is clipped to this card, while dialogs/system windows and + // the mascot Surface (attached later) remain above it. + setZOrderOnTop(true); + setPreserveEGLContextOnPause(true); + + renderer = new VehicleRenderer3D(context); + setRenderer(renderer); + setRenderMode(RENDERMODE_CONTINUOUSLY); + updateContentDescription(); + } + + public void setDoorOpen(Door door, boolean open) { + if (door == null) { + return; + } + synchronized (stateLock) { + if (Boolean.TRUE.equals(doorStates.get(door)) == open) { + return; + } + doorStates.put(door, open); + } + renderer.setDoorOpen(door, open); + stateChanged(); + } + + public boolean isDoorOpen(Door door) { + if (door == null) { + return false; + } + synchronized (stateLock) { + return Boolean.TRUE.equals(doorStates.get(door)); + } + } + + public void setAllDoorsClosed() { + boolean changed = false; + synchronized (stateLock) { + for (Door door : Door.values()) { + if (Boolean.TRUE.equals(doorStates.get(door))) { + doorStates.put(door, false); + renderer.setDoorOpen(door, false); + changed = true; + } + } + } + if (changed) { + stateChanged(); + } + } + + public void setWindowPercent(WindowPosition position, int percent) { + if (position == null) { + return; + } + int safePercent = clamp(percent, 0, 100); + synchronized (stateLock) { + Integer current = windowStates.get(position); + if (current != null && current.intValue() == safePercent) { + return; + } + windowStates.put(position, safePercent); + } + renderer.setWindowPercent(position, safePercent); + stateChanged(); + } + + public int getWindowPercent(WindowPosition position) { + if (position == null) { + return 0; + } + synchronized (stateLock) { + Integer value = windowStates.get(position); + return value == null ? 0 : value; + } + } + + public void setHeadlightsOn(boolean enabled) { + synchronized (stateLock) { + if (headlightsOn == enabled) { + return; + } + headlightsOn = enabled; + } + renderer.setLights(enabled, brakeLightsOn, ambientLightOn); + stateChanged(); + } + + public boolean areHeadlightsOn() { + synchronized (stateLock) { + return headlightsOn; + } + } + + public void setBrakeLightsOn(boolean enabled) { + synchronized (stateLock) { + if (brakeLightsOn == enabled) { + return; + } + brakeLightsOn = enabled; + } + renderer.setLights(headlightsOn, enabled, ambientLightOn); + stateChanged(); + } + + public boolean areBrakeLightsOn() { + synchronized (stateLock) { + return brakeLightsOn; + } + } + + public void setAmbientLightOn(boolean enabled) { + synchronized (stateLock) { + if (ambientLightOn == enabled) { + return; + } + ambientLightOn = enabled; + } + renderer.setLights(headlightsOn, brakeLightsOn, enabled); + stateChanged(); + } + + public boolean isAmbientLightOn() { + synchronized (stateLock) { + return ambientLightOn; + } + } + + public void setLights(boolean headlights, boolean brakeLights, boolean ambientLight) { + boolean changed; + synchronized (stateLock) { + changed = headlightsOn != headlights + || brakeLightsOn != brakeLights + || ambientLightOn != ambientLight; + headlightsOn = headlights; + brakeLightsOn = brakeLights; + ambientLightOn = ambientLight; + } + if (changed) { + renderer.setLights(headlights, brakeLights, ambientLight); + stateChanged(); + } + } + + /** Updates the animated cabin airflow color and intensity. */ + public void setClimateState(boolean enabled, float targetTemperatureC, int fanLevel) { + float safeTemperature = Float.isFinite(targetTemperatureC) + ? clamp(targetTemperatureC, MIN_TEMPERATURE_C, MAX_TEMPERATURE_C) + : 24f; + int safeFanLevel = clamp(fanLevel, 0, MAX_FAN_LEVEL); + boolean changed; + synchronized (stateLock) { + changed = climateOn != enabled + || Math.abs(climateTargetTemperatureC - safeTemperature) > 0.001f + || climateFanLevel != safeFanLevel; + climateOn = enabled; + climateTargetTemperatureC = safeTemperature; + climateFanLevel = safeFanLevel; + } + if (changed) { + renderer.setClimateState(enabled, safeTemperature, safeFanLevel); + stateChanged(); + } + } + + public boolean isClimateOn() { + synchronized (stateLock) { + return climateOn; + } + } + + public float getClimateTargetTemperatureC() { + synchronized (stateLock) { + return climateTargetTemperatureC; + } + } + + public int getClimateFanLevel() { + synchronized (stateLock) { + return climateFanLevel; + } + } + + public void setSunshadePercent(int percent) { + int safePercent = clamp(percent, 0, 100); + synchronized (stateLock) { + if (sunshadePercent == safePercent) { + return; + } + sunshadePercent = safePercent; + } + renderer.setSunshadePercent(safePercent); + stateChanged(); + } + + public int getSunshadePercent() { + synchronized (stateLock) { + return sunshadePercent; + } + } + + /** Sets the user's persistent yaw offset in degrees. */ + public void setUserRotation(float degrees) { + float safeDegrees = Float.isFinite(degrees) ? normalizeDegrees(degrees) : 0f; + synchronized (stateLock) { + userRotationDegrees = safeDegrees; + } + renderer.setUserRotation(safeDegrees); + requestFrame(); + } + + public float getUserRotation() { + synchronized (stateLock) { + return userRotationDegrees; + } + } + + public void setAnimationEnabled(boolean enabled) { + if (animationEnabled == enabled) { + return; + } + animationEnabled = enabled; + renderer.setAnimationEnabled(enabled); + setRenderMode(enabled ? RENDERMODE_CONTINUOUSLY : RENDERMODE_WHEN_DIRTY); + requestFrame(); + } + + public boolean isAnimationEnabled() { + return animationEnabled; + } + + public void setOnVehicleZoneClickListener(OnVehicleZoneClickListener listener) { + zoneClickListener = listener; + setClickable(listener != null); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desiredWidth = CockpitTheme.dpInt(getContext(), 430f) + + getPaddingLeft() + getPaddingRight(); + int desiredHeight = CockpitTheme.dpInt(getContext(), 330f) + + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension( + resolveSize(desiredWidth, widthMeasureSpec), + resolveSize(desiredHeight, heightMeasureSpec)); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled()) { + return super.onTouchEvent(event); + } + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + activePointerId = event.getPointerId(0); + touchDownX = lastTouchX = event.getX(); + touchDownY = event.getY(); + dragging = false; + setPressed(true); + return true; + case MotionEvent.ACTION_POINTER_UP: + handlePointerUp(event); + return true; + case MotionEvent.ACTION_MOVE: + int pointerIndex = event.findPointerIndex(activePointerId); + if (pointerIndex < 0) { + return true; + } + float x = event.getX(pointerIndex); + float y = event.getY(pointerIndex); + if (!dragging && (Math.abs(x - touchDownX) > touchSlop + || Math.abs(y - touchDownY) > touchSlop)) { + dragging = true; + setPressed(false); + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(true); + } + } + if (dragging) { + float deltaDegrees = (x - lastTouchX) * TOUCH_ROTATION_DEGREES_PER_PIXEL; + synchronized (stateLock) { + userRotationDegrees = normalizeDegrees(userRotationDegrees + deltaDegrees); + } + renderer.addUserRotation(deltaDegrees); + requestFrame(); + } + lastTouchX = x; + return true; + case MotionEvent.ACTION_UP: + setPressed(false); + if (!dragging && zoneClickListener != null) { + Door selectedDoor = hitDoorRegion(event.getX(), event.getY()); + if (selectedDoor != null) { + performClick(); + zoneClickListener.onDoorSelected(selectedDoor); + } + } + endGesture(); + return true; + case MotionEvent.ACTION_CANCEL: + setPressed(false); + endGesture(); + return true; + default: + return true; + } + } + + @Override + public boolean performClick() { + super.performClick(); + return true; + } + + private void handlePointerUp(MotionEvent event) { + int liftedIndex = event.getActionIndex(); + if (event.getPointerId(liftedIndex) != activePointerId) { + return; + } + int replacementIndex = liftedIndex == 0 ? 1 : 0; + if (replacementIndex < event.getPointerCount()) { + activePointerId = event.getPointerId(replacementIndex); + lastTouchX = event.getX(replacementIndex); + } else { + activePointerId = MotionEvent.INVALID_POINTER_ID; + } + } + + private void endGesture() { + activePointerId = MotionEvent.INVALID_POINTER_ID; + dragging = false; + ViewParent parent = getParent(); + if (parent != null) { + parent.requestDisallowInterceptTouchEvent(false); + } + } + + /** + * Approximate screen-space regions for the default front-left three-quarter camera. The + * renderer may animate and rotate the car, so these are intentionally generous control zones. + */ + private Door hitDoorRegion(float x, float y) { + float contentWidth = getWidth() - getPaddingLeft() - getPaddingRight(); + float contentHeight = getHeight() - getPaddingTop() - getPaddingBottom(); + if (contentWidth <= 0f || contentHeight <= 0f) { + return null; + } + float nx = (x - getPaddingLeft()) / contentWidth; + float ny = (y - getPaddingTop()) / contentHeight; + if (nx < 0.08f || nx > 0.92f || ny < 0.08f || ny > 0.94f) { + return null; + } + if (ny < 0.30f && nx > 0.38f) { + return Door.TRUNK; + } + boolean leftSide = nx < 0.50f; + boolean frontHalf = ny >= 0.55f; + if (frontHalf) { + return leftSide ? Door.FRONT_LEFT : Door.FRONT_RIGHT; + } + return leftSide ? Door.REAR_LEFT : Door.REAR_RIGHT; + } + + @Override + protected void onAttachedToWindow() { + detached = false; + super.onAttachedToWindow(); + updateVisibilityLifecycle(); + } + + @Override + protected void onDetachedFromWindow() { + detached = true; + if (!rendererPaused) { + try { + queueEvent(renderer::releaseGlObjects); + requestRender(); + } catch (RuntimeException ignored) { + renderer.markContextLost(); + } + } else { + renderer.markContextLost(); + } + pauseRendererIfNeeded(); + super.onDetachedFromWindow(); + renderer.markContextLost(); + } + + @Override + protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + updateVisibilityLifecycle(); + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + updateVisibilityLifecycle(); + } + + /** Allows an Activity to forward its lifecycle explicitly; visibility handling is automatic. */ + @Override + public void onPause() { + pauseRendererIfNeeded(); + } + + /** Allows an Activity to forward its lifecycle explicitly; visibility handling is automatic. */ + @Override + public void onResume() { + if (shouldRenderNow()) { + resumeRendererIfNeeded(); + } + } + + private void updateVisibilityLifecycle() { + if (!isAttachedToWindow() || detached) { + return; + } + if (shouldRenderNow()) { + resumeRendererIfNeeded(); + } else { + pauseRendererIfNeeded(); + } + } + + private boolean shouldRenderNow() { + return !detached + && isAttachedToWindow() + && getVisibility() == VISIBLE + && getWindowVisibility() == VISIBLE + && isShown(); + } + + private void pauseRendererIfNeeded() { + if (!rendererPaused) { + super.onPause(); + rendererPaused = true; + } + } + + private void resumeRendererIfNeeded() { + if (rendererPaused) { + super.onResume(); + rendererPaused = false; + } + requestFrame(); + } + + private void stateChanged() { + updateContentDescription(); + requestFrame(); + } + + private void requestFrame() { + if (renderer != null) { + requestRender(); + } + } + + private void updateContentDescription() { + int openDoors = 0; + int openWindows = 0; + synchronized (stateLock) { + for (Door door : Door.values()) { + if (Boolean.TRUE.equals(doorStates.get(door))) { + openDoors++; + } + } + for (WindowPosition position : WindowPosition.values()) { + Integer value = windowStates.get(position); + if (value != null && value > 0) { + openWindows++; + } + } + setContentDescription("三维车辆模型," + openDoors + " 个车门开启," + + openWindows + " 个车窗开启,前灯" + + (headlightsOn ? "已开启" : "已关闭") + ",空调" + + (climateOn ? "已开启" : "已关闭")); + } + } + + private static int clamp(int value, int minimum, int maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private static float normalizeDegrees(float degrees) { + float normalized = degrees % 360f; + if (normalized > 180f) { + normalized -= 360f; + } else if (normalized < -180f) { + normalized += 360f; + } + return normalized; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSnapshot.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSnapshot.java new file mode 100644 index 0000000..f14fc2f --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/VehicleSnapshot.java @@ -0,0 +1,203 @@ +package com.seeker.n7cockpit; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Immutable, structured vehicle/climate state returned by {@link VehicleGateway}. */ +public final class VehicleSnapshot { + private final Map vehicle; + private final Map climate; + private final boolean simulated; + private final String source; + private final long capturedAtMillis; + private final long revision; + private final String lastAction; + + VehicleSnapshot(Map vehicle, Map climate, + boolean simulated, String source, long capturedAtMillis, + long revision, String lastAction) { + this.vehicle = immutableCopy(vehicle); + this.climate = immutableCopy(climate); + this.simulated = simulated; + this.source = source == null ? "未知环境" : source; + this.capturedAtMillis = capturedAtMillis; + this.revision = revision; + this.lastAction = lastAction == null ? "" : lastAction; + } + + static VehicleSnapshot create(Map vehicle, Map climate, + boolean simulated, String source) { + return new VehicleSnapshot(vehicle, climate, simulated, source, + System.currentTimeMillis(), 0L, ""); + } + + static VehicleSnapshot create(Map vehicle, Map climate, + boolean simulated, String source, long revision, String lastAction) { + return new VehicleSnapshot(vehicle, climate, simulated, source, + System.currentTimeMillis(), revision, lastAction); + } + + public Map vehicle() { + return vehicle; + } + + public Map climate() { + return climate; + } + + public Map getVehicle() { + return vehicle; + } + + public Map getClimate() { + return climate; + } + + public String vehicleValue(String key) { + return value(vehicle, key); + } + + public String climateValue(String key) { + return value(climate, key); + } + + public String getVehicleValue(String key) { + return vehicleValue(key); + } + + public String getClimateValue(String key) { + return climateValue(key); + } + + public float vehicleFloat(String key, float fallback) { + return floatValue(vehicle, key, fallback); + } + + public float climateFloat(String key, float fallback) { + return floatValue(climate, key, fallback); + } + + public int vehicleInt(String key, int fallback) { + return intValue(vehicle, key, fallback); + } + + public int climateInt(String key, int fallback) { + return intValue(climate, key, fallback); + } + + public boolean vehicleFlag(String key, boolean fallback) { + return flagValue(vehicle, key, fallback); + } + + public boolean climateFlag(String key, boolean fallback) { + return flagValue(climate, key, fallback); + } + + public float speedKph() { + return vehicleFloat("speed", Float.NaN); + } + + public float socPercent() { + return vehicleFloat("soc", Float.NaN); + } + + public float mileageKm() { + return vehicleFloat("mileage", Float.NaN); + } + + public float powerKw() { + return vehicleFloat("power", Float.NaN); + } + + public int gearCode() { + return vehicleInt("gear", -1); + } + + public boolean climatePowerOn() { + return climateFlag("power", false); + } + + public float targetTemperatureC() { + return climateFloat("temp", Float.NaN); + } + + public int fanLevel() { + return climateInt("fan", -1); + } + + public boolean isSimulated() { + return simulated; + } + + public boolean isSimulation() { + return simulated; + } + + public String source() { + return source; + } + + public String getSource() { + return source; + } + + public long capturedAtMillis() { + return capturedAtMillis; + } + + public long getCapturedAtMillis() { + return capturedAtMillis; + } + + public long revision() { + return revision; + } + + public String lastAction() { + return lastAction; + } + + private static Map immutableCopy(Map source) { + if (source == null || source.isEmpty()) return Collections.emptyMap(); + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } + + private static String value(Map values, String key) { + String result = values.get(key); + return result == null || result.isEmpty() ? "?" : result; + } + + private static float floatValue(Map values, String key, float fallback) { + String raw = values.get(key); + if (raw == null || "?".equals(raw)) return fallback; + try { + float parsed = Float.parseFloat(raw); + return Float.isFinite(parsed) ? parsed : fallback; + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static int intValue(Map values, String key, int fallback) { + String raw = values.get(key); + if (raw == null || "?".equals(raw)) return fallback; + try { + return Integer.parseInt(raw); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static boolean flagValue(Map values, String key, boolean fallback) { + String raw = values.get(key); + if ("1".equals(raw) || "true".equalsIgnoreCase(raw) || "on".equalsIgnoreCase(raw)) { + return true; + } + if ("0".equals(raw) || "2".equals(raw) || "false".equalsIgnoreCase(raw) + || "off".equalsIgnoreCase(raw)) { + return false; + } + return fallback; + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/WindowControlView.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/WindowControlView.java new file mode 100644 index 0000000..dd092ef --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/WindowControlView.java @@ -0,0 +1,513 @@ +package com.seeker.n7cockpit; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Shader; +import android.util.AttributeSet; +import android.view.HapticFeedbackConstants; +import android.view.MotionEvent; +import android.view.View; + +import java.util.EnumMap; +import java.util.Locale; + +/** Four-zone window status and preset control. */ +public class WindowControlView extends View { + public enum Position { + FRONT_LEFT, + FRONT_RIGHT, + REAR_LEFT, + REAR_RIGHT + } + + public interface OnWindowChangeListener { + void onWindowPercentRequested(Position position, int percent); + } + + private static final int[] DEFAULT_PRESETS = {10, 50, 100}; + + private final EnumMap percentages = new EnumMap<>(Position.class); + private final EnumMap positionEnabled = new EnumMap<>(Position.class); + private final EnumMap positionLabels = new EnumMap<>(Position.class); + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF cardRect = new RectF(); + private final RectF columnRect = new RectF(); + private final RectF tempRect = new RectF(); + private final RectF[][] presetHitRects = new RectF[Position.values().length][3]; + private Shader surfaceShader; + + private OnWindowChangeListener changeListener; + private boolean controlsLocked; + private int pressedPosition = -1; + private int pressedPreset = -1; + + public WindowControlView(Context context) { + super(context); + init(); + } + + public WindowControlView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public WindowControlView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + private void init() { + setClickable(true); + setFocusable(true); + setMinimumWidth(CockpitTheme.dpInt(getContext(), 800f)); + setMinimumHeight(CockpitTheme.dpInt(getContext(), 190f)); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setStrokeCap(Paint.Cap.ROUND); + strokePaint.setStrokeJoin(Paint.Join.ROUND); + + positionLabels.put(Position.FRONT_LEFT, "左前窗"); + positionLabels.put(Position.FRONT_RIGHT, "右前窗"); + positionLabels.put(Position.REAR_LEFT, "左后窗"); + positionLabels.put(Position.REAR_RIGHT, "右后窗"); + for (Position position : Position.values()) { + percentages.put(position, 0); + positionEnabled.put(position, true); + } + for (int position = 0; position < presetHitRects.length; position++) { + for (int preset = 0; preset < presetHitRects[position].length; preset++) { + presetHitRects[position][preset] = new RectF(); + } + } + updateContentDescription(); + } + + public void setOnWindowChangeListener(OnWindowChangeListener listener) { + changeListener = listener; + } + + public void setWindowPercent(Position position, int percent) { + if (position == null) { + return; + } + int safePercent = CockpitTheme.clamp(percent, 0, 100); + Integer current = percentages.get(position); + if (current == null || current != safePercent) { + percentages.put(position, safePercent); + updateContentDescription(); + invalidate(); + } + } + + public int getWindowPercent(Position position) { + Integer value = position == null ? null : percentages.get(position); + return value == null ? 0 : value; + } + + public void setAllWindows(int percent) { + int safePercent = CockpitTheme.clamp(percent, 0, 100); + boolean changed = false; + for (Position position : Position.values()) { + if (getWindowPercent(position) != safePercent) { + percentages.put(position, safePercent); + changed = true; + } + } + if (changed) { + updateContentDescription(); + invalidate(); + } + } + + public void setWindowEnabled(Position position, boolean enabled) { + if (position == null) { + return; + } + if (Boolean.TRUE.equals(positionEnabled.get(position)) != enabled) { + positionEnabled.put(position, enabled); + updateContentDescription(); + invalidate(); + } + } + + public boolean isWindowEnabled(Position position) { + return position != null && Boolean.TRUE.equals(positionEnabled.get(position)); + } + + public void setControlsLocked(boolean locked) { + if (controlsLocked != locked) { + controlsLocked = locked; + updateContentDescription(); + invalidate(); + } + } + + public boolean areControlsLocked() { + return controlsLocked; + } + + public void setPositionLabel(Position position, CharSequence label) { + if (position != null) { + positionLabels.put(position, label == null ? "" : label); + updateContentDescription(); + invalidate(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int desiredWidth = CockpitTheme.dpInt(getContext(), 1040f) + + getPaddingLeft() + getPaddingRight(); + int desiredHeight = CockpitTheme.dpInt(getContext(), 220f) + + getPaddingTop() + getPaddingBottom(); + setMeasuredDimension( + resolveSize(desiredWidth, widthMeasureSpec), + resolveSize(desiredHeight, heightMeasureSpec)); + } + + @Override + protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { + super.onSizeChanged(width, height, oldWidth, oldHeight); + surfaceShader = new LinearGradient( + getPaddingLeft(), + getPaddingTop(), + Math.max(getPaddingLeft() + 1f, width - getPaddingRight()), + Math.max(getPaddingTop() + 1f, height - getPaddingBottom()), + CockpitTheme.COLOR_SURFACE_RAISED, + CockpitTheme.COLOR_SURFACE, + Shader.TileMode.CLAMP); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + cardRect.set( + getPaddingLeft(), + getPaddingTop(), + getWidth() - getPaddingRight(), + getHeight() - getPaddingBottom()); + float corner = CockpitTheme.dp(getContext(), CockpitTheme.CORNER_MEDIUM_DP); + paint.setStyle(Paint.Style.FILL); + paint.setShader(surfaceShader); + canvas.drawRoundRect(cardRect, corner, corner, paint); + paint.setShader(null); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1f)); + strokePaint.setColor(CockpitTheme.COLOR_STROKE); + canvas.drawRoundRect(cardRect, corner, corner, strokePaint); + + float inset = CockpitTheme.dp(getContext(), 14f); + float availableLeft = cardRect.left + inset; + float availableRight = cardRect.right - inset; + float columnWidth = (availableRight - availableLeft) / Position.values().length; + for (int i = 0; i < Position.values().length; i++) { + float columnLeft = availableLeft + i * columnWidth; + columnRect.set( + columnLeft, + cardRect.top + inset, + columnLeft + columnWidth, + cardRect.bottom - inset); + drawColumn(canvas, Position.values()[i], i, columnRect); + if (i < Position.values().length - 1) { + float dividerX = columnRect.right; + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1f)); + strokePaint.setColor(CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.72f)); + canvas.drawLine( + dividerX, + columnRect.top + CockpitTheme.dp(getContext(), 7f), + dividerX, + columnRect.bottom - CockpitTheme.dp(getContext(), 7f), + strokePaint); + } + } + } + + private void drawColumn(Canvas canvas, Position position, int index, RectF column) { + boolean enabled = isEnabled() && isWindowEnabled(position) && !controlsLocked; + float sideInset = CockpitTheme.dp(getContext(), 9f); + float buttonHeight = Math.min( + CockpitTheme.dp(getContext(), 62f), + Math.max(CockpitTheme.dp(getContext(), CockpitTheme.TOUCH_TARGET_DP), + column.height() * 0.34f)); + float labelHeight = CockpitTheme.dp(getContext(), 24f); + float buttonTop = column.bottom - buttonHeight; + + paint.setStyle(Paint.Style.FILL); + paint.setTypeface(CockpitTheme.TYPEFACE_MEDIUM); + paint.setTextSize(CockpitTheme.sp(getContext(), 15f)); + paint.setTextAlign(Paint.Align.LEFT); + paint.setColor(enabled + ? CockpitTheme.COLOR_TEXT_PRIMARY + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.48f)); + Paint.FontMetrics labelMetrics = paint.getFontMetrics(); + float labelBaseline = column.top - labelMetrics.ascent; + canvas.drawText( + String.valueOf(positionLabels.get(position)), + column.left + sideInset, + labelBaseline, + paint); + + RectF windowBounds = new RectF( + column.left + sideInset, + column.top + labelHeight + CockpitTheme.dp(getContext(), 3f), + column.right - sideInset, + buttonTop - CockpitTheme.dp(getContext(), 10f)); + drawWindow(canvas, position, windowBounds, getWindowPercent(position), enabled); + + float buttonGap = CockpitTheme.dp(getContext(), 6f); + float buttonAreaLeft = column.left + sideInset; + float buttonAreaRight = column.right - sideInset; + float buttonWidth = (buttonAreaRight - buttonAreaLeft - buttonGap * 2f) / 3f; + for (int presetIndex = 0; presetIndex < DEFAULT_PRESETS.length; presetIndex++) { + float buttonLeft = buttonAreaLeft + presetIndex * (buttonWidth + buttonGap); + RectF hit = presetHitRects[index][presetIndex]; + hit.set(buttonLeft, buttonTop, buttonLeft + buttonWidth, column.bottom); + drawPresetButton( + canvas, + hit, + DEFAULT_PRESETS[presetIndex], + getWindowPercent(position) == DEFAULT_PRESETS[presetIndex], + enabled, + pressedPosition == index && pressedPreset == presetIndex); + } + } + + private void drawWindow( + Canvas canvas, + Position position, + RectF bounds, + int percent, + boolean enabled) { + float percentageWidth = Math.min( + CockpitTheme.dp(getContext(), 58f), bounds.width() * 0.27f); + tempRect.set( + bounds.left, + bounds.top, + bounds.right - percentageWidth, + bounds.bottom); + boolean front = position == Position.FRONT_LEFT || position == Position.FRONT_RIGHT; + boolean mirrored = position == Position.FRONT_RIGHT || position == Position.REAR_RIGHT; + createWindowPath(tempRect, front, mirrored); + + paint.setStyle(Paint.Style.FILL); + paint.setShader(new LinearGradient( + tempRect.left, + tempRect.top, + tempRect.right, + tempRect.bottom, + Color.rgb(39, 65, 78), + Color.rgb(11, 24, 33), + Shader.TileMode.CLAMP)); + paint.setAlpha(enabled ? 255 : 112); + canvas.drawPath(path, paint); + paint.setShader(null); + paint.setAlpha(255); + + if (percent > 0) { + int save = canvas.save(); + canvas.clipPath(path); + float fillTop = tempRect.bottom - tempRect.height() * percent / 100f; + paint.setShader(new LinearGradient( + tempRect.left, + fillTop, + tempRect.right, + tempRect.bottom, + CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, enabled ? 0.68f : 0.22f), + CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, enabled ? 0.18f : 0.08f), + Shader.TileMode.CLAMP)); + canvas.drawRect(tempRect.left, fillTop, tempRect.right, tempRect.bottom, paint); + paint.setShader(null); + canvas.restoreToCount(save); + } + + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), 1.2f)); + strokePaint.setColor(enabled + ? (percent > 0 ? CockpitTheme.COLOR_PRIMARY + : CockpitTheme.COLOR_TEXT_SECONDARY) + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.34f)); + canvas.drawPath(path, strokePaint); + + paint.setStyle(Paint.Style.FILL); + paint.setTypeface(CockpitTheme.TYPEFACE_LIGHT); + paint.setTextAlign(Paint.Align.RIGHT); + paint.setTextSize(CockpitTheme.sp(getContext(), 25f)); + paint.setColor(enabled + ? CockpitTheme.COLOR_PRIMARY + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.42f)); + Paint.FontMetrics valueMetrics = paint.getFontMetrics(); + float baseline = bounds.centerY() - (valueMetrics.ascent + valueMetrics.descent) * 0.5f; + canvas.drawText(percent + "%", bounds.right, baseline, paint); + } + + private void createWindowPath(RectF bounds, boolean front, boolean mirrored) { + path.reset(); + float width = bounds.width(); + float height = bounds.height(); + if (front ^ mirrored) { + path.moveTo(bounds.left, bounds.bottom); + path.lineTo(bounds.left + width * 0.08f, bounds.top + height * 0.58f); + path.quadTo( + bounds.left + width * 0.30f, + bounds.top + height * 0.10f, + bounds.left + width * 0.72f, + bounds.top); + path.lineTo(bounds.right, bounds.top); + path.lineTo(bounds.right, bounds.bottom); + } else { + path.moveTo(bounds.left, bounds.bottom); + path.lineTo(bounds.left, bounds.top); + path.lineTo(bounds.left + width * 0.28f, bounds.top); + path.quadTo( + bounds.left + width * 0.70f, + bounds.top + height * 0.10f, + bounds.right - width * 0.08f, + bounds.top + height * 0.58f); + path.lineTo(bounds.right, bounds.bottom); + } + path.close(); + } + + private void drawPresetButton( + Canvas canvas, + RectF bounds, + int percent, + boolean selected, + boolean enabled, + boolean pressed) { + float radius = CockpitTheme.dp(getContext(), 12f); + int fill; + int stroke; + if (!enabled) { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_SURFACE, 0.46f); + stroke = CockpitTheme.withAlpha(CockpitTheme.COLOR_STROKE, 0.40f); + } else if (pressed) { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.24f); + stroke = CockpitTheme.COLOR_PRIMARY; + } else if (selected) { + fill = CockpitTheme.withAlpha(CockpitTheme.COLOR_PRIMARY, 0.13f); + stroke = CockpitTheme.COLOR_PRIMARY; + } else { + fill = CockpitTheme.COLOR_SURFACE_RAISED; + stroke = CockpitTheme.COLOR_STROKE; + } + paint.setStyle(Paint.Style.FILL); + paint.setColor(fill); + canvas.drawRoundRect(bounds, radius, radius, paint); + strokePaint.setStrokeWidth(CockpitTheme.dp(getContext(), selected ? 1.25f : 1f)); + strokePaint.setColor(stroke); + canvas.drawRoundRect(bounds, radius, radius, strokePaint); + + String topLabel; + String bottomLabel; + if (percent == 10) { + topLabel = "通风"; + bottomLabel = "10%"; + } else if (percent == 50) { + topLabel = "半开"; + bottomLabel = "50%"; + } else { + topLabel = "全部"; + bottomLabel = "打开"; + } + paint.setStyle(Paint.Style.FILL); + paint.setTypeface(CockpitTheme.TYPEFACE_REGULAR); + paint.setTextAlign(Paint.Align.CENTER); + paint.setTextSize(CockpitTheme.sp(getContext(), + bounds.width() < CockpitTheme.dp(getContext(), 70f) ? 12f : 14f)); + paint.setColor(enabled + ? (selected ? CockpitTheme.COLOR_PRIMARY : CockpitTheme.COLOR_TEXT_PRIMARY) + : CockpitTheme.withAlpha(CockpitTheme.COLOR_TEXT_SECONDARY, 0.38f)); + Paint.FontMetrics metrics = paint.getFontMetrics(); + float lineHeight = metrics.descent - metrics.ascent; + float firstBaseline = bounds.centerY() - lineHeight * 0.5f - metrics.ascent; + canvas.drawText(topLabel, bounds.centerX(), firstBaseline, paint); + canvas.drawText(bottomLabel, bounds.centerX(), firstBaseline + lineHeight * 0.88f, paint); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (!isEnabled() || controlsLocked) { + return super.onTouchEvent(event); + } + int[] hit = findPreset(event.getX(), event.getY()); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + if (hit == null || !isWindowEnabled(Position.values()[hit[0]])) { + return false; + } + pressedPosition = hit[0]; + pressedPreset = hit[1]; + setPressed(true); + invalidate(); + return true; + case MotionEvent.ACTION_MOVE: + if (hit == null || hit[0] != pressedPosition || hit[1] != pressedPreset) { + setPressed(false); + } + return true; + case MotionEvent.ACTION_CANCEL: + clearPressed(); + return true; + case MotionEvent.ACTION_UP: + int originalPosition = pressedPosition; + int originalPreset = pressedPreset; + clearPressed(); + if (hit != null && hit[0] == originalPosition && hit[1] == originalPreset) { + Position position = Position.values()[originalPosition]; + int percent = DEFAULT_PRESETS[originalPreset]; + setWindowPercent(position, percent); + performClick(); + performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + if (changeListener != null) { + changeListener.onWindowPercentRequested(position, percent); + } + } + return true; + default: + return true; + } + } + + @Override + public boolean performClick() { + super.performClick(); + return true; + } + + private int[] findPreset(float x, float y) { + for (int position = 0; position < presetHitRects.length; position++) { + for (int preset = 0; preset < presetHitRects[position].length; preset++) { + if (presetHitRects[position][preset].contains(x, y)) { + return new int[]{position, preset}; + } + } + } + return null; + } + + private void clearPressed() { + pressedPosition = -1; + pressedPreset = -1; + setPressed(false); + invalidate(); + } + + private void updateContentDescription() { + StringBuilder builder = new StringBuilder("车窗控制"); + for (Position position : Position.values()) { + builder.append(',') + .append(positionLabels.get(position)) + .append(' ') + .append(String.format(Locale.getDefault(), "%d%%", getWindowPercent(position))); + } + if (controlsLocked) { + builder.append(",控制已锁止"); + } + setContentDescription(builder.toString()); + } +} diff --git a/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ZlgUsbCanDriver.java b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ZlgUsbCanDriver.java new file mode 100644 index 0000000..7bb358c --- /dev/null +++ b/N7Cockpit/app/src/main/java/com/seeker/n7cockpit/ZlgUsbCanDriver.java @@ -0,0 +1,349 @@ +package com.seeker.n7cockpit; + +import android.hardware.usb.UsbDevice; +import android.hardware.usb.UsbDeviceConnection; +import android.hardware.usb.UsbEndpoint; +import android.hardware.usb.UsbInterface; +import android.hardware.usb.UsbManager; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.Locale; + +/** Fixed-function USB driver for one verified USBCANFD-200U air-conditioning test frame. */ +final class ZlgUsbCanDriver { + private static final int TIMEOUT_MS = 1_500; + private static final int RX_TIMEOUT_MS = 350; + + private static final byte[] INIT_CAN = hex( + "55 11 00 04 14 00 00 00 00 87 93 03 00 00 00 00 " + + "0E 03 02 00 05 00 0A 02 02 00 01 00 94 B5"); + private static final byte[] FILTER = hex( + "55 14 00 05 0C 00 00 00 00 00 00 00 00 00 00 00 FF 07 00 00 86 60"); + private static final byte[] TERMINATION_OFF = hex( + "55 18 00 06 01 00 00 00 AA 74 D0"); + private static final byte[] START_CAN = hex( + "55 12 00 07 00 00 00 00 02 C9"); + private static final byte[] RESET_CAN = hex( + "55 13 00 04 00 00 00 00 00 00"); + private static final byte[] AC_POWER_ON = hex( + "55 F1 00 08 1C 00 00 00 01 00 00 00 00 00 00 00 " + + "DE 01 00 00 00 00 00 00 00 00 00 08 " + + "00 00 00 10 00 00 00 00 58 4D"); + private static final byte[] AC_POWER_OFF = hex( + "55 F1 00 08 1C 00 00 00 01 00 00 00 00 00 00 00 " + + "DE 01 00 00 00 00 00 00 00 00 00 08 " + + "00 00 00 20 00 00 00 00 00 00"); + + static final class Result { + final boolean success; + final String detail; + + Result(boolean success, String detail) { + this.success = success; + this.detail = detail; + } + } + + static Result sendAcPowerOn(UsbManager manager, UsbDevice device) { + return sendAcPower(manager, device, true); + } + + static Result sendAcPower(UsbManager manager, UsbDevice device, boolean enabled) { + if (device == null || device.getInterfaceCount() < 1) { + return fail("CAN 盒接口不存在"); + } + UsbInterface usbInterface = device.getInterface(0); + UsbEndpoint commandOut = endpoint(usbInterface, 0x01); + UsbEndpoint commandAck = endpoint(usbInterface, 0x81); + UsbEndpoint canOut = endpoint(usbInterface, 0x02); + UsbEndpoint txAck = endpoint(usbInterface, 0x82); + UsbEndpoint canIn = endpoint(usbInterface, 0x83); + if (commandOut == null || commandAck == null || canOut == null + || txAck == null || canIn == null) { + return fail("CAN 盒端点布局不符合 USBCANFD-200U 协议"); + } + + UsbDeviceConnection connection = manager.openDevice(device); + if (connection == null) return fail("无法打开 CAN 盒"); + boolean claimed = false; + boolean canStarted = false; + StringBuilder log = new StringBuilder(); + try { + claimed = connection.claimInterface(usbInterface, true); + if (!claimed) return fail("无法独占 CAN 盒接口"); + + byte[] version = new byte[2]; + int versionRead = connection.controlTransfer( + 0xA1, 0x00, 0, 0, version, version.length, TIMEOUT_MS); + if (versionRead != 2) { + return fail("读取 USB 协议版本失败,返回 " + versionRead); + } + log.append("USB 协议版本:0x").append(hexWordLe(version)).append('\n'); + + int staleAckBytes = drainEndpoint(connection, commandAck); + if (staleAckBytes > 0) { + log.append("✓ 已清理上次操作残留回执:") + .append(staleAckBytes).append(" 字节\n"); + } + + String error; + error = command(connection, commandOut, commandAck, + withSequence(RESET_CAN, 4), "预复位 CAN", true); + if (error != null) return failWithLog(error, log); + log.append("✓ CAN0 已预复位(已启动/已停止状态均可重复执行)\n"); + + error = command(connection, commandOut, commandAck, + withSequence(INIT_CAN, 5), "初始化 CAN", false); + if (error != null) return failWithLog(error, log); + log.append("✓ CAN0 初始化:500 kbps / 2 Mbps,ISO CAN FD normal\n"); + + error = command(connection, commandOut, commandAck, + withSequence(FILTER, 6), "设置过滤器", false); + if (error != null) return failWithLog(error, log); + log.append("✓ 接收过滤器已设置\n"); + + error = command(connection, commandOut, commandAck, + withSequence(TERMINATION_OFF, 7), "关闭内部终端电阻", false); + if (error != null) return failWithLog(error, log); + log.append("✓ CAN 盒内部 120Ω 终端:关闭\n"); + + error = command(connection, commandOut, commandAck, + withSequence(START_CAN, 8), "启动 CAN", false); + if (error != null) return failWithLog(error, log); + canStarted = true; + log.append("✓ CAN0 已启动\n"); + + byte[] acPower = withSequence(enabled ? AC_POWER_ON : AC_POWER_OFF, 9); + for (int i = 1; i <= 3; i++) { + int written = connection.bulkTransfer( + canOut, acPower, acPower.length, TIMEOUT_MS); + if (written != acPower.length) { + return failWithLog("第 " + i + " 帧 USB 写入失败,写入 " + written + + "/" + acPower.length + " 字节", log); + } + + byte[] ack = new byte[128]; + int ackLength = connection.bulkTransfer(txAck, ack, ack.length, TIMEOUT_MS); + if (ackLength <= 0) { + return failWithLog("第 " + i + " 帧未收到 CAN 盒 TX 回执(帧已写入盒子)", log); + } + error = validateTxAck(acPower, ack, ackLength, i); + if (error != null) return failWithLog(error, log); + log.append("✓ 第 ").append(i).append(" 帧 TX 回执:") + .append(toHex(ack, Math.min(ackLength, 32))); + if (ackLength > 32) log.append(" …"); + log.append('\n'); + if (i < 3) Thread.sleep(100L); + } + + byte[] receive = new byte[1024]; + int received = connection.bulkTransfer( + canIn, receive, receive.length, RX_TIMEOUT_MS); + if (received > 0) { + boolean contains404 = containsCanId404(receive, received); + log.append("CAN 接收:").append(received).append(" 字节") + .append(contains404 ? ",发现 0x404 候选反馈" : ",未识别到 0x404") + .append("\n原始数据:") + .append(toHex(receive, Math.min(received, 96))); + if (received > 96) log.append(" …"); + log.append('\n'); + } else { + log.append("CAN 接收:350 ms 窗口内无数据(不影响 TX 回执判定)\n"); + } + + error = command(connection, commandOut, commandAck, + withSequence(RESET_CAN, 10), "发送后复位 CAN", false); + if (error != null) { + return failWithLog("三帧已发送,但" + error, log); + } + canStarted = false; + log.append("✓ CAN0 已复位,可再次执行测试\n"); + + log.append("实际发送完成:空调").append(enabled ? "开" : "关") + .append(",标准 ID 0x1DE,DLC 8,") + .append(enabled + ? "00 00 00 10 00 00 00 00" + : "00 00 00 20 00 00 00 00") + .append(",共 3 帧"); + return new Result(true, log.toString()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return failWithLog("发送线程被中断", log); + } catch (RuntimeException exception) { + return failWithLog("USB 驱动异常:" + exception.getMessage(), log); + } finally { + // Once START_CAN succeeds, every exit path must leave the adapter in a stopped + // state. This cleanup is deliberately best-effort so it never masks the original + // TX/ack/interruption result returned to the caller. + if (canStarted && claimed) { + try { + command(connection, commandOut, commandAck, + withSequence(RESET_CAN, 11), "异常路径复位 CAN", true); + } catch (RuntimeException ignored) { + // The USB device may already be detached; release/close still follows. + } + } + if (claimed) connection.releaseInterface(usbInterface); + connection.close(); + } + } + + private static String command(UsbDeviceConnection connection, UsbEndpoint out, + UsbEndpoint in, byte[] packet, String label, boolean allowAlreadyStopped) { + byte[] header = Arrays.copyOfRange(packet, 0, 8); + byte[] bodyAndCrc = Arrays.copyOfRange(packet, 8, packet.length); + int headerWritten = connection.bulkTransfer(out, header, header.length, TIMEOUT_MS); + if (headerWritten != header.length) { + return label + "命令头写入失败:" + headerWritten + "/" + header.length; + } + int bodyWritten = connection.bulkTransfer( + out, bodyAndCrc, bodyAndCrc.length, TIMEOUT_MS); + if (bodyWritten != bodyAndCrc.length) { + return label + "命令体写入失败:" + bodyWritten + "/" + bodyAndCrc.length; + } + + byte[] ackHeader = new byte[12]; + int headerRead = connection.bulkTransfer(in, ackHeader, ackHeader.length, TIMEOUT_MS); + if (headerRead != ackHeader.length) { + return label + "回执头读取失败:" + headerRead + "/" + ackHeader.length; + } + if (ackHeader[0] != header[0] || ackHeader[1] != header[1] + || ackHeader[2] != header[2] || ackHeader[3] != header[3]) { + return label + "回执 ID/索引不匹配:" + toHex(ackHeader, ackHeader.length); + } + long responseLengthLong = uint32Le(ackHeader, 8); + if (responseLengthLong > 4096) return label + "回执长度异常:" + responseLengthLong; + int responseLength = (int) responseLengthLong; + byte[] ackBody = new byte[responseLength + 2]; + int bodyRead = connection.bulkTransfer(in, ackBody, ackBody.length, TIMEOUT_MS); + if (bodyRead != ackBody.length) { + return label + "回执体读取失败:" + bodyRead + "/" + ackBody.length; + } + + ByteArrayOutputStream crcInput = new ByteArrayOutputStream(); + crcInput.write(ackHeader, 0, ackHeader.length); + crcInput.write(ackBody, 0, responseLength); + int expectedCrc = crc16Modbus(crcInput.toByteArray(), 0, + ackHeader.length + responseLength); + int actualCrc = (ackBody[responseLength] & 0xFF) + | ((ackBody[responseLength + 1] & 0xFF) << 8); + if (expectedCrc != actualCrc) { + return label + "回执 CRC 错误:期望 " + String.format(Locale.US, "%04X", expectedCrc) + + ",收到 " + String.format(Locale.US, "%04X", actualCrc); + } + long deviceError = uint32Le(ackHeader, 4); + if (deviceError != 0 && !(allowAlreadyStopped && deviceError == 3)) { + return label + "被 CAN 盒拒绝,错误码 0x" + + String.format(Locale.US, "%08X", deviceError); + } + return null; + } + + private static int drainEndpoint(UsbDeviceConnection connection, UsbEndpoint endpoint) { + int total = 0; + byte[] buffer = new byte[1024]; + for (int i = 0; i < 8; i++) { + int read = connection.bulkTransfer(endpoint, buffer, buffer.length, 30); + if (read <= 0) break; + total += read; + } + return total; + } + + private static String validateTxAck(byte[] tx, byte[] ack, int length, int frameNumber) { + if (length != 8) return "第 " + frameNumber + " 帧 TX 回执长度异常:" + length; + for (int i = 0; i < 4; i++) { + if (ack[i] != tx[i]) { + return "第 " + frameNumber + " 帧 TX 回执 ID/索引不匹配:" + + toHex(ack, length); + } + } + int expectedCrc = crc16Modbus(ack, 0, 6); + int actualCrc = (ack[6] & 0xFF) | ((ack[7] & 0xFF) << 8); + if (expectedCrc != actualCrc) { + return "第 " + frameNumber + " 帧 TX 回执 CRC 错误"; + } + if (ack[4] != 0 || (ack[5] & 0xFF) != 1) { + return "第 " + frameNumber + " 帧未被 CAN 盒确认发送:" + toHex(ack, length); + } + return null; + } + + private static byte[] withSequence(byte[] template, int sequence) { + byte[] packet = Arrays.copyOf(template, template.length); + packet[3] = (byte) sequence; + int crc = crc16Modbus(packet, 0, packet.length - 2); + packet[packet.length - 2] = (byte) (crc & 0xFF); + packet[packet.length - 1] = (byte) (crc >>> 8); + return packet; + } + + private static UsbEndpoint endpoint(UsbInterface usbInterface, int address) { + for (int i = 0; i < usbInterface.getEndpointCount(); i++) { + UsbEndpoint endpoint = usbInterface.getEndpoint(i); + if (endpoint.getAddress() == address) return endpoint; + } + return null; + } + + private static boolean containsCanId404(byte[] bytes, int length) { + for (int i = 0; i + 3 < length; i++) { + if ((bytes[i] & 0xFF) == 0x04 && (bytes[i + 1] & 0xFF) == 0x04 + && bytes[i + 2] == 0 && bytes[i + 3] == 0) return true; + } + return false; + } + + private static long uint32Le(byte[] bytes, int offset) { + return (bytes[offset] & 0xFFL) + | ((bytes[offset + 1] & 0xFFL) << 8) + | ((bytes[offset + 2] & 0xFFL) << 16) + | ((bytes[offset + 3] & 0xFFL) << 24); + } + + private static int crc16Modbus(byte[] bytes, int offset, int length) { + int crc = 0xFFFF; + for (int i = offset; i < offset + length; i++) { + crc ^= bytes[i] & 0xFF; + for (int bit = 0; bit < 8; bit++) { + crc = (crc & 1) != 0 ? (crc >>> 1) ^ 0xA001 : crc >>> 1; + } + } + return crc & 0xFFFF; + } + + private static String hexWordLe(byte[] bytes) { + int value = (bytes[0] & 0xFF) | ((bytes[1] & 0xFF) << 8); + return String.format(Locale.US, "%04X", value); + } + + private static byte[] hex(String text) { + String[] parts = text.trim().split("\\s+"); + byte[] result = new byte[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = (byte) Integer.parseInt(parts[i], 16); + } + return result; + } + + private static String toHex(byte[] bytes, int length) { + StringBuilder text = new StringBuilder(length * 3); + for (int i = 0; i < length; i++) { + if (i > 0) text.append(' '); + text.append(String.format(Locale.US, "%02X", bytes[i] & 0xFF)); + } + return text.toString(); + } + + private static Result fail(String detail) { + return new Result(false, "CAN 实发已停止:" + detail); + } + + private static Result failWithLog(String detail, StringBuilder log) { + return new Result(false, log + "CAN 实发已停止:" + detail); + } + + private ZlgUsbCanDriver() {} +} + diff --git a/N7Cockpit/app/src/main/res/drawable/ic_launcher.xml b/N7Cockpit/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..bd69d06 --- /dev/null +++ b/N7Cockpit/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/N7Cockpit/app/src/main/res/drawable/ic_notification_music.xml b/N7Cockpit/app/src/main/res/drawable/ic_notification_music.xml new file mode 100644 index 0000000..0695a70 --- /dev/null +++ b/N7Cockpit/app/src/main/res/drawable/ic_notification_music.xml @@ -0,0 +1,10 @@ + + + + diff --git a/N7Cockpit/app/src/main/res/values/colors.xml b/N7Cockpit/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..83893cc --- /dev/null +++ b/N7Cockpit/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ + + + #071017 + #0E1B25 + #152733 + #5EE6D5 + #F3F7F8 + #9BB0B8 + diff --git a/N7Cockpit/app/src/main/res/values/strings.xml b/N7Cockpit/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..db76ac4 --- /dev/null +++ b/N7Cockpit/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + N7 智舱 + 桌宠悬浮服务 + 派蒙正在陪伴 + 点击返回智舱,桌宠可拖动和缩放 + 车辆控制恢复 + diff --git a/N7Cockpit/app/src/main/res/values/styles.xml b/N7Cockpit/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..8b6db78 --- /dev/null +++ b/N7Cockpit/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/CockpitLayoutProfileTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/CockpitLayoutProfileTest.java new file mode 100644 index 0000000..e66875f --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/CockpitLayoutProfileTest.java @@ -0,0 +1,48 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class CockpitLayoutProfileTest { + @Test + public void referenceResolutionIsNotDoubledByAndroidDensity() { + CockpitLayoutProfile profile = + CockpitLayoutProfile.forDisplay(1280, 720, 2f, 1f); + + assertEquals(CockpitLayoutProfile.Tier.STANDARD, profile.tier); + assertEquals(1f, profile.geometryScale, 0.0001f); + assertEquals(96, profile.px(96f)); + assertEquals(259, profile.inAppMascotMaximumPx()); + } + + @Test + public void compactAndWideDisplaysChooseProportionalTiers() { + CockpitLayoutProfile compact = + CockpitLayoutProfile.forDisplay(1024, 600, 2f, 1f); + CockpitLayoutProfile wide = + CockpitLayoutProfile.forDisplay(1920, 1080, 2f, 1f); + + assertEquals(CockpitLayoutProfile.Tier.COMPACT, compact.tier); + assertEquals(0.8f, compact.geometryScale, 0.0001f); + assertEquals(CockpitLayoutProfile.Tier.WIDE, wide.tier); + assertEquals(1.5f, wide.geometryScale, 0.0001f); + assertTrue(wide.inAppMascotMaximumPx() > compact.inAppMascotMaximumPx()); + assertTrue(wide.inAppMascotMaximumPx() <= Math.round(1080f * 0.30f)); + } + + @Test + public void densityDoesNotChangeGeometryButFontScaleStillChangesText() { + CockpitLayoutProfile densityOne = + CockpitLayoutProfile.forDisplay(1280, 720, 1f, 1f); + CockpitLayoutProfile densityThree = + CockpitLayoutProfile.forDisplay(1280, 720, 3f, 1f); + CockpitLayoutProfile largeText = + CockpitLayoutProfile.forDisplay(1280, 720, 3f, 1.2f); + + assertEquals(densityOne.px(56f), densityThree.px(56f)); + assertEquals(densityOne.textPx(17f), densityThree.textPx(17f), 0.0001f); + assertTrue(largeText.textPx(17f) > densityThree.textPx(17f)); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotActionTimingTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotActionTimingTest.java new file mode 100644 index 0000000..1f390ac --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotActionTimingTest.java @@ -0,0 +1,40 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class MascotActionTimingTest { + @Test + public void lowFrequencyOperationsReceiveLongerPhrases() { + long climate = MascotActionTiming.minimumOperationMs(MascotActionBus.Action.CLIMATE); + long window = MascotActionTiming.minimumOperationMs(MascotActionBus.Action.WINDOW); + long sunshade = MascotActionTiming.minimumOperationMs(MascotActionBus.Action.SUNSHADE); + long device = MascotActionTiming.minimumOperationMs(MascotActionBus.Action.DEVICE); + + assertTrue(window > climate); + assertTrue(sunshade > window); + assertTrue(device > sunshade); + } + + @Test + public void frequentlyRepeatedHandTouchStaysCompact() { + assertEquals(2_400L, MascotActionTiming.directTouchMs( + MascotInteractionMapper.TapRegion.LEFT_HAND)); + assertTrue(MascotActionTiming.directTouchMs( + MascotInteractionMapper.TapRegion.HEAD) > 2_400L); + } + + @Test + public void manualDemoOutlastsRealClimateFeedback() { + assertTrue(MascotActionTiming.demoMs(MascotActionBus.Action.CLIMATE) + > MascotActionTiming.minimumOperationMs(MascotActionBus.Action.CLIMATE)); + } + + @Test + public void errorAcknowledgementGetsMoreReadingTimeThanSuccess() { + assertTrue(MascotActionTiming.resultMs(MascotActionBus.Action.ERROR) + > MascotActionTiming.resultMs(MascotActionBus.Action.SUCCESS)); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotClipLibraryTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotClipLibraryTest.java new file mode 100644 index 0000000..b562c2b --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotClipLibraryTest.java @@ -0,0 +1,86 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public final class MascotClipLibraryTest { + private static final short UNIT_45 = 23170; + + @Test + public void parserReordersClipRotationsIntoModelSkinOrder() throws Exception { + byte[] bytes = clipFile(new String[]{"joint_a", "joint_b"}); + + MascotClipLibrary library = MascotClipLibrary.parse( + bytes, new String[]{"joint_b", "joint_a"}); + MascotClipLibrary.Clip clip = library.get("dance"); + MascotPose pose = new MascotPose(2); + float[] root = new float[3]; + + assertTrue(clip.sample(0f, pose, root)); + // joint_b is a 90-degree Z rotation and must land in model slot zero. + assertEquals(0f, pose.rot[0], 1e-4f); + assertEquals(0f, pose.rot[1], 1e-4f); + assertEquals((float) Math.sqrt(0.5), pose.rot[2], 1e-3f); + assertEquals((float) Math.sqrt(0.5), pose.rot[3], 1e-3f); + // joint_a is identity and must land in model slot one. + assertEquals(0f, pose.rot[4], 1e-4f); + assertEquals(0f, pose.rot[5], 1e-4f); + assertEquals(0f, pose.rot[6], 1e-4f); + assertEquals(1f, pose.rot[7], 1e-4f); + assertEquals(0.25f, root[1], 1e-3f); + } + + @Test(expected = IOException.class) + public void parserRejectsRigWithDifferentJointContract() throws Exception { + MascotClipLibrary.parse( + clipFile(new String[]{"joint_a", "joint_b"}), + new String[]{"joint_a"}); + } + + private static byte[] clipFile(String[] jointNames) { + ByteBuffer buffer = ByteBuffer.allocate(512).order(ByteOrder.LITTLE_ENDIAN); + buffer.putInt(0x31434d50); // PMC1 + buffer.putShort((short) 1); + buffer.putShort((short) jointNames.length); + buffer.putShort((short) 1); + buffer.putShort((short) 0); + for (String name : jointNames) putString(buffer, name); + + putString(buffer, "dance"); + buffer.putShort((short) 24); // fps + buffer.putShort((short) 1); // frames + buffer.put((byte) 0); // non-looping, fingers absent + buffer.put((byte) 0); + buffer.putFloat(0.5f); // root scale + + // joint_a identity, joint_b = 90 degrees around Z. + putQuaternion(buffer, (short) 0, (short) 0, (short) 0, (short) 32767); + putQuaternion(buffer, (short) 0, (short) 0, UNIT_45, UNIT_45); + buffer.putShort((short) 0); + buffer.putShort((short) 16384); // 0.5 * rootScale = 0.25 model heights + buffer.putShort((short) 0); + return Arrays.copyOf(buffer.array(), buffer.position()); + } + + private static void putString(ByteBuffer buffer, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + buffer.put((byte) encoded.length); + buffer.put(encoded); + } + + private static void putQuaternion( + ByteBuffer buffer, short x, short y, short z, short w) { + buffer.putShort(x); + buffer.putShort(y); + buffer.putShort(z); + buffer.putShort(w); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotDockPlannerTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotDockPlannerTest.java new file mode 100644 index 0000000..aef30a9 --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotDockPlannerTest.java @@ -0,0 +1,83 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +public final class MascotDockPlannerTest { + @Test + public void keepsRequestedPositionWhenNothingIsInteractive() { + MascotDockPlanner.Point point = MascotDockPlanner.findSafest( + 0f, 0f, 800f, 400f, + 200f, 200f, + 620f, 140f, + Collections.emptyList(), + 8f); + + assertEquals(620f, point.x, 0.001f); + assertEquals(140f, point.y, 0.001f); + } + + @Test + public void movesCircularHitTargetAwayFromCriticalButton() { + MascotDockPlanner.Exclusion button = new MascotDockPlanner.Exclusion( + 720f, 120f, 990f, 210f, MascotDockPlanner.PRIORITY_CRITICAL); + + MascotDockPlanner.Point point = MascotDockPlanner.findSafest( + 0f, 0f, 824f, 424f, + 200f, 200f, + 790f, 120f, + Collections.singletonList(button), + 8f); + + float centerX = point.x + 100f; + float centerY = point.y + 100f; + float nearestX = clamp(centerX, 712f, 998f); + float nearestY = clamp(centerY, 112f, 218f); + float dx = centerX - nearestX; + float dy = centerY - nearestY; + float radius = 100f * MascotDockPlanner.HIT_DIAMETER_FRACTION; + assertTrue(dx * dx + dy * dy >= radius * radius - 0.01f); + } + + @Test + public void commandSafetyOutranksDecorativeVisualOverlap() { + MascotDockPlanner.Exclusion criticalButton = new MascotDockPlanner.Exclusion( + 0f, 80f, 200f, 330f, MascotDockPlanner.PRIORITY_CRITICAL); + MascotDockPlanner.Exclusion visualCard = new MascotDockPlanner.Exclusion( + 0f, 340f, 200f, 590f, MascotDockPlanner.PRIORITY_VISUAL); + + MascotDockPlanner.Point point = MascotDockPlanner.findSafest( + 0f, 0f, 0f, 400f, + 200f, 200f, + 0f, 130f, + Arrays.asList(criticalButton, visualCard), + 8f); + + // The lower visual is allowed behind the mascot so the upper command block stays clear. + assertTrue(point.y >= 300f); + } + + @Test + public void alwaysReturnsFiniteClampedCoordinates() { + MascotDockPlanner.Point point = MascotDockPlanner.findSafest( + 12f, 18f, 300f, 220f, + Float.NaN, -1f, + Float.POSITIVE_INFINITY, Float.NaN, + Collections.emptyList(), + Float.NaN); + + assertTrue(Float.isFinite(point.x)); + assertTrue(Float.isFinite(point.y)); + assertTrue(point.x >= 12f && point.x <= 300f); + assertTrue(point.y >= 18f && point.y <= 220f); + } + + private static float clamp(float value, float minimum, float maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotInteractionMapperTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotInteractionMapperTest.java new file mode 100644 index 0000000..7313b6e --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MascotInteractionMapperTest.java @@ -0,0 +1,83 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class MascotInteractionMapperTest { + @Test + public void tapMapSeparatesHeadBodyHandsAndFeet() { + assertEquals(MascotInteractionMapper.TapRegion.HEAD, + MascotInteractionMapper.mapTap(50f, 18f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.BODY, + MascotInteractionMapper.mapTap(50f, 52f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.LEFT_HAND, + MascotInteractionMapper.mapTap(20f, 52f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.RIGHT_HAND, + MascotInteractionMapper.mapTap(80f, 52f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.FEET, + MascotInteractionMapper.mapTap(50f, 86f, 100f, 100f)); + } + + @Test + public void tapMapClampsOutOfBoundsCoordinates() { + assertEquals(MascotInteractionMapper.TapRegion.HEAD, + MascotInteractionMapper.mapTap(50f, -400f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.FEET, + MascotInteractionMapper.mapTap(50f, 400f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.LEFT_HAND, + MascotInteractionMapper.mapTap(-400f, 50f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.RIGHT_HAND, + MascotInteractionMapper.mapTap(400f, 50f, 100f, 100f)); + assertEquals(MascotInteractionMapper.TapRegion.BODY, + MascotInteractionMapper.mapTap(10f, 10f, 0f, 100f)); + } + + @Test + public void dragMapRecognizesFourDirections() { + assertEquals(MascotInteractionMapper.DragDirection.LEFT, + MascotInteractionMapper.resolveDrag(-60f, 4f, null)); + assertEquals(MascotInteractionMapper.DragDirection.RIGHT, + MascotInteractionMapper.resolveDrag(60f, -4f, null)); + assertEquals(MascotInteractionMapper.DragDirection.UP, + MascotInteractionMapper.resolveDrag(4f, -60f, null)); + assertEquals(MascotInteractionMapper.DragDirection.DOWN, + MascotInteractionMapper.resolveDrag(-4f, 60f, null)); + } + + @Test + public void establishedDirectionUsesDiagonalHysteresis() { + assertEquals(MascotInteractionMapper.DragDirection.LEFT, + MascotInteractionMapper.resolveDrag( + -50f, 58f, MascotInteractionMapper.DragDirection.LEFT)); + assertEquals(MascotInteractionMapper.DragDirection.DOWN, + MascotInteractionMapper.resolveDrag( + -40f, 70f, MascotInteractionMapper.DragDirection.LEFT)); + assertEquals(MascotInteractionMapper.DragDirection.RIGHT, + MascotInteractionMapper.resolveDrag( + 60f, 4f, MascotInteractionMapper.DragDirection.LEFT)); + } + + @Test + public void zeroOrInvalidMotionDoesNotChangeDirection() { + assertEquals(MascotInteractionMapper.DragDirection.NONE, + MascotInteractionMapper.resolveDrag( + 0f, 0f, MascotInteractionMapper.DragDirection.NONE)); + assertEquals(MascotInteractionMapper.DragDirection.UP, + MascotInteractionMapper.resolveDrag( + 0f, 0f, MascotInteractionMapper.DragDirection.UP)); + assertEquals(MascotInteractionMapper.DragDirection.UP, + MascotInteractionMapper.resolveDrag( + Float.NaN, 20f, MascotInteractionMapper.DragDirection.UP)); + } + + @Test + public void semanticActionsMatchResolvedRegionsAndDirections() { + assertEquals(MascotActionBus.Action.TAP_LEFT_HAND, + MascotInteractionMapper.actionForTap( + MascotInteractionMapper.TapRegion.LEFT_HAND)); + assertEquals(MascotActionBus.Action.DRAG_DOWN, + MascotInteractionMapper.actionForDrag( + MascotInteractionMapper.DragDirection.DOWN)); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockUsbCanGatewayTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockUsbCanGatewayTest.java new file mode 100644 index 0000000..564723b --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockUsbCanGatewayTest.java @@ -0,0 +1,33 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Test; + +public class MockUsbCanGatewayTest { + @Test + public void selfTestAndAcSendProduceVisibleVirtualCanState() throws Exception { + CountDownLatch sent = new CountDownLatch(1); + AtomicReference latest = new AtomicReference<>(""); + MockUsbCanGateway gateway = new MockUsbCanGateway(detail -> { + latest.set(detail); + if (detail.contains("3 帧")) sent.countDown(); + }); + try { + gateway.selfTest(); + gateway.sendAc(true); + assertTrue(sent.await(2, TimeUnit.SECONDS)); + assertTrue(gateway.isSimulation()); + assertTrue(gateway.describe().contains("纯模拟")); + assertTrue(gateway.describe().contains("虚拟发送:3 帧")); + assertTrue(gateway.describe().contains("空调:开")); + assertTrue(latest.get().contains("未触达真实总线")); + } finally { + gateway.close(); + } + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockVehicleGatewayTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockVehicleGatewayTest.java new file mode 100644 index 0000000..9a76fa5 --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/MockVehicleGatewayTest.java @@ -0,0 +1,56 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.Test; + +public class MockVehicleGatewayTest { + @Test + public void commandMutationsAreVisibleInNextSnapshot() throws Exception { + MockVehicleGateway gateway = new MockVehicleGateway(); + try { + awaitCommand(gateway, VehicleCommands.climatePower(true)); + awaitCommand(gateway, VehicleCommands.climateTemperature(21)); + awaitCommand(gateway, VehicleCommands.allWindows(50)); + awaitCommand(gateway, VehicleCommands.sunshade(true)); + awaitCommand(gateway, VehicleCommands.light(VehicleCommands.Light.DRL, false)); + + CountDownLatch read = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + gateway.readSnapshot((success, snapshot, detail) -> { + if (success) result.set(snapshot); + read.countDown(); + }); + + assertTrue(read.await(2, TimeUnit.SECONDS)); + VehicleSnapshot snapshot = result.get(); + assertNotNull(snapshot); + assertTrue(snapshot.isSimulation()); + assertTrue(snapshot.climatePowerOn()); + assertEquals(21f, snapshot.targetTemperatureC(), 0.001f); + assertEquals(50, snapshot.vehicleInt("wfl", -1)); + assertEquals(50, snapshot.vehicleInt("wrr", -1)); + assertEquals(100, snapshot.vehicleInt("sunshade", -1)); + assertEquals("2", snapshot.vehicleValue("drlight")); + } finally { + gateway.shutdown(); + } + } + + private static void awaitCommand(MockVehicleGateway gateway, String command) throws Exception { + CountDownLatch done = new CountDownLatch(1); + AtomicReference success = new AtomicReference<>(false); + gateway.sendCommand(command, (ok, detail) -> { + success.set(ok); + done.countDown(); + }); + assertTrue(done.await(2, TimeUnit.SECONDS)); + assertTrue(success.get()); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleCommandsTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleCommandsTest.java new file mode 100644 index 0000000..0054bba --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleCommandsTest.java @@ -0,0 +1,58 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public class VehicleCommandsTest { + @Test + public void buildersProduceOriginalAllowlistedWireCommands() { + assertEquals("AC 1", VehicleCommands.acPower(true)); + assertEquals("CLIMATE POWER 0", VehicleCommands.climatePower(false)); + assertEquals("CLIMATE AUTO 1", VehicleCommands.climateAuto(true)); + assertEquals("CLIMATE CYCLE INNER", VehicleCommands.climateInnerCycle(true)); + assertEquals("CLIMATE TEMP 22", VehicleCommands.climateTemperature(22)); + assertEquals("CLIMATE DEFROST_FRONT 1", VehicleCommands.frontDefrost(true)); + assertEquals("CLIMATE DEFROST_REAR 0", VehicleCommands.rearDefrost(false)); + assertEquals("WINDOWS 50", VehicleCommands.allWindows(50)); + assertEquals("WINDOW FL 100", + VehicleCommands.window(VehicleCommands.Window.FRONT_LEFT, true)); + assertEquals("SUNSHADE CLOSE", VehicleCommands.sunshade(false)); + assertEquals("LIGHT AMBIENT 1", + VehicleCommands.light(VehicleCommands.Light.AMBIENT, true)); + } + + @Test + public void simulatorParserAcceptsEveryOriginalCommandShape() { + String[] commands = { + "VEHICLE STATUS", "AC 0", "AC 1", "CLIMATE STATUS", + "CLIMATE POWER 0", "CLIMATE POWER 1", "CLIMATE AUTO 0", "CLIMATE AUTO 1", + "CLIMATE CYCLE INNER", "CLIMATE CYCLE OUTER", + "CLIMATE DEFROST_FRONT 0", "CLIMATE DEFROST_FRONT 1", + "CLIMATE DEFROST_REAR 0", "CLIMATE DEFROST_REAR 1", + "CLIMATE TEMP 16", "CLIMATE TEMP 30", + "WINDOWS 0", "WINDOWS 10", "WINDOWS 50", "WINDOWS 100", + "WINDOW FL 0", "WINDOW FR 100", "WINDOW RL 0", "WINDOW RR 100", + "SUNSHADE OPEN", "SUNSHADE CLOSE", + "LIGHT INTERIOR 0", "LIGHT AMBIENT 1", "LIGHT DRL 0" + }; + for (String command : commands) assertEquals(command, VehicleCommand.parse(command).wireCommand); + } + + @Test + public void commandValidationRejectsOutOfRangeAndInjection() { + assertThrows(IllegalArgumentException.class, + () -> VehicleCommands.climateTemperature(31)); + assertThrows(IllegalArgumentException.class, + () -> VehicleCommands.allWindows(25)); + assertThrows(IllegalArgumentException.class, + () -> VehicleCommand.parse("CLIMATE POWER EXTRA 1")); + assertThrows(IllegalArgumentException.class, + () -> VehicleCommand.parse("WINDOW FL 50")); + assertThrows(IllegalArgumentException.class, + () -> VehicleCommand.parse("LIGHT DRL 0\nAC 1")); + assertThrows(IllegalArgumentException.class, + () -> VehicleCommand.parse("shell id")); + } +} diff --git a/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleResponseParserTest.java b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleResponseParserTest.java new file mode 100644 index 0000000..d1fe5db --- /dev/null +++ b/N7Cockpit/app/src/test/java/com/seeker/n7cockpit/VehicleResponseParserTest.java @@ -0,0 +1,62 @@ +package com.seeker.n7cockpit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Map; + +import org.junit.Test; + +public class VehicleResponseParserTest { + @Test + public void parsesRealVehicleAndClimateWireResponses() { + String vehicle = "OK VEHICLE speed=0.0 soc=78.5 mileage=12684.6 power=-2 " + + "gear=1 wfl=0 wfr=10 wrl=50 wrr=100 drlight=2 unknown_future=abc"; + String climate = "OK CLIMATE power=1 temp=23 fan=4 cycle=1 inside=25 outside=31 " + + "front=0 auto=0"; + + VehicleSnapshot snapshot = VehicleResponseParser.parseSnapshot( + vehicle, climate, false, "test-real"); + + assertFalse(snapshot.isSimulation()); + assertEquals(0f, snapshot.speedKph(), 0.001f); + assertEquals(78.5f, snapshot.socPercent(), 0.001f); + assertEquals(-2f, snapshot.powerKw(), 0.001f); + assertEquals(100, snapshot.vehicleInt("wrr", -1)); + assertTrue(snapshot.climatePowerOn()); + assertEquals(23f, snapshot.targetTemperatureC(), 0.001f); + assertEquals(4, snapshot.fanLevel()); + assertEquals("abc", snapshot.vehicleValue("unknown_future")); + assertEquals("?", snapshot.vehicleValue("not_present")); + } + + @Test + public void acceptsUnknownMarkerAsStructuredValue() { + Map fields = VehicleResponseParser.parseFields( + "VEHICLE", "OK VEHICLE speed=? soc=80.0"); + assertEquals("?", fields.get("speed")); + assertEquals("80.0", fields.get("soc")); + } + + @Test + public void rejectsWrongSectionMalformedAndDuplicateFields() { + assertThrows(IllegalArgumentException.class, + () -> VehicleResponseParser.parseFields("VEHICLE", "OK CLIMATE power=1")); + assertThrows(IllegalArgumentException.class, + () -> VehicleResponseParser.parseFields("VEHICLE", "OK VEHICLE speed")); + assertThrows(IllegalArgumentException.class, + () -> VehicleResponseParser.parseFields("VEHICLE", "OK VEHICLE speed=1 speed=2")); + assertThrows(IllegalArgumentException.class, + () -> VehicleResponseParser.parseFields("VEHICLE", "ERR unavailable")); + } + + @Test + public void snapshotMapsAreImmutable() { + VehicleSnapshot snapshot = VehicleResponseParser.parseSnapshot( + "OK VEHICLE speed=0", "OK CLIMATE power=0", false, "test"); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.vehicle().put("speed", "99")); + } +} diff --git a/N7Cockpit/build.gradle b/N7Cockpit/build.gradle new file mode 100644 index 0000000..5b7ccba --- /dev/null +++ b/N7Cockpit/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'com.android.application' version '8.7.3' apply false +} diff --git a/N7Cockpit/config/migration.example.psd1 b/N7Cockpit/config/migration.example.psd1 new file mode 100644 index 0000000..32339f4 --- /dev/null +++ b/N7Cockpit/config/migration.example.psd1 @@ -0,0 +1,28 @@ +@{ + # URL used by server B to reach Cognitive_Agent on server A. + ServerAUrl = 'http://192.168.1.10:8000' + + # URL embedded into the APK and used by the Android device. + # Use server A's routable IP or DNS name. Use 10.0.2.2 only when the + # Android emulator and server A are running on the same machine. + AndroidServerAUrl = 'http://192.168.1.10:8000' + + VehicleId = 'denza_n7' + HardwareClientId = 'hwc-n7cockpit-server-b-001' + + # Portable packages are safe by default. Real CAN requires an explicit + # hardware build and local approval on server A. + ExecutionTarget = 'dry_run' + + # Relative paths are resolved from the N7Cockpit project root. + TokenFile = 'secrets/device-token.txt' + + # Leave empty to use JAVA_HOME / ANDROID_SDK_ROOT / ANDROID_HOME or + # standard installation locations. + JavaHome = '' + AndroidSdk = '' + + # Optional absolute Gradle 8.9 executable. Leave empty to use the included + # Gradle Wrapper (which downloads Gradle on the first build). + GradleExecutable = '' +} diff --git a/N7Cockpit/gradle.properties b/N7Cockpit/gradle.properties new file mode 100644 index 0000000..ed2d02a --- /dev/null +++ b/N7Cockpit/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=false +android.nonTransitiveRClass=true diff --git a/N7Cockpit/gradle/wrapper/gradle-wrapper.jar b/N7Cockpit/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/N7Cockpit/gradle/wrapper/gradle-wrapper.jar differ diff --git a/N7Cockpit/gradle/wrapper/gradle-wrapper.properties b/N7Cockpit/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d22acce --- /dev/null +++ b/N7Cockpit/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/N7Cockpit/gradlew b/N7Cockpit/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/N7Cockpit/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || 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 + # IBM's JDK on AIX uses strange locations for the executables + 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 + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + 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. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/N7Cockpit/gradlew.bat b/N7Cockpit/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/N7Cockpit/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/N7Cockpit/scripts/build-migrated-apk.ps1 b/N7Cockpit/scripts/build-migrated-apk.ps1 new file mode 100644 index 0000000..70faeae --- /dev/null +++ b/N7Cockpit/scripts/build-migrated-apk.ps1 @@ -0,0 +1,192 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = '', + [ValidateSet('debug', 'release')] + [string]$Variant = 'debug', + [switch]$AllowHardware +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $ConfigPath) { + $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' +} +if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { + $ConfigPath = Join-Path $projectRoot $ConfigPath +} +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Migration config does not exist: $ConfigPath" +} + +$config = Import-PowerShellDataFile -LiteralPath $ConfigPath +$required = @( + 'ServerAUrl', + 'AndroidServerAUrl', + 'VehicleId', + 'HardwareClientId', + 'ExecutionTarget', + 'TokenFile' +) +foreach ($key in $required) { + if (-not $config.ContainsKey($key) -or -not ([string]$config[$key]).Trim()) { + throw "Migration config is missing: $key" + } +} + +$serverAUrl = ([string]$config.ServerAUrl).TrimEnd('/') +$androidServerAUrl = ([string]$config.AndroidServerAUrl).TrimEnd('/') +$vehicleId = ([string]$config.VehicleId).Trim() +$hardwareClientId = ([string]$config.HardwareClientId).Trim() +$executionTarget = ([string]$config.ExecutionTarget).Trim().ToLowerInvariant() +if ($executionTarget -notin @('dry_run', 'simulated_hardware', 'hardware')) { + throw "Unsupported ExecutionTarget: $executionTarget" +} +if ($executionTarget -eq 'hardware' -and -not $AllowHardware) { + throw 'Safety interlock: hardware builds require the explicit -AllowHardware switch.' +} + +$tokenPath = [string]$config.TokenFile +if (-not [System.IO.Path]::IsPathRooted($tokenPath)) { + $tokenPath = Join-Path $projectRoot $tokenPath +} +if (-not (Test-Path -LiteralPath $tokenPath)) { + throw "Device token file does not exist: $tokenPath" +} +$deviceToken = (Get-Content -LiteralPath $tokenPath -Raw).Trim() +if ($deviceToken.Length -lt 32) { + throw 'The device token must contain at least 32 characters.' +} + +$javaHomeCandidates = @(@( + [string]$config.JavaHome, + $env:JAVA_HOME, + 'C:\Program Files\Android\Android Studio\jbr' +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) +if (-not $javaHomeCandidates) { + throw 'JDK 17 was not found. Set JavaHome in config or JAVA_HOME.' +} +$javaHome = $javaHomeCandidates[0] +$javaExe = Join-Path $javaHome 'bin\java.exe' +if (-not (Test-Path -LiteralPath $javaExe)) { + throw "java.exe does not exist under JavaHome: $javaHome" +} +$savedErrorAction = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +$javaVersion = (& $javaExe -version 2>&1 | Out-String) +$ErrorActionPreference = $savedErrorAction +$javaMajor = if ($javaVersion -match 'version "(?\d+)') { + [int]$Matches.major +} else { + 0 +} +if ($javaMajor -lt 17) { + throw "JDK 17 or newer is required. Detected: $($javaVersion.Trim())" +} + +$sdkCandidates = @(@( + [string]$config.AndroidSdk, + $env:ANDROID_SDK_ROOT, + $env:ANDROID_HOME, + (Join-Path $env:LOCALAPPDATA 'Android\Sdk') +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) +if (-not $sdkCandidates) { + throw 'Android SDK was not found. Set AndroidSdk, ANDROID_SDK_ROOT, or ANDROID_HOME.' +} +$androidSdk = $sdkCandidates[0] +if (-not (Test-Path -LiteralPath (Join-Path $androidSdk 'platforms\android-35'))) { + throw "Android SDK platform 35 is missing under: $androidSdk" +} + +$headers = @{ Authorization = "Bearer $deviceToken" } +$catalogUri = "$serverAUrl/api/v1/vehicles/$vehicleId/modes" +try { + $catalog = Invoke-RestMethod -Uri $catalogUri -Headers $headers -TimeoutSec 10 +} catch { + throw "Server A preflight failed at $catalogUri : $($_.Exception.Message)" +} +$presetCount = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count +if ($presetCount -lt 1) { + throw 'Server A returned no repository preset modes.' +} + +$oldJavaHome = $env:JAVA_HOME +$oldAndroidSdkRoot = $env:ANDROID_SDK_ROOT +$oldAndroidHome = $env:ANDROID_HOME +$oldBaseUrl = $env:COGNITIVE_AGENT_BASE_URL +$oldDeviceToken = $env:COGNITIVE_AGENT_DEVICE_API_TOKEN +try { + $env:JAVA_HOME = $javaHome + $env:ANDROID_SDK_ROOT = $androidSdk + $env:ANDROID_HOME = $androidSdk + $env:COGNITIVE_AGENT_BASE_URL = $androidServerAUrl + $env:COGNITIVE_AGENT_DEVICE_API_TOKEN = $deviceToken + + $gradle = ([string]$config.GradleExecutable).Trim() + if (-not $gradle) { $gradle = Join-Path $projectRoot 'gradlew.bat' } + if (-not [System.IO.Path]::IsPathRooted($gradle)) { + $gradle = Join-Path $projectRoot $gradle + } + if (-not (Test-Path -LiteralPath $gradle)) { + throw "Gradle executable does not exist: $gradle" + } + $variantTask = if ($Variant -eq 'release') { 'assembleRelease' } else { 'assembleDebug' } + $testTask = if ($Variant -eq 'release') { $null } else { 'testDebugUnitTest' } + $arguments = @( + '--no-daemon', + "-PorchestratorVehicleId=$vehicleId", + "-PorchestratorHardwareClientId=$hardwareClientId", + "-PorchestratorExecutionTarget=$executionTarget" + ) + if ($testTask) { $arguments += $testTask } + $arguments += $variantTask + Push-Location $projectRoot + try { + & $gradle @arguments + if ($LASTEXITCODE -ne 0) { throw 'Gradle build failed.' } + } finally { + Pop-Location + } +} finally { + $env:JAVA_HOME = $oldJavaHome + $env:ANDROID_SDK_ROOT = $oldAndroidSdkRoot + $env:ANDROID_HOME = $oldAndroidHome + $env:COGNITIVE_AGENT_BASE_URL = $oldBaseUrl + $env:COGNITIVE_AGENT_DEVICE_API_TOKEN = $oldDeviceToken + $deviceToken = $null +} + +$sourceApk = Join-Path $projectRoot "app\build\outputs\apk\$Variant\app-$Variant.apk" +if (-not (Test-Path -LiteralPath $sourceApk)) { + throw "Built APK was not found: $sourceApk" +} +$distDirectory = Join-Path $projectRoot 'dist' +New-Item -ItemType Directory -Force -Path $distDirectory | Out-Null +$destinationApk = Join-Path $distDirectory "N7Cockpit-remote-$Variant.apk" +Copy-Item -LiteralPath $sourceApk -Destination $destinationApk -Force +$apkHash = (Get-FileHash -LiteralPath $destinationApk -Algorithm SHA256).Hash +$buildInfo = [ordered]@{ + generated_at = (Get-Date).ToUniversalTime().ToString('o') + variant = $Variant + package = if ($Variant -eq 'debug') { + 'com.seeker.n7cockpit.remote' + } else { + 'com.seeker.n7cockpit' + } + server_a_url_from_server_b = $serverAUrl + android_server_a_url = $androidServerAUrl + vehicle_id = $vehicleId + hardware_client_id = $hardwareClientId + execution_target = $executionTarget + preset_count = $presetCount + apk_sha256 = $apkHash +} +$buildInfo | ConvertTo-Json | Set-Content -LiteralPath ( + Join-Path $distDirectory 'build-info.json' +) -Encoding UTF8 + +Write-Host '' +Write-Host 'BUILD PASSED' +Write-Host "APK: $destinationApk" +Write-Host "SHA256: $apkHash" +Write-Host "Execution target: $executionTarget" +Write-Host 'The token is embedded in the APK. Keep the APK private and rotate the token if leaked.' diff --git a/N7Cockpit/scripts/check-server-a.ps1 b/N7Cockpit/scripts/check-server-a.ps1 new file mode 100644 index 0000000..34bb7f9 --- /dev/null +++ b/N7Cockpit/scripts/check-server-a.ps1 @@ -0,0 +1,31 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = '' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' } +if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { + $ConfigPath = Join-Path $projectRoot $ConfigPath +} +$config = Import-PowerShellDataFile -LiteralPath $ConfigPath +$tokenPath = [string]$config.TokenFile +if (-not [System.IO.Path]::IsPathRooted($tokenPath)) { + $tokenPath = Join-Path $projectRoot $tokenPath +} +$token = (Get-Content -LiteralPath $tokenPath -Raw).Trim() +$baseUrl = ([string]$config.ServerAUrl).TrimEnd('/') +$vehicleId = ([string]$config.VehicleId).Trim() +$headers = @{ Authorization = "Bearer $token" } +$catalog = Invoke-RestMethod -Uri "$baseUrl/api/v1/vehicles/$vehicleId/modes" ` + -Headers $headers -TimeoutSec 10 +$events = Invoke-RestMethod -Uri "$baseUrl/api/v1/vehicles/$vehicleId/events?after=0" ` + -Headers $headers -TimeoutSec 10 +[pscustomobject]@{ + Passed = $true + ServerA = $baseUrl + VehicleId = $catalog.vehicle_id + PresetModes = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count + EventSequence = $events.next_sequence +} diff --git a/N7Cockpit/scripts/create-portable-package.ps1 b/N7Cockpit/scripts/create-portable-package.ps1 new file mode 100644 index 0000000..eca9bc5 --- /dev/null +++ b/N7Cockpit/scripts/create-portable-package.ps1 @@ -0,0 +1,123 @@ +[CmdletBinding()] +param( + [string]$OutputDirectory = '' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $OutputDirectory) { + $OutputDirectory = Join-Path $projectRoot 'dist' +} +if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) { + $OutputDirectory = Join-Path $projectRoot $OutputDirectory +} +$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory) +$projectFull = [System.IO.Path]::GetFullPath($projectRoot) +if (-not $outputRoot.StartsWith($projectFull, [StringComparison]::OrdinalIgnoreCase)) { + throw 'OutputDirectory must stay inside the N7Cockpit project.' +} + +$version = Get-Date -Format 'yyyyMMdd-HHmmss' +$stagingRoot = Join-Path $outputRoot "portable-staging-$version" +$packageRoot = Join-Path $stagingRoot 'N7Cockpit' +$zipPath = Join-Path $outputRoot "N7Cockpit-portable-$version.zip" +New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null + +$rootFiles = @( + '.gitignore', + 'build.gradle', + 'gradle.properties', + 'gradlew', + 'gradlew.bat', + 'README.md', + 'MIGRATION_GUIDE.md', + 'settings.gradle' +) +foreach ($relative in $rootFiles) { + Copy-Item -LiteralPath (Join-Path $projectRoot $relative) ` + -Destination (Join-Path $packageRoot $relative) +} + +$directoryPairs = @( + @('gradle', 'gradle'), + @('app\src', 'app\src') +) +foreach ($pair in $directoryPairs) { + $destination = Join-Path $packageRoot $pair[1] + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null + Copy-Item -LiteralPath (Join-Path $projectRoot $pair[0]) ` + -Destination $destination -Recurse +} +New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'app') | Out-Null +Copy-Item -LiteralPath (Join-Path $projectRoot 'app\build.gradle') ` + -Destination (Join-Path $packageRoot 'app\build.gradle') +Copy-Item -LiteralPath (Join-Path $projectRoot 'app\proguard-rules.pro') ` + -Destination (Join-Path $packageRoot 'app\proguard-rules.pro') +New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'scripts') | Out-Null +$portableScripts = @( + 'initialize-migration.ps1', + 'verify-migration-environment.ps1', + 'check-server-a.ps1', + 'test-server-a-dry-run.ps1', + 'build-migrated-apk.ps1', + 'install-migrated-apk.ps1', + 'create-portable-package.ps1' +) +foreach ($script in $portableScripts) { + Copy-Item -LiteralPath (Join-Path $projectRoot "scripts\$script") ` + -Destination (Join-Path $packageRoot "scripts\$script") +} +New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'config') | Out-Null +Copy-Item -LiteralPath (Join-Path $projectRoot 'config\migration.example.psd1') ` + -Destination (Join-Path $packageRoot 'config\migration.example.psd1') + +$forbiddenNames = @( + 'migration.psd1', + 'device-token.txt', + 'local.properties' +) +$forbidden = Get-ChildItem -Recurse -Force -File -LiteralPath $packageRoot | + Where-Object { $_.Name -in $forbiddenNames -or $_.Extension -in @('.jks', '.p12') } +if ($forbidden) { + throw "Portable package contains forbidden files: $($forbidden.FullName -join ', ')" +} + +$files = Get-ChildItem -Recurse -Force -File -LiteralPath $packageRoot | + Sort-Object FullName +$manifest = [ordered]@{ + package = 'N7Cockpit portable source package' + generated_at = (Get-Date).ToUniversalTime().ToString('o') + contains_credentials = $false + default_execution_target = 'dry_run' + file_count = $files.Count + files = @($files | ForEach-Object { + [ordered]@{ + path = $_.FullName.Substring($packageRoot.Length + 1).Replace('\', '/') + size = $_.Length + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) +} +$manifest | ConvertTo-Json -Depth 5 | Set-Content ` + -LiteralPath (Join-Path $packageRoot 'PACKAGE-MANIFEST.json') -Encoding UTF8 + +Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath ` + -CompressionLevel Optimal +$zipHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash +$checksumPath = "$zipPath.sha256" +"$zipHash $([System.IO.Path]::GetFileName($zipPath))" | Set-Content ` + -LiteralPath $checksumPath -Encoding ASCII + +$resolvedStaging = [System.IO.Path]::GetFullPath($stagingRoot) +if (-not $resolvedStaging.StartsWith($outputRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to remove staging directory outside output root.' +} +Remove-Item -LiteralPath $resolvedStaging -Recurse -Force + +[pscustomobject]@{ + Passed = $true + Zip = $zipPath + Sha256 = $zipHash + ChecksumFile = $checksumPath + CredentialFilesIncluded = $false +} diff --git a/N7Cockpit/scripts/initialize-migration.ps1 b/N7Cockpit/scripts/initialize-migration.ps1 new file mode 100644 index 0000000..278b7bc --- /dev/null +++ b/N7Cockpit/scripts/initialize-migration.ps1 @@ -0,0 +1,30 @@ +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$configDirectory = Join-Path $projectRoot 'config' +$secretDirectory = Join-Path $projectRoot 'secrets' +$example = Join-Path $configDirectory 'migration.example.psd1' +$config = Join-Path $configDirectory 'migration.psd1' +$token = Join-Path $secretDirectory 'device-token.txt' + +New-Item -ItemType Directory -Force -Path $configDirectory, $secretDirectory | Out-Null +if (-not (Test-Path -LiteralPath $config)) { + Copy-Item -LiteralPath $example -Destination $config + Write-Host "Created: $config" +} else { + Write-Host "Kept existing: $config" +} +if (-not (Test-Path -LiteralPath $token)) { + New-Item -ItemType File -Path $token | Out-Null + Write-Host "Created empty token file: $token" +} else { + Write-Host "Kept existing: $token" +} + +Write-Host '' +Write-Host 'Next steps:' +Write-Host '1. Edit config\migration.psd1.' +Write-Host '2. Paste the server A device token into secrets\device-token.txt.' +Write-Host '3. Run scripts\build-migrated-apk.ps1.' diff --git a/N7Cockpit/scripts/install-migrated-apk.ps1 b/N7Cockpit/scripts/install-migrated-apk.ps1 new file mode 100644 index 0000000..e188e8d --- /dev/null +++ b/N7Cockpit/scripts/install-migrated-apk.ps1 @@ -0,0 +1,56 @@ +[CmdletBinding()] +param( + [string]$AdbPath = '', + [string]$ApkPath = '', + [string]$Serial = '', + [ValidateSet('debug', 'release')] + [string]$Variant = 'debug' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $AdbPath) { + $sdkRoots = @( + $env:ANDROID_SDK_ROOT, + $env:ANDROID_HOME, + (Join-Path $env:LOCALAPPDATA 'Android\Sdk') + ) | Where-Object { $_ } + foreach ($sdkRoot in $sdkRoots) { + $candidate = Join-Path $sdkRoot 'platform-tools\adb.exe' + if (Test-Path -LiteralPath $candidate) { $AdbPath = $candidate; break } + } +} +if (-not $AdbPath -or -not (Test-Path -LiteralPath $AdbPath)) { + throw 'adb.exe was not found. Set -AdbPath or ANDROID_SDK_ROOT.' +} +if (-not $ApkPath) { + $ApkPath = Join-Path $projectRoot "dist\N7Cockpit-remote-$Variant.apk" +} +if (-not (Test-Path -LiteralPath $ApkPath)) { + throw "APK does not exist: $ApkPath" +} + +$adbArgs = @() +if ($Serial) { $adbArgs = @('-s', $Serial) } +$online = @(& $AdbPath devices) | Where-Object { $_ -match "\tdevice$" } +if (-not $Serial -and $online.Count -ne 1) { + throw "Exactly one online Android device is required; found $($online.Count)." +} +& $AdbPath @adbArgs install -r $ApkPath +if ($LASTEXITCODE -ne 0) { throw 'APK installation failed.' } + +$package = if ($Variant -eq 'debug') { + 'com.seeker.n7cockpit.remote' +} else { + 'com.seeker.n7cockpit' +} +$component = "$package/com.seeker.n7cockpit.MainActivity" +& $AdbPath @adbArgs shell am force-stop $package | Out-Null +$launch = & $AdbPath @adbArgs shell am start -W -n $component 2>&1 +if ($LASTEXITCODE -ne 0 -or ($launch -join "`n") -notmatch 'Status:\s+ok') { + throw "APK installed but launch failed:`n$($launch -join "`n")" +} + +Write-Host 'INSTALL PASSED' +Write-Host "Package: $package" +Write-Host "Component: $component" diff --git a/N7Cockpit/scripts/start-remote-app.ps1 b/N7Cockpit/scripts/start-remote-app.ps1 new file mode 100644 index 0000000..e6d3ad5 --- /dev/null +++ b/N7Cockpit/scripts/start-remote-app.ps1 @@ -0,0 +1,57 @@ +[CmdletBinding()] +param( + [string]$AdbPath = 'D:\Android\Sdk\platform-tools\adb.exe', + [string]$PackageName = 'com.seeker.n7cockpit.remote', + [string]$ActivityName = 'com.seeker.n7cockpit.MainActivity', + [int]$TimeoutSeconds = 15 +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $AdbPath)) { + throw "adb does not exist: $AdbPath" +} + +$deviceLines = @(& $AdbPath devices) | Where-Object { $_ -match "\tdevice$" } +if ($deviceLines.Count -ne 1) { + throw "Exactly one online Android device is required; found $($deviceLines.Count)." +} + +$installed = (& $AdbPath shell pm list packages $PackageName | Out-String).Trim() +if ($installed -notmatch [regex]::Escape($PackageName)) { + throw "The remote debug app is not installed: $PackageName" +} + +$component = "$PackageName/$ActivityName" +& $AdbPath shell am force-stop $PackageName | Out-Null +$launchOutput = & $AdbPath shell am start -W -n $component 2>&1 +if ($LASTEXITCODE -ne 0 -or ($launchOutput -join "`n") -notmatch 'Status:\s+ok') { + throw "Failed to launch app:`n$($launchOutput -join "`n")" +} + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$resumed = '' +$pidValue = '' +do { + Start-Sleep -Milliseconds 500 + $pidValue = (& $AdbPath shell pidof $PackageName 2>$null | Out-String).Trim() + $resumed = (& $AdbPath shell dumpsys activity activities | + Select-String 'topResumedActivity' | + Select-Object -First 1).Line +} while ((-not $pidValue -or $resumed -notmatch [regex]::Escape($component)) -and + (Get-Date) -lt $deadline) + +if (-not $pidValue) { + throw 'The app process was not found after launch.' +} +if ($resumed -notmatch [regex]::Escape($component)) { + throw "The app process exists but its Activity is not foreground: $resumed" +} + +[pscustomobject]@{ + Device = (([string]$deviceLines) -split "\t")[0] + Package = $PackageName + Component = $component + Pid = $pidValue + Foreground = $true +} diff --git a/N7Cockpit/scripts/test-remote-link.ps1 b/N7Cockpit/scripts/test-remote-link.ps1 new file mode 100644 index 0000000..72f76c7 --- /dev/null +++ b/N7Cockpit/scripts/test-remote-link.ps1 @@ -0,0 +1,150 @@ +[CmdletBinding()] +param( + [string]$AdbPath = 'D:\Android\Sdk\platform-tools\adb.exe', + [string]$ServerBaseUrl = 'http://127.0.0.1:8000', + [string]$TokenFile = "$env:LOCALAPPDATA\Temp\n7-device-token.txt", + [string]$PackageName = 'com.seeker.n7cockpit.remote', + [string]$ModeId = 'baby_nap', + [int]$TimeoutSeconds = 40, + [string]$ArtifactDirectory = '' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $ArtifactDirectory) { + $ArtifactDirectory = Join-Path $projectRoot 'artifacts\remote-link' +} +$buildConfig = Join-Path $projectRoot 'app\build\generated\source\buildConfig\debug\com\seeker\n7cockpit\BuildConfig.java' + +if (-not (Test-Path -LiteralPath $TokenFile)) { + throw "The device token file does not exist: $TokenFile" +} +if (-not (Test-Path -LiteralPath $buildConfig)) { + throw 'Debug BuildConfig was not found. Build the remote debug APK first.' +} + +$configText = Get-Content -LiteralPath $buildConfig -Raw -Encoding UTF8 +if ($configText -notmatch 'ORCHESTRATOR_EXECUTION_TARGET\s*=\s*"dry_run"') { + throw 'Safety interlock: the APK execution target is not dry_run.' +} +if ($configText -notmatch 'ORCHESTRATOR_BASE_URL\s*=\s*"http://10\.0\.2\.2:8000"') { + throw 'This test only permits an emulator APK targeting 10.0.2.2:8000.' +} + +$token = (Get-Content -LiteralPath $TokenFile -Raw).Trim() +if (-not $token) { + throw 'The device token is empty.' +} +$headers = @{ Authorization = "Bearer $token" } + +$catalog = Invoke-RestMethod -Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/modes" ` + -Headers $headers -TimeoutSec 5 +$mode = @($catalog.modes | Where-Object { $_.id -eq $ModeId -and $_.origin -eq 'preset' }) +if ($mode.Count -ne 1) { + throw "The preset is not unique in the server catalog: $ModeId" +} +$expectedModeName = [string]$mode[0].name + +& (Join-Path $PSScriptRoot 'start-remote-app.ps1') -AdbPath $AdbPath ` + -PackageName $PackageName | Out-Null +Start-Sleep -Seconds 1 + +# Standard 2560x1600 emulator: mode navigation, then the first preset button. +& $AdbPath shell input tap 94 406 +Start-Sleep -Seconds 2 + +New-Item -ItemType Directory -Force -Path $ArtifactDirectory | Out-Null +& $AdbPath shell uiautomator dump /sdcard/n7-before-mode.xml | Out-Null +& $AdbPath pull /sdcard/n7-before-mode.xml (Join-Path $ArtifactDirectory 'before-mode.xml') | Out-Null +$beforeUi = Get-Content -LiteralPath (Join-Path $ArtifactDirectory 'before-mode.xml') ` + -Raw -Encoding UTF8 +if (-not $beforeUi.Contains('http://10.0.2.2:8000')) { + throw 'The Android mode page did not render its server connection.' +} +if (-not $beforeUi.Contains($expectedModeName)) { + throw "The Android mode page did not render the preset: $ModeId" +} + +$baseline = Invoke-RestMethod ` + -Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/events?after=0" ` + -Headers $headers -TimeoutSec 5 +$sequence = [long]$baseline.next_sequence + +& $AdbPath shell input tap 1469 444 + +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$observed = New-Object System.Collections.Generic.List[object] +$terminal = $null +do { + Start-Sleep -Seconds 1 + $batch = Invoke-RestMethod ` + -Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/events?after=$sequence" ` + -Headers $headers -TimeoutSec 5 + foreach ($event in @($batch.events)) { + $execution = $event.execution + if (-not $execution -and $event.data) { + $execution = $event.data.execution + } + if (-not $execution -or $execution.mode_id -ne $ModeId -or + $execution.source -ne 'android') { + continue + } + $observed.Add([pscustomobject]@{ + sequence = [long]$event.sequence + execution_id = $execution.execution_id + status = $execution.status + target = $execution.execution_target + }) + if ($execution.status -in @('succeeded', 'failed', 'cancelled')) { + $terminal = $execution + } + } + $sequence = [long]$batch.next_sequence +} while (-not $terminal -and (Get-Date) -lt $deadline) + +if (-not $terminal) { + throw "No terminal mode event was received within $TimeoutSeconds seconds." +} +if ($terminal.execution_target -ne 'dry_run') { + throw "Safety failure: server execution target was $($terminal.execution_target)." +} +if ($terminal.status -ne 'succeeded') { + throw "Mode execution failed: $($terminal.status) $($terminal.error)" +} + +$statusPath = Join-Path $ArtifactDirectory 'after-execution.xml' +$screenPath = Join-Path $ArtifactDirectory 'after-execution.png' +Start-Sleep -Seconds 2 +& $AdbPath shell uiautomator dump /sdcard/n7-after-execution.xml | Out-Null +& $AdbPath pull /sdcard/n7-after-execution.xml $statusPath | Out-Null +& $AdbPath shell screencap -p /sdcard/n7-after-execution.png | Out-Null +& $AdbPath pull /sdcard/n7-after-execution.png $screenPath | Out-Null +$afterUi = Get-Content -LiteralPath $statusPath -Raw -Encoding UTF8 + +if (-not $afterUi.Contains($expectedModeName) -or -not $afterUi.Contains('100%')) { + throw 'The server task succeeded, but Android did not render terminal progress.' +} +if (-not $afterUi.Contains($terminal.execution_id)) { + throw 'The Android execution_id does not match the server execution_id.' +} +if (-not $afterUi.Contains('N7Cockpit')) { + throw 'Android did not render the expected source identity.' +} + +$statuses = @($observed | Sort-Object sequence | Select-Object -ExpandProperty status) +foreach ($required in @('queued', 'executing', 'succeeded')) { + if ($required -notin $statuses) { + throw "The event stream is missing state: $required" + } +} + +[pscustomobject]@{ + Passed = $true + SafetyTarget = $terminal.execution_target + CanExecuted = $false + ModeId = $terminal.mode_id + ExecutionId = $terminal.execution_id + EventStates = ($statuses -join ' -> ') + AndroidStateSynchronized = $true + Screenshot = $screenPath +} diff --git a/N7Cockpit/scripts/test-server-a-dry-run.ps1 b/N7Cockpit/scripts/test-server-a-dry-run.ps1 new file mode 100644 index 0000000..19a4a54 --- /dev/null +++ b/N7Cockpit/scripts/test-server-a-dry-run.ps1 @@ -0,0 +1,64 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = '', + [string]$ModeId = 'baby_nap', + [int]$TimeoutSeconds = 30 +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' } +if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { + $ConfigPath = Join-Path $projectRoot $ConfigPath +} +$config = Import-PowerShellDataFile -LiteralPath $ConfigPath +if (([string]$config.ExecutionTarget).Trim().ToLowerInvariant() -ne 'dry_run') { + throw 'Safety interlock: this smoke test requires ExecutionTarget=dry_run.' +} +$tokenPath = [string]$config.TokenFile +if (-not [System.IO.Path]::IsPathRooted($tokenPath)) { + $tokenPath = Join-Path $projectRoot $tokenPath +} +$token = (Get-Content -LiteralPath $tokenPath -Raw).Trim() +$baseUrl = ([string]$config.ServerAUrl).TrimEnd('/') +$vehicleId = ([string]$config.VehicleId).Trim() +$headers = @{ + Authorization = "Bearer $token" + 'Content-Type' = 'application/json; charset=utf-8' + 'Idempotency-Key' = "server-b-smoke-$([guid]::NewGuid().ToString('N'))" +} +$payload = @{ + mode_id = $ModeId + execution_target = 'dry_run' + source = 'android' + source_device_id = 'n7-server-b-smoke' +} | ConvertTo-Json +$accepted = Invoke-RestMethod ` + -Uri "$baseUrl/api/v1/vehicles/$vehicleId/mode-executions" ` + -Method Post -Headers $headers -Body $payload -TimeoutSec 10 +$deadline = (Get-Date).AddSeconds($TimeoutSeconds) +$terminal = $accepted +do { + if ($terminal.status -in @( + 'succeeded', 'partially_succeeded', 'failed', 'cancelled', 'timed_out' + )) { break } + Start-Sleep -Milliseconds 500 + $terminal = Invoke-RestMethod ` + -Uri "$baseUrl/api/v1/mode-executions/$($accepted.execution_id)" ` + -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 10 +} while ((Get-Date) -lt $deadline) +$token = $null +if ($terminal.execution_target -ne 'dry_run') { + throw "Safety failure: server used $($terminal.execution_target)." +} +if ($terminal.status -ne 'succeeded') { + throw "Dry-run smoke test failed: $($terminal.status) $($terminal.message)" +} +[pscustomobject]@{ + Passed = $true + CanExecuted = $false + ExecutionTarget = $terminal.execution_target + ExecutionId = $terminal.execution_id + ModeId = $terminal.mode_id + Status = $terminal.status +} diff --git a/N7Cockpit/scripts/verify-migration-environment.ps1 b/N7Cockpit/scripts/verify-migration-environment.ps1 new file mode 100644 index 0000000..995e1c2 --- /dev/null +++ b/N7Cockpit/scripts/verify-migration-environment.ps1 @@ -0,0 +1,93 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = '' +) + +$ErrorActionPreference = 'Stop' +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' } +if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { + $ConfigPath = Join-Path $projectRoot $ConfigPath +} +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw 'Run scripts\initialize-migration.ps1 and edit config\migration.psd1 first.' +} +$config = Import-PowerShellDataFile -LiteralPath $ConfigPath + +$javaCandidates = @( + [string]$config.JavaHome, + $env:JAVA_HOME, + 'C:\Program Files\Android\Android Studio\jbr' +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } +$javaHome = $javaCandidates | Select-Object -First 1 +$javaOk = $false +$javaSummary = 'not found' +if ($javaHome) { + $javaExe = Join-Path $javaHome 'bin\java.exe' + if (Test-Path -LiteralPath $javaExe) { + $savedErrorAction = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $javaOutput = (& $javaExe -version 2>&1 | Out-String) + $ErrorActionPreference = $savedErrorAction + $javaSummary = ($javaOutput -split "`r?`n" | Select-Object -First 1).Trim() + $javaMajor = if ($javaOutput -match 'version "(?\d+)') { + [int]$Matches.major + } else { + 0 + } + $javaOk = $javaMajor -ge 17 + } +} + +$sdkCandidates = @( + [string]$config.AndroidSdk, + $env:ANDROID_SDK_ROOT, + $env:ANDROID_HOME, + (Join-Path $env:LOCALAPPDATA 'Android\Sdk') +) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } +$androidSdk = $sdkCandidates | Select-Object -First 1 +$platform35 = $androidSdk -and ( + Test-Path -LiteralPath (Join-Path $androidSdk 'platforms\android-35') +) +$adb = if ($androidSdk) { Join-Path $androidSdk 'platform-tools\adb.exe' } else { '' } +$adbOk = $adb -and (Test-Path -LiteralPath $adb) + +$tokenPath = [string]$config.TokenFile +if (-not [System.IO.Path]::IsPathRooted($tokenPath)) { + $tokenPath = Join-Path $projectRoot $tokenPath +} +$tokenOk = (Test-Path -LiteralPath $tokenPath) -and ( + (Get-Content -LiteralPath $tokenPath -Raw).Trim().Length -ge 32 +) + +$serverOk = $false +$presetCount = 0 +if ($tokenOk) { + try { + $token = (Get-Content -LiteralPath $tokenPath -Raw).Trim() + $baseUrl = ([string]$config.ServerAUrl).TrimEnd('/') + $catalog = Invoke-RestMethod ` + -Uri "$baseUrl/api/v1/vehicles/$($config.VehicleId)/modes" ` + -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 10 + $presetCount = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count + $serverOk = $presetCount -gt 0 + } catch { + $serverOk = $false + } finally { + $token = $null + } +} + +$result = [pscustomobject]@{ + Passed = [bool]($javaOk -and $platform35 -and $adbOk -and $tokenOk -and $serverOk) + Java17OrNewer = $javaOk + Java = $javaSummary + AndroidSdk = $androidSdk + AndroidPlatform35 = [bool]$platform35 + Adb = [bool]$adbOk + DeviceToken = [bool]$tokenOk + ServerA = [bool]$serverOk + PresetModes = $presetCount +} +$result +if (-not $result.Passed) { exit 2 } diff --git a/N7Cockpit/settings.gradle b/N7Cockpit/settings.gradle new file mode 100644 index 0000000..fb54d46 --- /dev/null +++ b/N7Cockpit/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/gradle-plugin' } + maven { url 'https://maven.aliyun.com/repository/public' } + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + maven { url 'https://maven.aliyun.com/repository/google' } + maven { url 'https://maven.aliyun.com/repository/central' } + maven { url 'https://maven.aliyun.com/repository/public' } + google() + mavenCentral() + } +} + +rootProject.name = 'N7Cockpit' +include ':app' diff --git a/README.md b/README.md index b405840..59bb454 100755 --- a/README.md +++ b/README.md @@ -113,6 +113,10 @@ config/gesture/default.yaml 同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。 +N7Cockpit Android 远程执行仓库预设模式时,Android 与 Web 使用服务器 A 的同一套 +模式任务和增量事件接口,不进行浏览器自动点击。双服务器地址、设备令牌、硬件本机 +审批和联调步骤见 [N7Cockpit 远程功能模式接入](docs/workflows/n7cockpit-remote-modes.md)。 + ## 认证与公网体验 公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。 diff --git a/apps/web-console/app.js b/apps/web-console/app.js index 0499acc..3ee3a2b 100755 --- a/apps/web-console/app.js +++ b/apps/web-console/app.js @@ -58,6 +58,11 @@ const state = { physicalStateSyncPromise: null, pendingModeProfileId: null, modeRunTimer: null, + modeEventSequence: 0, + modeEventPoller: null, + modeEventStartedAt: Date.now(), + activeModeExecutions: new Set(), + renderedModeExecutions: new Set(), modeDraftCommand: "", lastSuccessfulUtterance: "", }; @@ -4159,17 +4164,141 @@ function setModeChipsDisabled(disabled) { }); } -function applyModeCommand(command, modeProfileId = null) { +async function applyModeCommand(command, modeProfileId = null) { if (!command) return; if ($("#runButton").disabled) { showToast("上一条指令仍在执行,请稍候"); return; } + if (!modeProfileId) { + $("#utterance").value = command; + $("#requestForm").requestSubmit(); + return; + } + const driverOnlyCh1 = isDriverOnlyCh1Runtime(); + if (state.executionMode === "hardware" + && !driverOnlyCh1 + && !isLocalDecisionHost() + && state.hardwareAccess?.state !== "approved") { + showToast(state.hardwareAccess?.state === "pending" + ? "申请仍在等待本地审批" + : "请先申请并获得本地真实台架使用权"); + return; + } closeModeGallery({ restoreFocus: false }); - state.pendingModeProfileId = modeProfileId; - if (modeProfileId) setModeExecutionState(modeProfileId, "running"); - $("#utterance").value = command; - $("#requestForm").requestSubmit(); + setModeExecutionState(modeProfileId, "running"); + setModeChipsDisabled(true); + try { + const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id; + const response = await apiRequest( + `/api/v1/vehicles/${encodeURIComponent(vehicleId)}/mode-executions`, + { + method: "POST", + timeoutMs: 10000, + headers: { + "Content-Type": "application/json", + "Idempotency-Key": createOpaqueId("web-mode"), + }, + body: JSON.stringify({ + mode_id: modeProfileId, + execution_target: state.executionMode, + source: "web", + source_device_id: "web-console", + ...(state.executionMode === "hardware" && !driverOnlyCh1 + ? { hardware_client_id: state.hardwareClientId } + : {}), + }), + }, + ); + const execution = await response.json(); + if (response.status === 401) { + window.location.replace("/login"); + return; + } + if (!response.ok) throw new Error(execution.message || execution.error || `HTTP ${response.status}`); + state.activeModeExecutions.add(execution.execution_id); + showToast(execution.message || `${modeChipLabel({ name: command })}已进入执行队列`); + } catch (error) { + setModeExecutionState(modeProfileId, "failed"); + setModeChipsDisabled(false); + showToast(`模式提交失败:${error.message || "服务不可用"}`); + } +} + +function webModeState(status) { + if (["queued", "validating", "executing", "verifying"].includes(status)) return "running"; + if (["succeeded", "partially_succeeded"].includes(status)) return "succeeded"; + return "failed"; +} + +function modeExecutionTerminal(status) { + return ["succeeded", "partially_succeeded", "failed", "cancelled", "timed_out"].includes(status); +} + +async function handleModeExecutionEvent(event) { + const execution = event?.execution || event; + if (!execution?.execution_id || !execution.mode_id) return; + setModeExecutionState(execution.mode_id, webModeState(execution.status)); + if (modeExecutionTerminal(execution.status)) { + state.activeModeExecutions.delete(execution.execution_id); + } else { + state.activeModeExecutions.add(execution.execution_id); + } + setModeChipsDisabled(state.activeModeExecutions.size > 0); + $("#resultTitle").textContent = execution.message || `正在执行${execution.mode_name}`; + + if (execution.source === "android") { + showToast(`N7Cockpit:${execution.message || execution.mode_name}`); + } + if (!modeExecutionTerminal(execution.status) + || state.renderedModeExecutions.has(execution.execution_id)) return; + state.renderedModeExecutions.add(execution.execution_id); + try { + const response = await apiRequest( + `/api/v1/mode-executions/${encodeURIComponent(execution.execution_id)}`, + { cache: "no-store", timeoutMs: 10000 }, + ); + const completed = await response.json(); + if (response.ok && completed.result) { + renderResult(completed.result); + // renderResult updates the digital twin, but its generic domain summary must not + // replace the stable mode identity required by Web/Android remote operation. + $("#resultTitle").textContent = execution.message || `${execution.mode_name}执行完成`; + $("#requestMeta").textContent = [ + execution.execution_id, + `mode:${execution.mode_id}`, + `source:${execution.source === "android" ? "N7Cockpit" : execution.source}`, + execution.execution_target, + ].filter(Boolean).join(" / "); + } + } catch (_error) { + // The mode card and status message still contain the terminal state. + } +} + +async function pollModeExecutionEvents({ initial = false } = {}) { + const vehicleId = state.vehicleId || state.runtime?.default_vehicle_id; + if (!vehicleId || document.visibilityState === "hidden") return; + try { + const response = await apiRequest( + `/api/v1/vehicles/${encodeURIComponent(vehicleId)}/events?after=${state.modeEventSequence}`, + { cache: "no-store", timeoutMs: 8000 }, + ); + if (!response.ok) return; + const payload = await response.json(); + state.modeEventSequence = Number(payload.next_sequence || state.modeEventSequence); + const events = Array.isArray(payload.events) ? payload.events : []; + for (const event of events) { + const createdAt = Date.parse(event.created_at || ""); + if (initial && Number.isFinite(createdAt) && createdAt < state.modeEventStartedAt - 2000) continue; + await handleModeExecutionEvent(event); + } + if (initial && Array.isArray(payload.active_executions)) { + for (const execution of payload.active_executions) await handleModeExecutionEvent(execution); + } + } catch (_error) { + // The normal connection probe remains responsible for the global offline badge. + } } function updateModeDraftPreview() { @@ -5941,6 +6070,8 @@ document.addEventListener("DOMContentLoaded", async () => { $$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download))); await loadAuthStatus(); await loadRuntime(); + await pollModeExecutionEvents({ initial: true }); + state.modeEventPoller = window.setInterval(pollModeExecutionEvents, 1000); if (!isDriverOnlyCh1Runtime()) { await loadHardwareAccessStatus({ silent: true }); state.hardwareAccessPoller = window.setInterval( diff --git a/apps/web-console/index.html b/apps/web-console/index.html index 8d84059..f477cb2 100755 --- a/apps/web-console/index.html +++ b/apps/web-console/index.html @@ -19,7 +19,7 @@ - + diff --git a/docs/workflows/n7cockpit-remote-modes.md b/docs/workflows/n7cockpit-remote-modes.md new file mode 100644 index 0000000..5ca1634 --- /dev/null +++ b/docs/workflows/n7cockpit-remote-modes.md @@ -0,0 +1,70 @@ +# N7Cockpit 远程功能模式接入 + +服务器 A 运行 Cognitive_Agent 并独占 CAN 盒;服务器 B 只构建、分发 N7Cockpit +Android APK。Android 和 Web 不互相调用,也不模拟页面点击,而是调用服务器 A +上的同一个模式执行资源: + +```text +N7Cockpit ─┐ + ├─ POST /api/v1/vehicles/denza_n7/mode-executions +Web Console┘ │ + ▼ + Cognitive_Agent → 安全校验 → CAN → 车端回读 + │ + GET /api/v1/vehicles/denza_n7/events?after= + ▼ + Web 与 Android 同步 +``` + +Android 只提交仓库模式目录中的 `mode_id`。服务器再次按车型解析 YAML 预设, +模式展开、参数校验、CAN 编译、硬件门禁和回读仍全部留在服务器 A。 + +## 服务器 A + +生成至少 32 字节的随机设备令牌,将同一个值配置给服务器 A 和服务器 B 的 +Android 构建环境。不要提交令牌到源码: + +```powershell +$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = '<长随机令牌>' +$env:PYTHONPATH = 'src;domains/seat/src' +python -m cognitive_agent.server --host 0.0.0.0 --port 8000 +``` + +开放防火墙时只允许服务器 B、车机所在网段访问 TCP 8000。跨不可信网络应在服务 +前使用 HTTPS 反向代理;Android 配置的地址必须是服务器 A 的可路由 IP 或域名, +不能是 `http://127.0.0.1:8000`。 + +设备令牌可访问以下受限接口: + +- `GET /api/v1/vehicles/{vehicle_id}/modes` +- `POST /api/v1/vehicles/{vehicle_id}/mode-executions` +- `GET /api/v1/mode-executions/{execution_id}` +- `GET /api/v1/vehicles/{vehicle_id}/events?after={sequence}` +- 硬件使用权的申请、状态与释放接口 + +真实硬件仍采用本机审批租约。Android 首次点击“申请真实硬件控制”后,服务器 A +操作员访问 `http://127.0.0.1:8000/hardware-control`,批准 +`hwc-n7cockpit-001`。审批 API 只允许服务器 A 的无代理回环请求,设备令牌不能批准 +自己。 + +## 服务器 B / Android + +在 N7Cockpit 根目录构建: + +```powershell +$env:COGNITIVE_AGENT_BASE_URL = 'http://<服务器A局域网IP>:8000' +$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = '<与服务器A相同的令牌>' +./gradlew.bat assembleRelease +``` + +安装 APK 后进入“模式”页。目录只显示 Cognitive_Agent 的 `origin=preset` 模式; +执行目标默认是 `hardware`。queued/executing/terminal 事件每秒同步到 Web 和 Android, +任务终态后两端再读取完整执行结果并显示车端状态。 + +## 联调顺序 + +1. 先把 Android 构建参数 `orchestratorExecutionTarget` 设为 `dry_run`,验证模式目录、 + Android 点击、Web 状态和最终孪生状态。 +2. 再切换为 `hardware`,由服务器 A 本机批准 Android 终端。 +3. 在空载且可急停条件下依次验收零重力、小憩、轻松进出;核对 Web 和 Android 的 + execution_id、终态、CAN 回读一致。 diff --git a/src/cognitive_agent/application/mode_execution.py b/src/cognitive_agent/application/mode_execution.py new file mode 100644 index 0000000..59d443d --- /dev/null +++ b/src/cognitive_agent/application/mode_execution.py @@ -0,0 +1,231 @@ +"""Thread-safe remote mode execution state shared by Web and Android clients.""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import UTC, datetime +import threading +import uuid +from collections.abc import Callable +from typing import Any + + +TERMINAL_STATUSES = { + "succeeded", + "partially_succeeded", + "failed", + "cancelled", + "timed_out", +} + + +def _utc_now() -> str: + return datetime.now(UTC).isoformat().replace("+00:00", "Z") + + +class ModeExecutionStore: + """Keep a bounded in-memory execution/event log and run jobs off the HTTP thread.""" + + def __init__(self, max_events: int = 512, max_executions: int = 128) -> None: + self.max_events = max_events + self.max_executions = max_executions + self._lock = threading.RLock() + self._sequence = 0 + self._events: list[dict[str, Any]] = [] + self._executions: dict[str, dict[str, Any]] = {} + self._execution_order: list[str] = [] + self._idempotency: dict[tuple[str, str], str] = {} + + def create( + self, + *, + vehicle_id: str, + mode: dict[str, Any], + execution_target: str, + source: str, + source_device_id: str, + hardware_client_id: str | None, + idempotency_key: str | None, + runner: Callable[[], dict[str, Any]], + ) -> tuple[dict[str, Any], bool]: + normalized_key = (idempotency_key or "").strip() + idempotency_scope = (source_device_id, normalized_key) + with self._lock: + if normalized_key: + existing_id = self._idempotency.get(idempotency_scope) + if existing_id and existing_id in self._executions: + return self._public_execution_locked(existing_id, include_result=False), False + + execution_id = ( + "mode-exec-" + + datetime.now(UTC).strftime("%Y%m%d%H%M%S") + + "-" + + uuid.uuid4().hex[:8] + ) + now = _utc_now() + execution = { + "execution_id": execution_id, + "vehicle_id": vehicle_id, + "mode_id": str(mode["id"]), + "mode_name": str(mode["name"]), + "domains": list(mode.get("domains", [])), + "execution_target": execution_target, + "source": source, + "source_device_id": source_device_id, + "hardware_client_id": hardware_client_id, + "status": "queued", + "progress": 0, + "message": f"{mode['name']}已进入执行队列", + "created_at": now, + "updated_at": now, + "request_id": None, + "result": None, + "error": None, + } + self._executions[execution_id] = execution + self._execution_order.append(execution_id) + if normalized_key: + self._idempotency[idempotency_scope] = execution_id + self._trim_locked() + self._publish_locked(execution) + + worker = threading.Thread( + target=self._run, + args=(execution_id, runner), + name=f"mode-execution-{execution_id[-8:]}", + daemon=True, + ) + worker.start() + return self.get(execution_id, include_result=False), True + + def get(self, execution_id: str, *, include_result: bool = True) -> dict[str, Any]: + with self._lock: + if execution_id not in self._executions: + raise KeyError(execution_id) + return self._public_execution_locked(execution_id, include_result=include_result) + + def events(self, vehicle_id: str, after_sequence: int = 0) -> dict[str, Any]: + if after_sequence < 0: + raise ValueError("after sequence must not be negative") + with self._lock: + events = [ + deepcopy(event) + for event in self._events + if event["sequence"] > after_sequence + and event["execution"]["vehicle_id"] == vehicle_id + ] + active = [ + self._public_execution_locked(item_id, include_result=False) + for item_id in self._execution_order + if self._executions[item_id]["vehicle_id"] == vehicle_id + and self._executions[item_id]["status"] not in TERMINAL_STATUSES + ] + return { + "vehicle_id": vehicle_id, + "events": events, + "next_sequence": self._sequence, + "active_executions": active, + } + + def _run(self, execution_id: str, runner: Callable[[], dict[str, Any]]) -> None: + self._update( + execution_id, + status="executing", + progress=15, + message=f"正在执行{self._mode_name(execution_id)}", + ) + try: + result = runner() + result_status = str(result.get("status", "failed")) + if result_status == "succeeded": + execution_status = "succeeded" + progress = 100 + message = f"{self._mode_name(execution_id)}执行成功,车端状态已同步" + elif result_status == "partially_succeeded": + execution_status = "partially_succeeded" + progress = 100 + message = f"{self._mode_name(execution_id)}部分执行成功,请检查未完成设备" + else: + # SeatDemoService has detailed failure statuses such as + # execution_failed and validation_failed. The remote contract + # deliberately exposes one stable terminal value to clients. + execution_status = "failed" + progress = 100 + message = ( + f"{self._mode_name(execution_id)}执行未完成" + f"({result_status})" + ) + self._update( + execution_id, + status=execution_status, + progress=progress, + message=message, + request_id=result.get("request_id"), + result=result, + vehicle_state={ + "actuator_state": deepcopy(result.get("actuator_state", {})), + "domain_state": deepcopy(result.get("domain_state", {})), + }, + ) + except Exception as exc: # noqa: BLE001 - preserve an auditable terminal event + self._update( + execution_id, + status="failed", + progress=100, + message=f"{self._mode_name(execution_id)}执行失败:{exc}", + error={"type": type(exc).__name__, "message": str(exc)}, + ) + + def _mode_name(self, execution_id: str) -> str: + with self._lock: + execution = self._executions.get(execution_id) + return str(execution.get("mode_name", "功能模式")) if execution else "功能模式" + + def _update(self, execution_id: str, **changes: Any) -> None: + with self._lock: + execution = self._executions.get(execution_id) + if execution is None: + return + execution.update(changes) + execution["updated_at"] = _utc_now() + self._publish_locked(execution) + + def _publish_locked(self, execution: dict[str, Any]) -> None: + self._sequence += 1 + self._events.append( + { + "sequence": self._sequence, + "type": "mode.execution.updated", + "created_at": _utc_now(), + "execution": self._without_result(execution), + } + ) + if len(self._events) > self.max_events: + del self._events[: len(self._events) - self.max_events] + + def _public_execution_locked( + self, + execution_id: str, + *, + include_result: bool, + ) -> dict[str, Any]: + result = deepcopy(self._executions[execution_id]) + if not include_result: + result.pop("result", None) + return result + + @staticmethod + def _without_result(execution: dict[str, Any]) -> dict[str, Any]: + result = deepcopy(execution) + result.pop("result", None) + return result + + def _trim_locked(self) -> None: + while len(self._execution_order) > self.max_executions: + oldest_id = self._execution_order.pop(0) + self._executions.pop(oldest_id, None) + stale_keys = [ + key for key, value in self._idempotency.items() if value == oldest_id + ] + for key in stale_keys: + self._idempotency.pop(key, None) diff --git a/src/cognitive_agent/application/mode_profile.py b/src/cognitive_agent/application/mode_profile.py index 6a1b006..9f47cef 100644 --- a/src/cognitive_agent/application/mode_profile.py +++ b/src/cognitive_agent/application/mode_profile.py @@ -391,6 +391,15 @@ class ModeProfileRegistry: for profile in profiles ] + def get(self, mode_id: str, vehicle_id: str | None = None) -> ModeProfile: + clean_id = str(mode_id).strip() + if not clean_id: + raise KeyError(clean_id) + for profile in self._profiles_for_vehicle(vehicle_id): + if profile.id == clean_id: + return profile + raise KeyError(clean_id) + def match_text( self, utterance: str, diff --git a/src/cognitive_agent/application/seat_service.py b/src/cognitive_agent/application/seat_service.py index 899beeb..3bb2f90 100755 --- a/src/cognitive_agent/application/seat_service.py +++ b/src/cognitive_agent/application/seat_service.py @@ -393,6 +393,32 @@ class SeatDemoService: "vehicle_id": resolved_vehicle_id, } + def process_mode( + self, + mode_id: str, + *, + conversation_id: str, + execution_mode: str, + vehicle_id: str | None = None, + hardware_client_id: str | None = None, + hardware_access_bypass: bool = False, + ) -> dict[str, Any]: + """Execute one repository-backed mode without free-form control parameters.""" + + vehicle_profile = self.vehicle_profiles.get(vehicle_id) + profile = self.mode_profiles.get(mode_id, vehicle_profile.id) + return self.process( + f"进入{profile.name}", + provider="local", + conversation_id=conversation_id, + execution_mode=execution_mode, + approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"), + vehicle_id=vehicle_profile.id, + hardware_client_id=hardware_client_id, + hardware_access_bypass=hardware_access_bypass, + exact_mode_id=profile.id, + ) + def synchronize_hardware_state( self, conversation_id: str, @@ -655,6 +681,7 @@ class SeatDemoService: hardware_client_id: str | None = None, hardware_access_bypass: bool = True, reasoning_effort: str | None = None, + exact_mode_id: str | None = None, ) -> dict[str, Any]: # Creating a reusable scene is a configuration workflow. Synthesize # and validate it in dry-run even when the console currently targets @@ -672,6 +699,7 @@ class SeatDemoService: vehicle_id, simulation_fault, reasoning_effort, + exact_mode_id, ) if hardware_access_bypass or self.driver_only_ch1_execution_active: with self.hardware_execution.reserve(): @@ -684,6 +712,7 @@ class SeatDemoService: vehicle_id, simulation_fault, reasoning_effort, + exact_mode_id, ) authorized_client_id = self.hardware_access.ensure_authorized(hardware_client_id) with self.hardware_execution.reserve(): @@ -697,6 +726,7 @@ class SeatDemoService: vehicle_id, simulation_fault, reasoning_effort, + exact_mode_id, ) def _process_once( @@ -709,6 +739,7 @@ class SeatDemoService: vehicle_id: str | None = None, simulation_fault: str = "normal", reasoning_effort: str | None = None, + exact_mode_id: str | None = None, ) -> dict[str, Any]: utterance = utterance.strip() if not utterance: @@ -775,9 +806,13 @@ class SeatDemoService: vehicle_profile, ) context["mode_profiles"] = self.mode_profiles.public_modes(vehicle_profile.id) - matched_modes = self.mode_profiles.match_text( - utterance, - vehicle_id=vehicle_profile.id, + matched_modes = ( + [self.mode_profiles.get(exact_mode_id, vehicle_profile.id)] + if exact_mode_id is not None + else self.mode_profiles.match_text( + utterance, + vehicle_id=vehicle_profile.id, + ) ) traces: list[dict[str, Any]] = [] result: dict[str, Any] = { diff --git a/src/cognitive_agent/server.py b/src/cognitive_agent/server.py index ab86ba7..7b8c6f9 100755 --- a/src/cognitive_agent/server.py +++ b/src/cognitive_agent/server.py @@ -10,6 +10,7 @@ import mimetypes import os import re import socket +import uuid from http.cookies import CookieError, SimpleCookie from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -27,6 +28,7 @@ load_local_env(REPOSITORY_ROOT / ".env.auth", override=True) from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402 from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402 +from cognitive_agent.application.mode_execution import ModeExecutionStore # noqa: E402 from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402 from cognitive_agent.application.calibration.service import ( # noqa: E402 ApiError as CalibrationApiError, @@ -52,6 +54,16 @@ STATIC_ROOT = REPOSITORY_ROOT / "apps" / "web-console" mimetypes.add_type("application/manifest+json", ".webmanifest") mimetypes.add_type("model/gltf-binary", ".glb") REQUEST_ID_PATTERN = re.compile(r"^req-[a-z0-9-]+$") +MODE_EXECUTION_ID_PATTERN = re.compile(r"^mode-exec-[a-z0-9-]+$") +MODE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,79}$") +REMOTE_DEVICE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{2,79}$") +VEHICLE_MODE_PATH_PATTERN = re.compile( + r"^/api/v1/vehicles/(?P[a-z0-9][a-z0-9_-]{0,79})/" + r"(?Pmodes|mode-executions|events)$" +) +MODE_EXECUTION_PATH_PATTERN = re.compile( + r"^/api/v1/mode-executions/(?Pmode-exec-[a-z0-9-]+)$" +) PUBLIC_AUTH_PATHS = { "/login", "/login.html", @@ -78,6 +90,7 @@ def _lan_ipv4_address() -> str | None: class SeatDemoHandler(BaseHTTPRequestHandler): service = SeatDemoService() + mode_executions = ModeExecutionStore() gesture = GestureCapabilityBridge(REPOSITORY_ROOT) extension = SelfExtensionService() calibration = CalibrationService( @@ -151,6 +164,17 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path == "/api/runtime": self._json(self.service.runtime_info()) return + mode_path = VEHICLE_MODE_PATH_PATTERN.fullmatch(path) + if mode_path and mode_path.group("resource") == "modes": + self._serve_vehicle_modes(mode_path.group("vehicle_id")) + return + if mode_path and mode_path.group("resource") == "events": + self._serve_mode_events(parsed_url, mode_path.group("vehicle_id")) + return + execution_path = MODE_EXECUTION_PATH_PATTERN.fullmatch(path) + if execution_path: + self._serve_mode_execution(execution_path.group("execution_id")) + return if path in {"/api/hardware/seat-state", "/api/hardware/vehicle-state"}: query = parse_qs(parsed_url.query) conversation_id = str(query.get("conversation_id", [""])[0]) @@ -273,6 +297,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler): if path.startswith("/api/self-repair/"): self._handle_self_repair_post(path) return + mode_path = VEHICLE_MODE_PATH_PATTERN.fullmatch(path) + if mode_path and mode_path.group("resource") == "mode-executions": + self._create_mode_execution(mode_path.group("vehicle_id")) + return if path != "/api/requests": self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) return @@ -761,7 +789,142 @@ class SeatDemoHandler(BaseHTTPRequestHandler): raise ValueError("request body must be a JSON object") return payload + def _serve_vehicle_modes(self, vehicle_id: str) -> None: + try: + vehicle = self.service.vehicle_profiles.get(vehicle_id) + self._json( + { + "vehicle_id": vehicle.id, + "vehicle_name": vehicle.name, + "modes": self.service.mode_profiles.public_modes(vehicle.id), + } + ) + except KeyError: + self._json( + {"error": "vehicle_not_found", "message": "未找到指定车型。"}, + HTTPStatus.NOT_FOUND, + ) + + def _serve_mode_events(self, parsed_url: ParseResult, vehicle_id: str) -> None: + try: + self.service.vehicle_profiles.get(vehicle_id) + raw_after = parse_qs(parsed_url.query).get("after", ["0"])[0] + after = int(str(raw_after)) + self._json( + self.mode_executions.events(vehicle_id, after) + ) + except KeyError: + self._json( + {"error": "vehicle_not_found", "message": "未找到指定车型。"}, + HTTPStatus.NOT_FOUND, + ) + except ValueError as exc: + self._json( + {"error": "invalid_request", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + + def _serve_mode_execution(self, execution_id: str) -> None: + if not MODE_EXECUTION_ID_PATTERN.fullmatch(execution_id): + self._json({"error": "invalid_execution_id"}, HTTPStatus.BAD_REQUEST) + return + try: + self._json( + self.mode_executions.get(execution_id) + ) + except KeyError: + self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND) + + def _create_mode_execution(self, vehicle_id: str) -> None: + try: + payload = self._read_json_body(max_bytes=8192) + mode_id = str(payload.get("mode_id", "")).strip() + execution_target = str(payload.get("execution_target", "hardware")).strip() + source = str(payload.get("source", "android")).strip().lower() + source_device_id = str(payload.get("source_device_id", "")).strip().lower() + hardware_client_id = ( + str(payload.get("hardware_client_id", "")).strip().lower() or None + ) + idempotency_key = self.headers.get("Idempotency-Key", "").strip() + + if not MODE_ID_PATTERN.fullmatch(mode_id): + raise ValueError("mode_id is invalid") + if execution_target not in {"dry_run", "simulated_hardware", "hardware"}: + raise ValueError( + "execution_target must be dry_run, simulated_hardware, or hardware" + ) + if source not in {"android", "web"}: + raise ValueError("source must be android or web") + if not REMOTE_DEVICE_ID_PATTERN.fullmatch(source_device_id): + raise ValueError("source_device_id is invalid") + if idempotency_key and ( + len(idempotency_key) > 128 + or any(ord(character) < 33 or ord(character) > 126 for character in idempotency_key) + ): + raise ValueError("Idempotency-Key is invalid") + + vehicle = self.service.vehicle_profiles.get(vehicle_id) + profile = self.service.mode_profiles.get(mode_id, vehicle.id) + mode = profile.public_dict() + conversation_id = "conv-mode-" + uuid.uuid4().hex + loopback_decision = self._is_unproxied_loopback_request() + execution, created = self.mode_executions.create( + vehicle_id=vehicle.id, + mode=mode, + execution_target=execution_target, + source=source, + source_device_id=source_device_id, + hardware_client_id=hardware_client_id, + idempotency_key=idempotency_key or None, + runner=lambda: self.service.process_mode( + profile.id, + conversation_id=conversation_id, + execution_mode=execution_target, + vehicle_id=vehicle.id, + hardware_client_id=hardware_client_id, + hardware_access_bypass=loopback_decision, + ), + ) + self._json( + execution, + HTTPStatus.ACCEPTED if created else HTTPStatus.OK, + ) + except KeyError: + self._json( + {"error": "not_found", "message": "未找到指定车型或功能模式。"}, + HTTPStatus.NOT_FOUND, + ) + except (ValueError, json.JSONDecodeError) as exc: + self._json( + {"error": "invalid_request", "message": str(exc)}, + HTTPStatus.BAD_REQUEST, + ) + def _authorize(self, path: str) -> bool: + device_paths = { + "/api/hardware/access/request", + "/api/hardware/access/status", + "/api/hardware/access/release", + } + device_api_path = path.startswith("/api/v1/") or path in device_paths + device_token_configured = bool( + os.getenv("COGNITIVE_AGENT_DEVICE_API_TOKEN", "").strip() + ) + if device_api_path and device_token_configured: + if ( + self._device_api_authorized() + or self._session_username() is not None + or self._is_unproxied_loopback_request() + ): + return True + self._json( + { + "error": "unauthorized", + "message": "该远程设备没有有效的 API 令牌。", + }, + HTTPStatus.UNAUTHORIZED, + ) + return False if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS: return True if self._session_username() is not None: @@ -779,6 +942,18 @@ class SeatDemoHandler(BaseHTTPRequestHandler): self._redirect(f"/login?next={quote(requested, safe='/')}") return False + def _device_api_authorized(self) -> bool: + expected = os.getenv("COGNITIVE_AGENT_DEVICE_API_TOKEN", "").strip() + if not expected: + return False + header = self.headers.get("Authorization", "") + scheme, separator, token = header.partition(" ") + return bool( + separator + and scheme.lower() == "bearer" + and hmac.compare_digest(token.strip(), expected) + ) + def _login(self) -> None: if not self.auth.config.enabled: self._json({"enabled": False, "authenticated": True}) diff --git a/tests/e2e/test_seat_web_api.py b/tests/e2e/test_seat_web_api.py index 6bcc4c2..64d9f95 100755 --- a/tests/e2e/test_seat_web_api.py +++ b/tests/e2e/test_seat_web_api.py @@ -47,6 +47,8 @@ class SeatWebApiTests(unittest.TestCase): cls.service = service cls.original_service = SeatDemoHandler.service SeatDemoHandler.service = service + cls.original_device_token = os.environ.get("COGNITIVE_AGENT_DEVICE_API_TOKEN") + os.environ["COGNITIVE_AGENT_DEVICE_API_TOKEN"] = "test-device-api-token" cls.original_auth = SeatDemoHandler.auth SeatDemoHandler.auth = AuthRuntime( AuthConfig( @@ -77,6 +79,10 @@ class SeatWebApiTests(unittest.TestCase): cls.thread.join(timeout=2) SeatDemoHandler.service = cls.original_service SeatDemoHandler.auth = cls.original_auth + if cls.original_device_token is None: + os.environ.pop("COGNITIVE_AGENT_DEVICE_API_TOKEN", None) + else: + os.environ["COGNITIVE_AGENT_DEVICE_API_TOKEN"] = cls.original_device_token cls.temporary_directory.cleanup() def setUp(self) -> None: @@ -177,18 +183,18 @@ operations: def test_static_console_and_runtime_endpoint(self) -> None: with self.opener.open(f"{self.base_url}/", timeout=3) as response: html = response.read().decode("utf-8") - with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-67", timeout=3) as response: + with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-69", timeout=3) as response: app_script = response.read().decode("utf-8") with self.opener.open( f"{self.base_url}/gesture-control.js?v=android-pad-7", timeout=3 ) as response: gesture_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.js?v=desktop-refactor-28", timeout=3 + f"{self.base_url}/console-v2.js?v=desktop-refactor-30", timeout=3 ) as response: console_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/console-v2.css?v=desktop-refactor-87", timeout=3 + f"{self.base_url}/console-v2.css?v=desktop-refactor-89", timeout=3 ) as response: console_styles = response.read().decode("utf-8") with self.opener.open( @@ -219,15 +225,16 @@ operations: self.assertIn("CMD 0x431 · FB 0x41A · CH1", html) self.assertIn("CMD 0x431 · FB 0x388 · CH1", html) self.assertNotIn("透明车体 · 实体设备", html) - self.assertIn('src="/app.js?v=dual-seat-67"', html) + self.assertIn('src="/app.js?v=dual-seat-69"', html) self.assertIn('href="/styles.css?v=dual-seat-9"', html) self.assertIn('src="/gesture-control.js?v=android-pad-7"', html) self.assertNotIn('class="vehicle-system-hud"', html) - self.assertIn('src="/console-v2.js?v=desktop-refactor-28"', html) - self.assertIn('href="/console-v2.css?v=desktop-refactor-87"', html) + self.assertIn('src="/console-v2.js?v=desktop-refactor-30"', html) + self.assertIn('href="/console-v2.css?v=desktop-refactor-89"', html) self.assertIn('src="/self-extension.js?v=extension-18"', html) self.assertIn('href="/self-extension.css?v=extension-12"', html) self.assertNotIn("滚动查看更多", html) + self.assertIn('aria-label="执行车舱控制指令"', html) self.assertIn('sessionStorage.getItem("cognitive-agent.demo-role")', console_script) self.assertIn('id="v2SidebarToggle"', console_script) @@ -534,13 +541,153 @@ operations: ) ) + def test_remote_mode_api_executes_repository_preset_and_publishes_events(self) -> None: + modes_url = f"{self.base_url}/api/v1/vehicles/denza_n7/modes" + with self.opener.open(modes_url, timeout=3) as response: + catalog = json.loads(response.read().decode("utf-8")) + self.assertEqual(catalog["vehicle_id"], "denza_n7") + self.assertIn("zero_gravity", {mode["id"] for mode in catalog["modes"]}) + + create = urllib.request.Request( + f"{self.base_url}/api/v1/vehicles/denza_n7/mode-executions", + method="POST", + headers={ + "Content-Type": "application/json", + "Idempotency-Key": "web-api-test-zero-gravity", + }, + data=json.dumps( + { + "mode_id": "zero_gravity", + "execution_target": "dry_run", + "source": "android", + "source_device_id": "n7-cockpit-test", + } + ).encode("utf-8"), + ) + with self.opener.open(create, timeout=3) as response: + accepted = json.loads(response.read().decode("utf-8")) + execution_id = accepted["execution_id"] + self.assertEqual(accepted["mode_id"], "zero_gravity") + + terminal = None + for _ in range(100): + with self.opener.open( + f"{self.base_url}/api/v1/mode-executions/{execution_id}", timeout=3 + ) as response: + terminal = json.loads(response.read().decode("utf-8")) + if terminal["status"] in { + "succeeded", "partially_succeeded", "failed", "cancelled", "timed_out" + }: + break + threading.Event().wait(0.02) + self.assertIsNotNone(terminal) + self.assertEqual(terminal["status"], "succeeded") + self.assertEqual(terminal["result"]["matched_modes"][0]["id"], "zero_gravity") + + with self.opener.open( + f"{self.base_url}/api/v1/vehicles/denza_n7/events?after=0", timeout=3 + ) as response: + events = json.loads(response.read().decode("utf-8")) + matching = [ + event for event in events["events"] + if event["execution"]["execution_id"] == execution_id + ] + self.assertGreaterEqual(len(matching), 2) + self.assertEqual(matching[-1]["execution"]["status"], "succeeded") + + nap_create = urllib.request.Request( + f"{self.base_url}/api/v1/vehicles/denza_n7/mode-executions", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "mode_id": "nap", + "execution_target": "dry_run", + "source": "android", + "source_device_id": "n7-cockpit-test", + } + ).encode("utf-8"), + ) + with self.opener.open(nap_create, timeout=3) as response: + nap_accepted = json.loads(response.read().decode("utf-8")) + nap_terminal = None + for _ in range(100): + with self.opener.open( + f"{self.base_url}/api/v1/mode-executions/" + f"{nap_accepted['execution_id']}", + timeout=3, + ) as response: + nap_terminal = json.loads(response.read().decode("utf-8")) + if nap_terminal["status"] in { + "succeeded", "partially_succeeded", "failed", "cancelled", "timed_out" + }: + break + threading.Event().wait(0.02) + self.assertEqual(nap_terminal["status"], "succeeded") + self.assertEqual( + [mode["id"] for mode in nap_terminal["result"]["matched_modes"]], + ["nap"], + ) + self.assertEqual( + nap_terminal["vehicle_state"]["actuator_state"]["passenger"] + ["position"]["backrest_recline"]["position_pct"], + 81, + ) + + def test_remote_mode_api_rejects_unknown_mode(self) -> None: + request = urllib.request.Request( + f"{self.base_url}/api/v1/vehicles/denza_n7/mode-executions", + method="POST", + headers={"Content-Type": "application/json"}, + data=json.dumps( + { + "mode_id": "not_a_real_mode", + "execution_target": "dry_run", + "source": "android", + "source_device_id": "n7-cockpit-test", + } + ).encode("utf-8"), + ) + with self.assertRaises(urllib.error.HTTPError) as caught: + self.opener.open(request, timeout=3) + self.assertEqual(caught.exception.code, 404) + + def test_device_bearer_token_is_scoped_to_remote_mode_api(self) -> None: + device = urllib.request.build_opener() + modes = urllib.request.Request( + f"{self.base_url}/api/v1/vehicles/denza_n7/modes", + headers={"Authorization": "Bearer test-device-api-token"}, + ) + with device.open(modes, timeout=3) as response: + catalog = json.loads(response.read().decode("utf-8")) + self.assertEqual(catalog["vehicle_id"], "denza_n7") + + runtime = urllib.request.Request( + f"{self.base_url}/api/runtime", + headers={"Authorization": "Bearer test-device-api-token"}, + ) + with self.assertRaises(urllib.error.HTTPError) as caught: + device.open(runtime, timeout=3) + self.assertEqual(caught.exception.code, 401) + + bad_token = urllib.request.Request( + f"{self.base_url}/api/v1/vehicles/denza_n7/modes", + headers={ + "Authorization": "Bearer wrong-device-token", + "X-Forwarded-For": "192.0.2.10", + }, + ) + with self.assertRaises(urllib.error.HTTPError) as caught: + device.open(bad_token, timeout=3) + self.assertEqual(caught.exception.code, 401) + def test_self_extension_vehicle_success_contract_is_strict(self) -> None: with self.opener.open( f"{self.base_url}/self-extension.js?v=extension-18", timeout=3 ) as response: extension_script = response.read().decode("utf-8") with self.opener.open( - f"{self.base_url}/app.js?v=dual-seat-67", timeout=3 + f"{self.base_url}/app.js?v=dual-seat-69", timeout=3 ) as response: app_script = response.read().decode("utf-8") diff --git a/tests/unit/test_mode_execution.py b/tests/unit/test_mode_execution.py new file mode 100644 index 0000000..7477559 --- /dev/null +++ b/tests/unit/test_mode_execution.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import threading +import unittest + +from cognitive_agent.application.mode_execution import ModeExecutionStore + + +class ModeExecutionStoreTests(unittest.TestCase): + def test_service_failure_status_is_normalized_for_remote_clients(self) -> None: + store = ModeExecutionStore() + finished = threading.Event() + + def run() -> dict[str, object]: + finished.set() + return {"status": "execution_failed", "request_id": "req-failed"} + + execution, created = store.create( + vehicle_id="denza_n7", + mode={"id": "nap", "name": "小憩模式", "domains": ["seat"]}, + execution_target="dry_run", + source="android", + source_device_id="n7-cockpit-test", + hardware_client_id=None, + idempotency_key="failure-normalization", + runner=run, + ) + self.assertTrue(created) + self.assertTrue(finished.wait(1)) + terminal = self._wait_terminal(store, execution["execution_id"]) + self.assertEqual(terminal["status"], "failed") + self.assertEqual(terminal["result"]["status"], "execution_failed") + + def test_idempotency_key_returns_the_original_execution(self) -> None: + store = ModeExecutionStore() + release = threading.Event() + + def run() -> dict[str, object]: + release.wait(1) + return {"status": "succeeded", "request_id": "req-ok"} + + arguments = { + "vehicle_id": "denza_n7", + "mode": {"id": "nap", "name": "小憩模式", "domains": ["seat"]}, + "execution_target": "dry_run", + "source": "android", + "source_device_id": "n7-cockpit-test", + "hardware_client_id": None, + "idempotency_key": "same-tap", + "runner": run, + } + first, first_created = store.create(**arguments) + second, second_created = store.create(**arguments) + release.set() + + self.assertTrue(first_created) + self.assertFalse(second_created) + self.assertEqual(first["execution_id"], second["execution_id"]) + + @staticmethod + def _wait_terminal( + store: ModeExecutionStore, + execution_id: str, + ) -> dict[str, object]: + for _ in range(100): + execution = store.get(execution_id) + if execution["status"] in { + "succeeded", "partially_succeeded", "failed", "cancelled", "timed_out" + }: + return execution + threading.Event().wait(0.01) + raise AssertionError("mode execution did not reach a terminal status") + + +if __name__ == "__main__": + unittest.main()