feat: integrate N7Cockpit remote mode control

This commit is contained in:
vee
2026-08-13 10:36:24 +08:00
parent fd3c219c39
commit 78d758d3fb
108 changed files with 23150 additions and 16 deletions
+11
View File
@@ -0,0 +1,11 @@
.gradle/
local.properties
**/build/
*.iml
.idea/
.DS_Store
config/migration.psd1
secrets/
dist/
artifacts/
apk/
+212
View File
@@ -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 <adb序列号>`。远程调试包名为
`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 缓存。
+54
View File
@@ -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
```
+78
View File
@@ -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'
}
+2
View File
@@ -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[]); }
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<application
android:allowBackup="false"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|density|fontScale|uiMode"
android:exported="true"
android:screenOrientation="sensorLandscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".BootReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<service android:name=".SelfActivationService" android:exported="false" />
<service
android:name=".MascotMusicService"
android:exported="false"
android:foregroundServiceType="mediaPlayback"
android:stopWithTask="false" />
<service
android:name=".MascotOverlayService"
android:exported="false"
android:stopWithTask="false" />
</application>
</manifest>
Binary file not shown.
Binary file not shown.
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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.
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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 ? "开启" : "关闭"));
}
}
@@ -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);
}
}
@@ -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.
*
* <p>Icons are vector geometry rendered by Canvas rather than Unicode glyphs,
* which keeps their appearance consistent across head-unit system fonts.</p>
*/
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);
}
}
@@ -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.
*
* <p>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.</p>
*/
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));
}
}
@@ -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);
}
}
}
@@ -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<CockpitNavigationButton> 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);
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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.</p>
*/
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));
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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.
*
* <p>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.</p>
*/
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;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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.
*
* <p>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.</p>
*/
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<WeakReference<Listener>> 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<Listener> 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<Listener> reference : LISTENERS) {
Listener listener = reference.get();
if (listener == null) {
LISTENERS.remove(reference);
} else {
visitor.visit(listener);
}
}
}
private static boolean contains(Listener listener) {
Iterator<WeakReference<Listener>> 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);
}
}
@@ -0,0 +1,63 @@
package com.seeker.n7cockpit;
/**
* Product-level timing policy based on interaction frequency and operational significance.
*
* <p>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.</p>
*/
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;
}
}
}
@@ -0,0 +1,381 @@
package com.seeker.n7cockpit;
import android.os.SystemClock;
/**
* Turns cockpit actions into a continuous mascot performance.
*
* <p>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.</p>
*
* <p>Idle motion stays deliberately restrained. Several quiet procedural micro-performances
* provide variety; large turns and raised-arm excerpts are reserved for explicit actions.</p>
*/
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);
}
}
@@ -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}.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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<float[]> SAMPLE_A = ThreadLocal.withInitial(() -> new float[4]);
private static final ThreadLocal<float[]> SAMPLE_B = ThreadLocal.withInitial(() -> new float[4]);
private final Map<String, Clip> clips;
private final int jointCount;
private MascotClipLibrary(Map<String, Clip> 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<String, Integer> 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<String> 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<String, Clip> 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();
}
}
@@ -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.
*
* <p>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.</p>
*/
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<Exclusion> 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<Exclusion> 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<Float> xSamples = samples(safeMinX, safeMaxX, step, preferredX);
ArrayList<Float> 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<Exclusion> 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<Float> samples(
float minimum, float maximum, float step, float preferred) {
ArrayList<Float> 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<Float> 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;
}
}
}
@@ -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}.
*
* <p>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.</p>
*/
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<String, Integer> 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();
}
}
@@ -0,0 +1,117 @@
package com.seeker.n7cockpit;
/**
* Hand shape library for the mascot.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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);
}
}
@@ -0,0 +1,132 @@
package com.seeker.n7cockpit;
/**
* Shared, host-independent interpretation of mascot touches.
*
* <p>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.</p>
*/
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));
}
}
@@ -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}.
*
* <p>This service intentionally analyzes only the PCM generated by {@link AudioReactivePlayer}.
* It does not capture another application's audio output.</p>
*/
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);
}
}
@@ -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();
}
}
}
@@ -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};
}
@@ -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.
*
* <p>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.</p>
*/
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 <em>model joint</em>, 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;
}
}
@@ -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));
}
}
@@ -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.
*
* <p>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 <em>rest</em> 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.</p>
*
* <p>{@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.</p>
*/
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.
*
* <p>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.</p>
*/
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.
*
* <p>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.</p>
*/
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];
}
}
@@ -0,0 +1,237 @@
package com.seeker.n7cockpit;
/**
* Secondary motion for the mascot's hair and cape.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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<Strand> 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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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 网关已关闭");
}
}
}
@@ -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<String, String> vehicle = new LinkedHashMap<>();
private final Map<String, String> 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;
}
}
@@ -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<Mode> 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<String> domains;
final boolean preset;
Mode(String id, String name, String description, List<String> 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<String> 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<String> 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<String> 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<Execution> executions;
final List<Execution> activeExecutions;
Events(long nextSequence, List<Execution> executions,
List<Execution> 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<Mode> 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<Execution> 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<Execution> 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);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,210 @@
package com.seeker.n7cockpit;
/**
* Minimal quaternion helpers for the mascot animation runtime.
*
* <p>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.</p>
*/
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.
*
* <p>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.</p>
*/
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)));
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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)
+ " </dev/null >/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("'", "'\\''");
}
}
@@ -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);
}
}
@@ -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() {}
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -0,0 +1,35 @@
package com.seeker.n7cockpit;
/**
* Small asynchronous boundary between the cockpit UI and vehicle hardware.
*
* <p>Callbacks are delivered on a gateway worker thread. Android views must marshal them back
* to the main thread (for example with {@code Activity.runOnUiThread}).</p>
*/
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();
}
@@ -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.
*
* <p>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.</p>
*/
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.
*
* <p>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.</p>
*/
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);
}
}
}
@@ -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;
}
}
@@ -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.
*
* <p>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.</p>
*/
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<String, Node> 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<String, VehicleGlTexture> deduplicated = new HashMap<>();
ArrayList<VehicleGlTexture> 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));
}
}
File diff suppressed because it is too large Load Diff
@@ -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<String, String> 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<String, String> 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<String, String> vehicle = parseFields("VEHICLE", vehicleResponse);
Map<String, String> 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) + "…";
}
}
@@ -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.
*
* <p>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.</p>
*/
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<Door, Boolean> doorStates = new EnumMap<>(Door.class);
private final EnumMap<WindowPosition, Integer> 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;
}
}
@@ -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<String, String> vehicle;
private final Map<String, String> climate;
private final boolean simulated;
private final String source;
private final long capturedAtMillis;
private final long revision;
private final String lastAction;
VehicleSnapshot(Map<String, String> vehicle, Map<String, String> 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<String, String> vehicle, Map<String, String> climate,
boolean simulated, String source) {
return new VehicleSnapshot(vehicle, climate, simulated, source,
System.currentTimeMillis(), 0L, "");
}
static VehicleSnapshot create(Map<String, String> vehicle, Map<String, String> climate,
boolean simulated, String source, long revision, String lastAction) {
return new VehicleSnapshot(vehicle, climate, simulated, source,
System.currentTimeMillis(), revision, lastAction);
}
public Map<String, String> vehicle() {
return vehicle;
}
public Map<String, String> climate() {
return climate;
}
public Map<String, String> getVehicle() {
return vehicle;
}
public Map<String, String> 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<String, String> immutableCopy(Map<String, String> source) {
if (source == null || source.isEmpty()) return Collections.emptyMap();
return Collections.unmodifiableMap(new LinkedHashMap<>(source));
}
private static String value(Map<String, String> values, String key) {
String result = values.get(key);
return result == null || result.isEmpty() ? "?" : result;
}
private static float floatValue(Map<String, String> 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<String, String> 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<String, String> 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;
}
}
@@ -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<Position, Integer> percentages = new EnumMap<>(Position.class);
private final EnumMap<Position, Boolean> positionEnabled = new EnumMap<>(Position.class);
private final EnumMap<Position, CharSequence> 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());
}
}
@@ -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() {}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#071017" android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#5EE6D5" android:pathData="M54,17c20.4,0 37,16.6 37,37s-16.6,37 -37,37 -37,-16.6 -37,-37 16.6,-37 37,-37z"/>
<path android:fillColor="#071017" android:pathData="M35,59c0,-15 8.5,-27 19,-27s19,12 19,27c0,10.5 -8.5,17 -19,17s-19,-6.5 -19,-17z"/>
<path android:fillColor="#F3F7F8" android:pathData="M43,53c3.9,0 7,3.1 7,7s-3.1,7 -7,7 -7,-3.1 -7,-7 3.1,-7 7,-7zM65,53c3.9,0 7,3.1 7,7s-3.1,7 -7,7 -7,-3.1 -7,-7 3.1,-7 7,-7z"/>
<path android:fillColor="#FFB968" android:pathData="M49,30l5,-12 5,12z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3v10.55c-0.59,-0.34 -1.27,-0.55 -2,-0.55c-2.21,0 -4,1.34 -4,3s1.79,3 4,3s4,-1.34 4,-3V7h4V3h-6z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="cockpit_background">#071017</color>
<color name="cockpit_surface">#0E1B25</color>
<color name="cockpit_surface_elevated">#152733</color>
<color name="cockpit_primary">#5EE6D5</color>
<color name="cockpit_text">#F3F7F8</color>
<color name="cockpit_muted">#9BB0B8</color>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">N7 智舱</string>
<string name="overlay_channel_name">桌宠悬浮服务</string>
<string name="overlay_notification_title">派蒙正在陪伴</string>
<string name="overlay_notification_text">点击返回智舱,桌宠可拖动和缩放</string>
<string name="restore_channel_name">车辆控制恢复</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:colorAccent">@color/cockpit_primary</item>
<item name="android:navigationBarColor">@color/cockpit_background</item>
<item name="android:statusBarColor">@color/cockpit_background</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:windowDisablePreview">true</item>
</style>
</resources>
@@ -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));
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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<String> 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();
}
}
}
@@ -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<VehicleSnapshot> 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<Boolean> success = new AtomicReference<>(false);
gateway.sendCommand(command, (ok, detail) -> {
success.set(ok);
done.countDown();
});
assertTrue(done.await(2, TimeUnit.SECONDS));
assertTrue(success.get());
}
}
@@ -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"));
}
}
@@ -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<String, String> 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"));
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.7.3' apply false
}
+28
View File
@@ -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 = ''
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=false
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -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
+252
View File
@@ -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" "$@"
+94
View File
@@ -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
+192
View File
@@ -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 "(?<major>\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.'
+31
View File
@@ -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
}
@@ -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
}
@@ -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.'
@@ -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"
+57
View File
@@ -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
}
+150
View File
@@ -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
}
@@ -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
}
@@ -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 "(?<major>\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 }
+24
View File
@@ -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'