Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78d758d3fb | |||
| fd3c219c39 | |||
| 9ce64b3a01 | |||
| 32379a1573 | |||
| 3701192a11 | |||
| 3bceb0d5a1 | |||
| 156b10fda1 | |||
| dc7c91800e |
+15
@@ -6,8 +6,23 @@ __pycache__/
|
||||
artifacts/runs/
|
||||
artifacts/server/
|
||||
artifacts/tmp/
|
||||
artifacts/self-repair/
|
||||
artifacts/*.log
|
||||
tmp/*.log
|
||||
.codex_tmp/
|
||||
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/logs/
|
||||
firmware/t2g/Seat_Controller_demo/Tests/manual_hil/seat_function_runtime/
|
||||
tools/zcan/seat_function_runtime/
|
||||
artifacts/hil/*_server_*.log
|
||||
artifacts/hil/frontend_service_*.log
|
||||
tmp/can-matrix-artifact/
|
||||
tmp/reference-project-code-CAN_DEV/
|
||||
firmware/t2g/Seat_Controller_demo/IAR/settings/
|
||||
firmware/t2g/Seat_Controller_demo/IAR/Seat_Controller_demo.eww
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/.Seat_Controller_demo_build_cache
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/.ninja_deps
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/.ninja_log
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/BrowseInfo/
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/List/
|
||||
firmware/t2g/Seat_Controller_demo/IAR/rev_d/Obj/
|
||||
firmware/t2g/tviibh8m/src/examples/crypto/ClientRunsOnCM7_0/RSA/key_data/private_key*.pem
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
config/migration.psd1
|
||||
secrets/
|
||||
dist/
|
||||
artifacts/
|
||||
apk/
|
||||
@@ -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 缓存。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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'
|
||||
}
|
||||
Vendored
+2
@@ -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.
Binary file not shown.
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.7.3' apply false
|
||||
}
|
||||
@@ -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 = ''
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=false
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
Vendored
+252
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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.'
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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'
|
||||
@@ -77,6 +77,8 @@ artifacts/runs/<request_id>/
|
||||
|
||||
## 可执行领域
|
||||
|
||||
座椅与空调等车身设备使用同一 ZLG CAN 盒双通道联动时,参见 [T2G 座椅与车身双通道 CAN 联动](docs/workflows/t2g-cabin-dual-channel-can.md)。
|
||||
|
||||
当前已接入四个相互隔离的可执行领域:
|
||||
|
||||
- 座椅:所有热管理、位置移动和姿态预设能力仅面向副驾;未指定座位时默认副驾,显式主驾请求拒绝执行。
|
||||
@@ -111,6 +113,10 @@ config/gesture/default.yaml
|
||||
|
||||
同一局域网的其他电脑需要体验时,使用 `make dev-lan` 监听全部网络接口。服务启动日志会打印实际 LAN 地址,例如 `http://172.20.10.5:8000`。前端和 API 使用同源访问,不需要 CORS,也不会把 `OPENAI_API_KEY` 发送到浏览器。若没有启用 `.env.auth`,任何可达设备都能触发服务端 GPT 请求,因此未认证的 LAN 模式只应在可信网络内临时演示。
|
||||
|
||||
N7Cockpit Android 远程执行仓库预设模式时,Android 与 Web 使用服务器 A 的同一套
|
||||
模式任务和增量事件接口,不进行浏览器自动点击。双服务器地址、设备令牌、硬件本机
|
||||
审批和联调步骤见 [N7Cockpit 远程功能模式接入](docs/workflows/n7cockpit-remote-modes.md)。
|
||||
|
||||
## 认证与公网体验
|
||||
|
||||
公网演示必须启用 `.env.auth` 中的服务端认证。密码使用 PBKDF2-SHA256 哈希保存;认证成功后签发有有效期的 HMAC 会话 Cookie,Cookie 使用 `HttpOnly` 和 `SameSite=Strict`,经 HTTPS 代理时自动增加 `Secure`。登录失败按来源 IP 限流,GPT 请求按体验账号限制每分钟调用次数。未登录访问页面会跳转 `/login`,未登录 API 请求返回 `401`。
|
||||
@@ -134,4 +140,4 @@ PYTHONPATH=src python -m cognitive_agent.auth generate-secret
|
||||
|
||||
完整能力边界、矩阵解释和验收用例见 [座椅阶段一交付](docs/workflows/seat-stage1-delivery.md)。
|
||||
|
||||
已验证的 T2G 加热、通风和四电机能力已完整纳入 `firmware/t2g`,包括 IAR 工程及仓库内 `common/tviibh8m` 构建依赖。M0/M1/M2/M3 分别对应水平、坐盆、靠背和腿托;真实硬件请求先被动核对新版周期反馈与 480/374/917/647 Hall 量程,未检测到匹配固件时默认通过 IAR C-SPY 自动烧写并复验,再发送 CAN 并等待真实回读。当前前端、能力注册、编译、安全门禁和状态反馈只允许副驾 `passenger/front_right`;固件中的 `Driver_Seat_*` 仅是板端既有协议字段名。构建、ZLG bridge、接线、安全门禁、前端测试集和已知量程归零见 [T2G 座椅真实硬件台架接入](docs/workflows/t2g-seat-hardware-bench.md) 与 [前端能力测试集](docs/testing/t2g-seat-frontend-test-suite.md)。
|
||||
已验证的 T2G 加热、通风、四电机和原生动态模式能力已完整纳入 `firmware/t2g`,包括 IAR 工程及仓库内 `common/tviibh8m` 构建依赖。M0/M1/M2/M3 分别对应水平、坐盆、靠背和腿托;revision 13通过`0x306`反馈带斜向耦合补偿的木马模式与四轴全程联动动态摇篮的阶段/循环状态。木马模式围绕中间姿态在`[270,250,460,400]`和`[370,130,540,460]`之间连续俯仰往复:坐盆峰峰行程120 Hall、约18 mm垂直分量,水平峰峰行程100 Hall、90%巡航Duty,用额外水平行程抵消坐盆斜向运动约16.4 mm的反向水平分量;参数集中在`seat_dynamic_mode_config.h`并由N7长期库镜像。动态模式收到一次启动命令后由ECU连续循环,收到停止/全停命令或累计运行10分钟时停止;目标到位式阶段在底层堵转/无运动保护继续生效的前提下使用独立保护窗口。真实硬件请求先被动核对新版周期反馈与 480/374/917/647 Hall 量程,未检测到匹配固件时默认通过 IAR C-SPY 自动烧写并复验,再发送 CAN 并等待真实回读。当前前端、能力注册、编译、安全门禁和状态反馈只允许副驾 `passenger/front_right`;固件中的 `Driver_Seat_*` 仅是板端既有协议字段名。构建、ZLG bridge、接线、安全门禁、前端测试集和已知量程归零见 [T2G 座椅真实硬件台架接入](docs/workflows/t2g-seat-hardware-bench.md) 与 [前端能力测试集](docs/testing/t2g-seat-frontend-test-suite.md)。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user