# EZOpenSDK Android 最佳实践

本文档定义了各功能模块的 UI 规范、业务逻辑和实现参考。生成代码时必须遵循对应模块的规范。

---

# 一、设备列表页面

## 1.1 功能概述

设备列表是用户登录后的主页面，以宫格卡片形式展示用户账号下的所有设备。

### 核心功能
- 宫格卡片展示（每行 2 个设备）
- 设备封面（16:9 比例，抓图获取，Bitmap 内存+磁盘持久化缓存）
- 设备名称、在线状态圆点图标
- 搜索框（设备名称/序列号模糊查询，带清除按钮）
- 下拉刷新、上拉加载更多
- 退出登录入口（导航栏左侧）、设备添加（配网）入口（导航栏右侧）

## 1.2 UI 规范

### 1.2.1 页面结构（从上到下）

```
┌─────────────────────────────────┐
│ [🚪退出]     标题居中     [➕添加]│
├─────────────────────────────────┤
│ [🔍 搜索框: 序列号或设备名称  ✕] │
├─────────────────────────────────┤
│ ┌──────────┐  ┌──────────┐     │
│ │ 封面16:9 🟢│  │ 封面16:9 ⚫│     │
│ ├──────────┤  ├──────────┤     │
│ │ 设备名称  │  │ 设备名称  │     │
│ └──────────┘  └──────────┘     │
│         (上拉加载更多)           │
└─────────────────────────────────┘
```

### 1.2.2 配色规范

```xml
<color name="primary">#1565C0</color>         <!-- 主色 -->
<color name="background">#F5F7FA</color>      <!-- 页面背景 -->
<color name="surface">#FFFFFF</color>         <!-- 卡片/工具栏背景 -->
<color name="text_primary">#1A1A2E</color>    <!-- 主文字 -->
<color name="text_secondary">#5A6070</color>  <!-- 次要文字 -->
<color name="text_hint">#9E9E9E</color>       <!-- 提示文字 -->
<color name="divider">#E8EAF0</color>         <!-- 分割线/边框 -->
<color name="status_online">#4CAF50</color>   <!-- 在线状态 -->
<color name="status_offline">#9E9E9E</color>  <!-- 离线状态 -->
<color name="card_bg">#FFFFFF</color>         <!-- 卡片背景 -->
<color name="toolbar_bg">#FFFFFF</color>      <!-- 工具栏背景 -->
<color name="ripple">#1A1565C0</color>        <!-- 点击波纹 -->
```

### 1.2.3 尺寸规范

```xml
<dimen name="spacing_xs">4dp</dimen>     <!-- 卡片间距 -->
<dimen name="spacing_sm">8dp</dimen>     <!-- 小间距 -->
<dimen name="spacing_md">16dp</dimen>    <!-- 中间距 -->
<dimen name="card_radius">12dp</dimen>   <!-- 卡片圆角 -->
<dimen name="card_elevation">2dp</dimen> <!-- 卡片阴影 -->
<dimen name="toolbar_height">56dp</dimen><!-- 工具栏高度 -->
<dimen name="text_body">14sp</dimen>     <!-- 正文字号 -->
<dimen name="text_caption">12sp</dimen>  <!-- 标签字号 -->
```

### 1.2.4 设备卡片规范

- 布局：`MaterialCardView`（12dp 圆角）+ `ConstraintLayout`
- 封面比例：**必须使用 `layout_constraintDimensionRatio="16:9"` 强制 16:9**，不能用 `adjustViewBounds`
- 封面占位：渐变背景 drawable（`#E8EAF0` → `#F0F2F6`，135°）+ 居中 44dp 图片图标，背景和图标分离实现
- 在线状态：右上角 **12dp 圆点图标**（绿色在线/灰色离线，1.5dp 白色描边），位于封面右上角 margin 8dp，不使用文字标签
- 离线设备：半透明遮罩（`#60000000`）+ 居中 24dp wifi-off 图标 + "设备离线了" 文字
- 设备名称：单行，超长省略号，14sp；名称区域上下间距均为 8dp（顶部距封面 = 底部距卡片）

### 1.2.5 搜索框规范

- 使用普通 `EditText`（**不要用 `TextInputLayout`**，会导致 hint 显示不全）
- 白色背景 + 1dp 边框 + **全圆角（radius = 高度/2，即 40dp 高度对应 20dp 圆角）**
- 左侧搜索图标使用 `drawableStart`
- 右侧清除按钮：输入文字时显示，点击清空文本、隐藏软键盘并刷新列表；无文字时隐藏
- 清除按钮通过 `setCompoundDrawablesRelativeWithIntrinsicBounds` 动态控制显示/隐藏
- 点击检测通过 `OnTouchListener` 判断触摸位置是否在 `drawableEnd` 区域
- 点击清除时必须调用 `InputMethodManager.hideSoftInputFromWindow` 隐藏软键盘

## 1.3 业务逻辑规范

### 1.3.1 设备列表加载

```java
private static final int PAGE_SIZE = 20;

// 下拉刷新：pageIndex = 0
// 上拉加载：pageIndex = rawCount / PAGE_SIZE + (rawCount % PAGE_SIZE > 0 ? 1 : 0)
List<EZDeviceInfo> devices = EZOpenSDK.getInstance().getDeviceList(pageIndex, PAGE_SIZE);

// 判断是否还有更多数据
if (devices == null || devices.size() < PAGE_SIZE) {
    mHasMoreData = false;
}
```

使用 `DiffUtil` 更新列表，避免 `notifyDataSetChanged` 导致的全量刷新。

### 1.3.2 设备封面方案（Bitmap 内存+磁盘缓存，零闪烁）

**不使用 Glide 加载封面**。`onBindViewHolder` 中只做同步的 `setImageBitmap`，彻底消除闪烁。

封面缓存架构：
```
内存缓存 Map<String, Bitmap>  ←→  磁盘缓存 files/covers/{serial}.jpg
         ↑                              ↑
    抓图下载 Bitmap                  saveBitmapToDisk()
```

加载优先级：
1. **内存 Bitmap 缓存**（`mCoverBitmapMap`）
2. **磁盘文件缓存**（`files/covers/{serial}.jpg`，App 重启后从此恢复）
3. **占位图**（渐变背景 + 图片图标）

```java
// onBindViewHolder 中，纯同步操作
Bitmap coverBitmap = mCoverBitmapMap.get(device.getDeviceSerial());
if (coverBitmap != null) {
    holder.ivCover.setScaleType(ImageView.ScaleType.CENTER_CROP);
    holder.ivCover.setBackgroundResource(android.R.color.transparent);
    holder.ivCover.setImageBitmap(coverBitmap);
} else {
    holder.ivCover.setScaleType(ImageView.ScaleType.CENTER);
    holder.ivCover.setBackgroundResource(R.drawable.bg_cover_placeholder);
    holder.ivCover.setImageResource(R.drawable.ic_device_placeholder);
}
```

### 1.3.3 封面磁盘持久化

```java
private static final String COVER_DIR = "covers";

// 初始化缓存目录
mCoverDir = new File(context.getFilesDir(), COVER_DIR);
if (!mCoverDir.exists()) mCoverDir.mkdirs();

// 保存到磁盘（异步）
private void saveBitmapToDisk(String deviceSerial, Bitmap bitmap) {
    new Thread(() -> {
        File file = new File(mCoverDir, deviceSerial + ".jpg");
        try (FileOutputStream fos = new FileOutputStream(file)) {
            bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
        } catch (Exception e) { }
    }).start();
}

// 从磁盘加载
private Bitmap loadBitmapFromDisk(String deviceSerial) {
    File file = new File(mCoverDir, deviceSerial + ".jpg");
    if (!file.exists()) return null;
    return BitmapFactory.decodeFile(file.getAbsolutePath());
}
```

`setData` 时对没有内存缓存的设备从磁盘预加载：
```java
public void setData(List<EZDeviceInfo> devices) {
    mAllDevices.clear();
    // 不清空 mCoverBitmapMap，旧封面保持显示
    if (devices != null) {
        mAllDevices.addAll(devices);
        for (EZDeviceInfo device : devices) {
            String serial = device.getDeviceSerial();
            if (!mCoverBitmapMap.containsKey(serial)) {
                Bitmap cached = loadBitmapFromDisk(serial);
                if (cached != null) mCoverBitmapMap.put(serial, cached);
            }
        }
    }
    applyFilter();
}
```

### 1.3.4 抓图逻辑

抓图前必须判断两个条件：
1. **设备在线**：`device.getStatus() == 1`
2. **支持抓图能力**：`device.getSupportInt(14) == 1`（能力集第 14 位）

```java
private static final int CAPTURE_SUPPORT_INDEX = 14;

private boolean canCapture(EZDeviceInfo device) {
    if (device.getStatus() != 1) return false;
    if (device.getCameraInfoList() == null || device.getCameraInfoList().isEmpty()) return false;
    return device.getSupportInt(CAPTURE_SUPPORT_INDEX) == 1;
}
```

抓图流程（子线程）：
```java
// 1. 抓图获取 URL
String imageUrl = EZOpenSDK.getInstance().captureCamera(deviceSerial, cameraNo);
// 2. 下载 Bitmap
Bitmap bitmap = downloadBitmap(imageUrl);
// 3. 回主线程更新 Adapter（同时写入内存+磁盘）
runOnUiThread(() -> mAdapter.setCoverBitmap(deviceSerial, bitmap));
```

每次下拉刷新都重新抓图，新 Bitmap 覆盖旧缓存。

### 1.3.5 双目设备封面（上下拼接）

双目设备（`isSupportMultiChannel() && cameraInfoList.size() >= 2`）有两个通道，封面需分别抓图后上下拼接成一张 Bitmap。

```java
boolean isDual = device.isSupportMultiChannel()
    && device.getCameraInfoList() != null
    && device.getCameraInfoList().size() >= 2;

if (isDual) {
    // 分别抓两个通道
    String url1 = EZOpenSDK.getInstance().captureCamera(deviceSerial, cameras.get(0).getCameraNo());
    String url2 = EZOpenSDK.getInstance().captureCamera(deviceSerial, cameras.get(1).getCameraNo());
    Bitmap bmp1 = downloadBitmap(url1);
    Bitmap bmp2 = downloadBitmap(url2);

    // 上下拼接：各取中间区域，整体保持 16:9
    int targetWidth = Math.max(bmp1.getWidth(), bmp2.getWidth());
    int halfHeight = targetWidth * 9 / 32; // 每张占一半高度
    int totalHeight = halfHeight * 2;
    Bitmap combined = Bitmap.createBitmap(targetWidth, totalHeight, Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(combined);

    // 从每张图的中间裁剪 halfHeight 高度的区域
    drawCenterCrop(canvas, bmp1, targetWidth, 0, halfHeight);
    drawCenterCrop(canvas, bmp2, targetWidth, halfHeight, totalHeight);

    mAdapter.setCoverBitmap(deviceSerial, combined);
}
```

拼接原理：两张 16:9 的图各取中间条带，上下拼成一张图，整体仍为 16:9 比例，与单目设备封面卡片尺寸一致。

### 1.3.5 设备名称显示规则

```java
String displayName = device.getDeviceName();
if (TextUtils.isEmpty(displayName)) {
    String category = device.getCategory() != null ? device.getCategory() : "";
    displayName = String.format("%s(%s)", category, device.getDeviceSerial());
}
```

### 1.3.6 在线状态

```java
boolean online = device.getStatus() == 1;  // 1=在线，2=离线
holder.ivStatus.setImageResource(online ? R.drawable.ic_status_online : R.drawable.ic_status_offline);
```

### 1.3.7 搜索过滤

本地过滤，不走网络请求。同时匹配设备名称和序列号：

```java
private boolean matchesFilter(EZDeviceInfo device) {
    if (TextUtils.isEmpty(mSearchKeyword)) return true;
    String serial = device.getDeviceSerial().toLowerCase();
    String name = device.getDeviceName() != null ? device.getDeviceName().toLowerCase() : "";
    return serial.contains(mSearchKeyword) || name.contains(mSearchKeyword);
}
```

### 1.3.8 搜索框清除按钮

```java
// TextWatcher 中动态显示/隐藏清除按钮
private void updateClearButton(boolean show) {
    binding.etSearch.setCompoundDrawablesRelativeWithIntrinsicBounds(
            getDrawable(R.drawable.ic_search), null,
            show ? getDrawable(R.drawable.ic_clear) : null, null);
}

// OnTouchListener 中检测点击 drawableEnd 区域
// 点击后：清空文本 + clearFocus + 隐藏软键盘
private void hideKeyboard() {
    InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
    if (imm != null && binding.etSearch.getWindowToken() != null) {
        imm.hideSoftInputFromWindow(binding.etSearch.getWindowToken(), 0);
    }
}
```

## 1.4 Drawable 资源参考

### 4.1 bg_search_box.xml — 搜索框背景（全圆角胶囊形）

```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#FFFFFF" />
    <corners android:radius="20dp" />
    <stroke android:width="1dp" android:color="#E8EAF0" />
</shape>
```

### 4.2 bg_cover_placeholder.xml — 封面占位背景

```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <gradient android:startColor="#E8EAF0" android:endColor="#F0F2F6" android:angle="135" />
</shape>
```

### 4.3 ic_status_online.xml — 在线状态圆点

```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="#4CAF50" />
    <size android:width="12dp" android:height="12dp" />
    <stroke android:width="1.5dp" android:color="#FFFFFF" />
</shape>
```

### 4.4 ic_status_offline.xml — 离线状态圆点

```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="#9E9E9E" />
    <size android:width="12dp" android:height="12dp" />
    <stroke android:width="1.5dp" android:color="#FFFFFF" />
</shape>
```

### 4.5 ic_search.xml — 搜索图标

```xml
<vector android:width="20dp" android:height="20dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#9E9E9E"
        android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>
```

### 4.6 ic_clear.xml — 搜索框清除按钮

```xml
<vector android:width="18dp" android:height="18dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#9E9E9E"
        android:pathData="M12,2C6.47,2 2,6.47 2,12s4.47,10 10,10 10,-4.47 10,-10S17.53,2 12,2zM17,15.59L15.59,17 12,13.41 8.41,17 7,15.59 10.59,12 7,8.41 8.41,7 12,10.59 15.59,7 17,8.41 13.41,12 17,15.59z" />
</vector>
```

### 4.7 ic_add_device.xml — 添加设备图标

```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#1A1A2E"
        android:pathData="M13,7h-2v4H7v2h4v4h2v-4h4v-2h-4V7zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
</vector>
```

### 4.8 ic_logout.xml — 退出登录图标

```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#1A1A2E"
        android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z" />
</vector>
```

### 4.9 ic_device_placeholder.xml — 封面占位图标

```xml
<vector android:width="44dp" android:height="44dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#C5CAD4"
        android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>
```

### 4.10 ic_device_offline.xml — 设备离线图标（封面中央，wifi-off）

```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M1,1L23,23" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M16.72,11.06A10.94,10.94 0,0 1,19 12.55" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M5,12.55A10.94,10.94 0,0 1,8.46 10.1" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M10.71,5.05A16,16 0,0 1,22.56 9" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M1.42,9A15.91,15.91 0,0 1,4.63 6.68" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M8.53,16.11A6,6 0,0 1,15.46 16.11" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M12,20L12,20" android:strokeWidth="2" android:strokeColor="#B3FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

### 4.11 ic_empty_device.xml — 空状态图标

```xml
<vector android:width="72dp" android:height="72dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#C5CAD4"
        android:pathData="M17,10.5V7c0,-0.55 -0.45,-1 -1,-1H4c-0.55,0 -1,0.45 -1,1v10c0,0.55 0.45,1 1,1h12c0.55,0 1,-0.45 1,-1v-3.5l4,4v-11l-4,4z" />
</vector>
```

## 1.5 国际化字符串

### values/strings.xml（英文）

```xml
<string name="main_device_list">Device List</string>
<string name="main_add_device">Add Device</string>
<string name="main_logout">Logout</string>
<string name="main_logout_confirm">Confirm logout?</string>
<string name="main_no_device">No devices found</string>
<string name="main_refresh_fail">Refresh failed: %s</string>
<string name="main_no_more_data">No more devices</string>
<string name="search_device_hint">Serial No. or device name</string>
<string name="device_online">Online</string>
<string name="device_offline">Offline</string>
<string name="device_offline_hint">Device is offline</string>
```

### values-zh/strings.xml（中文）

```xml
<string name="main_device_list">设备列表</string>
<string name="main_add_device">添加设备</string>
<string name="main_logout">退出登录</string>
<string name="main_logout_confirm">确认退出登录？</string>
<string name="main_no_device">暂无设备</string>
<string name="main_refresh_fail">刷新失败：%s</string>
<string name="main_no_more_data">没有更多设备了</string>
<string name="search_device_hint">序列号或设备名称</string>
<string name="device_online">在线</string>
<string name="device_offline">离线</string>
<string name="device_offline_hint">设备离线了</string>
```

## 1.6 依赖要求

```groovy
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.android.material:material:1.11.0'
```

注意：封面加载不依赖 Glide，使用 Bitmap 内存+磁盘缓存方案。

## 1.7 注意事项

1. **封面比例**：必须使用 `ConstraintLayout` + `layout_constraintDimensionRatio="16:9"`，不能用 `adjustViewBounds`
2. **搜索框**：使用普通 `EditText` + `drawableStart`，不要用 `TextInputLayout`；圆角 = 高度/2（全圆角胶囊形）
3. **封面零闪烁**：不使用 Glide 加载封面，`onBindViewHolder` 中只做同步 `setImageBitmap`；下拉刷新不清空内存缓存，强制重新抓图，抓图成功后覆盖缓存并精准刷新对应 item（`notifyItemChanged`），不使用 `notifyDataSetChanged`
4. **封面持久化**：Bitmap 保存到 `files/covers/{serial}.jpg`，App 重启后从磁盘恢复到内存
5. **占位图实现**：背景和图标分离 — `background` 设渐变 drawable 铺满，`src` 设小图标居中；有封面时 `scaleType=CENTER_CROP`，无封面时 `scaleType=CENTER`
6. **抓图条件**：必须同时满足在线（`status==1`）和支持抓图（`getSupportInt(14)==1`）
7. **抓图流程**：子线程 `captureCamera` → `downloadBitmap` → 主线程 `setCoverBitmap`（内存+磁盘）
8. **双目设备封面**：`isSupportMultiChannel() && cameraInfoList.size() >= 2` 时，分别对两个通道抓图，上下拼接成一张 Bitmap（各取中间区域，整体 16:9）
9. **在线状态**：使用圆点图标（12dp，白色描边），不使用文字标签
10. **清除按钮**：点击时必须同时清空文本、clearFocus、隐藏软键盘
11. **所有用户可见文本必须国际化**：同时提供英文和中文

---

# 二、预览/回放合并页面

## 2.1 功能概述

预览和回放合并在同一个 Activity 中，通过底部 Tab 切换。页面从设备列表点击进入，接收完整的 `EZDeviceInfo` 对象。

### 核心功能
- 预览/回放 Tab 切换（底部 tab + 左右滑动手势）
- 视频播放区域（SurfaceView，16:9 比例，双目设备 8:9）
- 预览功能：播放/暂停、声音开关、清晰度切换、全屏、电子放大
- 回放功能：云/卡录像 tab、日期选择、录像列表（宫格 + 分时段标题）、倍速、进度条、连续播放、下载
- 隐私遮蔽：查询/切换状态，覆盖层显示
- 对讲：全双工，内嵌面板（波形动画），双目设备用覆盖面板
- 云台控制：方向盘面板，双目设备用覆盖面板
- 截图、录制（带指示器）
- 工具栏 5 秒自动隐藏，点击画面切换显示/隐藏
- 验证码弹框（加密设备，底部弹窗风格）

## 2.2 页面结构

```
┌─────────────────────────────────┐
│ [←] 设备名称                     │  ← 顶部导航
├─────────────────────────────────┤
│                                 │
│        视频播放区域 16:9          │  ← SurfaceView + 工具栏叠加
│  [▶][🔊][HD][⛶]               │  ← 底部工具栏（自动隐藏）
├─────────────────────────────────┤
│ [隐私] [对讲] [截图] [录制]       │  ← 预览4按钮 / 回放3按钮
├─────────────────────────────────┤
│                                 │
│    云台面板 / 对讲面板 / 回放区域  │  ← 中间内容区（互斥显示）
│                                 │
├─────────────────────────────────┤
│     [预览]        [回放]         │  ← 底部 Tab
└─────────────────────────────────┘
```

### 双目设备差异
- 播放区域比例改为 8:9（两个 SurfaceView 上下排列，各 16:9）
- 云台面板和对讲面板改为覆盖面板（从底部滑入，覆盖画面下方区域）
- 不支持全屏

## 2.3 UI 规范

### 2.3.1 视频播放区域

- 使用 `ConstraintLayout` + `ratio_guide`（View 占位）控制比例
- 单目设备：`dimensionRatio="16:9"`
- 双目设备：`dimensionRatio="8:9"`，内部 LinearLayout 包裹两个 SurfaceView（各 `weight=1`）
- 背景色：黑色
- 工具栏叠加在视频底部，半透明背景

### 2.3.2 视频工具栏（叠加在画面底部）

```
[▶暂停] [🔊声音] [HD清晰度] [⚡倍速] [⏪倒退10s]  ···  [⛶全屏]
```

- 预览模式：显示播放、声音、清晰度、全屏
- 回放模式（点击录像片段后）：显示播放、声音、倍速、倒退10s、全屏；隐藏清晰度
- 5 秒无操作自动隐藏，点击画面切换显示/隐藏
- 清晰度和倍速使用 pill 样式标签（`bg_pill_white15` 背景 + 图标 + 文字）

### 2.3.3 功能按钮区域

预览模式 4 个按钮（水平等分）：
```
[隐私遮蔽] [对讲] [截图] [录制]
```
- 双目设备：隐私遮蔽位置替换为云台按钮
- 每个按钮：52dp 圆形背景（`bg_ctrl_icon`）+ 24dp 图标 + 底部文字标签
- 激活状态：黑色实心背景（`bg_ctrl_icon_active`）

回放模式 3 个按钮（点击录像片段后替换预览按钮）：
```
[下载] [截图] [录制]
```
- 切换到回放 Tab 时：**保留预览功能栏不隐藏**，仅显示回放录像列表区域
- 点击录像片段后：预览功能栏隐藏，替换为回放功能栏（下载/截图/录制 3 按钮）
- 切回预览 Tab 时：恢复预览功能栏

### 2.3.4 回放区域

```
[云录像] [卡录像]    [◀] 📅 04-29 [▶]    ← 云/卡 tab + 日期选择
┌────┐ ┌────┐ ┌────┐                     ← 录像宫格（每行3个）
│封面 │ │封面 │ │封面 │
│10:30│ │10:35│ │10:40│
└────┘ └────┘ └────┘
```

- 云/卡录像 tab：文字 + 下划线指示器；首次切换到回放时，若云录像为空则自动切换到 SD 卡 Tab
- 日期：左右箭头切换 + 日历图标点击弹出日期选择器；右箭头在当天时禁用（`setEnabled(false)` + 半透明），不能选择未来日期
- 录像列表：GridLayoutManager 3 列，按时间倒序排列（最新在前），分时段标题（如 "10:00"）占满一行
- 录像卡片：16:9 比例，左上角开始时间，右上角时长，选中蓝色边框 + 播放动画；下载中显示 ProgressBar
- 云录像封面：通过 `getCoverPic()` 获取 URL，加密设备需拼接 `&decodekey=验证码` 解密参数（详见 `ezopensdk-player.md` 录像列表展示规范）
- SD 卡录像封面：通过 `RecordCoverFetcherManager` 获取，注意 `seq` 匹配规则（详见 `ezopensdk-player.md` SD 卡录像封面章节）
- 下拉刷新

### 2.3.4.1 回放进度条

- 位置：`SeekBar`，`constraintBottom_toBottomOf="ratio_guide"` + `translationY="9dp"`，跨在视频区域底部边缘，全宽（start/end 对齐 parent）
- 点击录像片段开始回放后显示（`VISIBLE`），切回预览或回放完成后隐藏（`GONE`）
- 颜色：已播放部分和 thumb 圆点为红色 `#D71921`（accent，Material 主题自动应用），未播放部分为 `#33FFFFFF`
- Thumb：10dp 直径白色实心圆（`seekbar_thumb.xml`）
- 交互：`onStopTrackingTouch` 中计算目标时间调用 `seekPlayback`

### 2.3.5 对讲面板

- 非双目设备：覆盖在云台面板上方，从底部滑入
- 双目设备：全屏覆盖面板（画面下方区域）
- 顶部栏：收起按钮 + "对讲" 标题 + 挂断按钮（红色圆形）
- 中间：160dp 圆形背景 + 10 根波形竖条（属性动画 scaleY 脉冲）
- 收起按钮只隐藏面板不挂断，挂断按钮停止对讲并关闭面板

### 2.3.6 云台面板

- 非双目设备：在中间内容区显示
- 双目设备：覆盖面板（从底部滑入）
- 180dp 圆形方向盘（`bg_ptz_ring`），上下左右 4 个方向按钮 + 中心圆点
- 方向按钮使用 `OnTouchListener`：ACTION_DOWN 开始转动，ACTION_UP 停止

### 2.3.7 弹窗风格

清晰度、倍速、日期选择、验证码均使用 `BottomSheetDialog`：
- 顶部圆角背景（`bg_sheet_top_corners`，16dp 圆角）
- 顶部拖拽条（36dp × 4dp）
- monospace 大写标题
- 选项列表：pill 样式，选中黑底白字（`bg_quality_item_selected`），未选中边框（`bg_quality_item`）

## 2.4 业务逻辑规范

### 2.4.1 页面入参

从设备列表传入完整的 `EZDeviceInfo` 对象：
```java
Intent intent = new Intent(this, PreviewActivity.class);
intent.putExtra("device", deviceInfo); // EZDeviceInfo 实现了 Parcelable
startActivity(intent);
```

### 2.4.2 能力集查询（使用 EZBusinessTool 兼容子设备）

```java
EZCameraInfo cameraInfo = deviceInfo.getCameraInfoList().get(0);
boolean supportPTZ = EZBusinessTool.isSupportPTZ(deviceInfo, cameraInfo);
EZConstants.EZTalkbackCapability talkCap = EZBusinessTool.isSupportTalk(deviceInfo, cameraInfo);
int supportTalk = talkCap != null ? talkCap.ordinal() : 0; // 0=不支持, 1=全双工, 2=半双工
boolean supportPrivacy = deviceInfo.getSupportInt(388) == 1;
boolean isMultiChannel = EZBusinessTool.isSupportMultiChannel(deviceInfo, cameraInfo);
boolean isEncrypt = deviceInfo.getIsEncrypt() == 1;
```

### 2.4.3 预览流程

页面进入时自动创建播放器并开始预览，无需用户手动点击播放。实际播放时机是在 `surfaceCreated` 回调中触发（Surface 准备好后立即调用 `startRealPlay`），而非 `onCreate` 中直接调用。

```java
// 创建播放器
previewPlayer = EZOpenSDK.getInstance().createPlayer(deviceSerial, cameraNo);
previewPlayer.setHandler(handler);
previewPlayer.setSurfaceHold(surfaceHolder);
// 加密设备设置验证码（从缓存读取）
if (isEncrypt) previewPlayer.setPlayVerifyCode(cachedCode);
// 双目设备绑定第二路
if (isMultiChannel) previewPlayer.setSurfaceHold(secondHolder, 1);
// 开始预览
previewPlayer.startRealPlay();
```

### 2.4.4 预览/回放 Tab 切换

- 切换到回放：不停止预览（保持画面），搜索录像列表，等用户点击录像片段时再停止预览并开始回放
- 切换到预览：停止回放，自动恢复预览播放（若隐私遮蔽开启则显示遮蔽覆盖层，不恢复播放）
- 首次切换到回放时，若云录像列表为空，自动切换到 SD 卡录像 Tab

### 2.4.5 回放连续播放

详见 `ezopensdk-player.md` 第四节"多片段连续播放"。核心要点：
1. 首次点击：Parcel 深拷贝录像对象，修改 stopTime 为最新片段的结束时间，`startPlayback`
2. 切换片段：`seekPlayback` 跳转，不断流
3. OSD 定时器：每秒 `getOSDTime()`，匹配当前片段，更新选中高亮和进度条
4. 回放完成：`MSG_REMOTEPLAYBACK_PLAY_FINISH` 中自动播放下一个片段；若无下一片段，执行以下操作：
   - 停止回放（`stopPlayback`）
   - 设置 `isPlaybackFinished = true`
   - 隐藏进度条
   - 在播放窗口中央显示"播放完成"提示（`tv_playback_finished` VISIBLE）
   - 隐藏工具栏，且回放完成状态下点击播放窗口不再显示工具栏（`toggleToolbar` 判断 `isPlaybackFinished`）
   - 取消录像列表选中状态（`selectedRecordingPos = -1`，`currentPlaybackItem = null`，刷新 adapter）
   - 功能栏保持回放功能栏不变，不切换为预览功能栏
5. 重新播放：用户点击录像片段时，重置 `isPlaybackFinished = false`，隐藏完成提示
6. 切换回预览：`switchToPreview` 中需重置 `isPlaybackFinished = false` 并隐藏完成提示（`tv_playback_finished` GONE），否则切回预览后点击播放窗口无法显示工具栏

### 2.4.6 回放录像列表下拉刷新

回放区域的录像列表支持下拉刷新，使用 `SwipeRefreshLayout` 包裹 `RecyclerView`：
- 下拉刷新时清空当前录像类型的缓存，重新查询当天录像
- 刷新完成后调用 `setRefreshing(false)` 关闭刷新动画
- 云存储和 SD 卡录像各自维护独立的缓存列表，切换 Tab 时优先使用缓存，缓存为空时才发起查询
- 云录像封面使用 `Map<Integer, Bitmap>` 内存缓存（key 为列表索引），通过 Coil/手动下载后缓存，切换 Tab 不重复下载
- SD 卡录像封面通过 `RecordCoverFetcherManager` 回调后缓存到 `Map<Integer, Bitmap>`，`onBindViewHolder` 中直接读取

```java
binding.swipeRefreshPlayback.setOnRefreshListener(() -> {
    // 清空当前录像类型的缓存
    if (isCloudPlayback) {
        cloudRecordCache.clear();
    } else {
        sdRecordCache.clear();
    }
    // 重新查询录像列表
    searchRecordings();
});
```

### 2.4.7 倍速设置

统一使用 `setPlaybackRate`（`setCloudPlaybackRate` 已废弃），云存储和 SD 卡通用：
```java
// 倍速映射
EZConstants.EZPlaybackRate[] rates = {
    EZ_PLAYBACK_RATE_2_1,  // 0.5x
    EZ_PLAYBACK_RATE_1,    // 1x
    EZ_PLAYBACK_RATE_2,    // 2x
    EZ_PLAYBACK_RATE_4,    // 4x
    EZ_PLAYBACK_RATE_8,    // 8x
    EZ_PLAYBACK_RATE_16,   // 16x
    EZ_PLAYBACK_RATE_32    // 32x（云存储额外支持）
};
mPlaybackPlayer.setPlaybackRate(rates[speedIndex]);
```

### 2.4.8 倒退 10 秒

回放模式下，工具栏提供倒退 10 秒按钮，点击后将播放进度回退 10 秒：
```java
// 获取当前 OSD 时间
Calendar osd = mPlaybackPlayer.getOSDTime();
if (osd == null) return;
// 回退 10 秒
osd.add(Calendar.SECOND, -10);
// 不能早于当前片段的开始时间
if (osd.getTimeInMillis() < playbackBeginTime) {
    osd.setTimeInMillis(playbackBeginTime);
}
// 执行 seek
mPlaybackPlayer.seekPlayback(osd);
```

注意：倒退 10 秒仅在回放播放中可用，预览模式下不显示此按钮。

### 2.4.9 清晰度切换

清晰度数据来源：
- 初始值从 `cameraInfo.getVideoLevel().getVideoLevel()` 获取（设备列表传入的 `EZDeviceInfo` 中）
- 取流成功后通过 `previewPlayer.getDeviceDetailInfo()` 获取设备支持的完整清晰度列表
- 如果设备未上报清晰度列表，使用默认列表（流畅/均衡/高清）

videoLevel 完整映射：
- 0 = 流畅
- 1 = 均衡
- 2 = 高清
- 3 = 超清
- 4 = 极清
- 5 = 3K
- 6 = 4K

取流成功后获取设备支持的清晰度列表，切换时 stop → setVideoLevel（子线程）→ start（主线程）：
```java
// 进入页面时从 cameraInfo 获取初始清晰度
currentVideoLevel = cameraInfo.getVideoLevel().getVideoLevel();

// 取流成功后获取清晰度列表（public 字段，不是 getter）
EZDeviceDetailPublicInfo info = previewPlayer.getDeviceDetailInfo();
ArrayList<EZVideoQualityInfo> qualityInfos = info.videoQualityInfos;

// 切换清晰度
previewPlayer.stopRealPlay(); // 主线程
new Thread(() -> {
    EZOpenSDK.getInstance().setVideoLevel(deviceSerial, cameraNo, newLevel); // 子线程
    runOnUiThread(() -> previewPlayer.startRealPlay()); // 主线程
}).start();
```

### 2.4.10 电子放大

使用 SDK 内置 `CustomTouchListener`，需实现全部 8 个抽象方法：
```java
CustomTouchListener touchListener = new CustomTouchListener() {
    @Override public boolean canZoom(float scale) { return isPlaying; }
    @Override public boolean canDrag(int direction) { return isPlaying; }
    @Override public void onSingleClick() { /* 切换工具栏显示/隐藏 */ }
    @Override public void onDoubleClick(View v, MotionEvent e) { }
    @Override public void onZoom(View view, float scale) { }
    @Override public void onZoomChange(View view, float scale, CustomRect oRect, CustomRect curRect) {
        try {
            if (scale <= 1) player.setDisplayRegion(false, null, null);
            else player.setDisplayRegion(true, oRect, curRect);
        } catch (Exception e) { /* setDisplayRegion 抛 PlaySDKException */ }
    }
    @Override public void onDrag(int direction, float dx, float dy) { }
    @Override public void onEnd(View view, int mode) { }
};
surfaceView.setOnTouchListener(touchListener);
touchListener.setSacaleRect(4.0f, 0, 0, width, height); // SurfaceView 尺寸确定后调用
```

缩放比例 UI：缩放大于 1x 时，在画面左上角显示当前缩放比例（如 "2.0x"），使用 `bg_pill_black50` 半透明背景；缩放恢复 1x 时隐藏。

### 2.4.11 对讲

```java
// 1. 关闭预览声音（避免回音）
previewPlayer.closeSound();
// 2. 创建独立的对讲播放器
talkPlayer = EZOpenSDK.getInstance().createPlayer(deviceSerial, cameraNo);
talkPlayer.setHandler(handler);
// 3. 开始全双工对讲
talkPlayer.startVoiceTalk(true);
// 4. 停止对讲后恢复预览声音
talkPlayer.stopVoiceTalk();
talkPlayer.release();
previewPlayer.openSound();
```

对讲面板交互细节：
- 收起按钮（箭头）：仅隐藏面板，不停止对讲，再次点击对讲按钮可重新展开面板
- 挂断按钮（红色）：停止对讲并关闭面板
- 非双目设备在回放模式下打开对讲面板时，隐藏回放区域显示对讲面板；关闭面板后恢复回放区域

### 2.4.12 隐私遮蔽

通过萤石开放平台 HTTP 接口查询/切换（非 SDK 方法）：
- 查询：`GET /api/v3/device/video/switch/status?type=7`，Header 传 accessToken 和 deviceSerial
- 切换：`PUT /api/v3/device/video/switch/status`，Body 传 type=7 和 enable=0/1
- 开启隐私遮蔽后：停止预览，显示黑色覆盖层 + 提示文字
- 关闭隐私遮蔽后：隐藏覆盖层，恢复预览
- 隐私遮蔽开启时，点击播放按钮应 Toast 提示用户无法播放，不执行播放操作

### 2.4.13 验证码处理

加密设备（`getIsEncrypt() == 1`）需要验证码：
- 首次进入：从 SharedPreferences 读取缓存的验证码，设置给播放器
- 播放失败 `ErrorCode.ERROR_INNER_VERIFYCODE_NEED`（400035）：弹出验证码输入框
- 验证码错误 `ErrorCode.ERROR_INNER_VERIFYCODE_ERROR`（400036）：弹出验证码输入框（提示错误）
- 用户输入后：缓存到 SharedPreferences，设置给所有播放器（预览+回放），然后自动重新播放：
  - 预览模式：`stopRealPlay` → `startRealPlay`
  - 回放模式（已选中录像片段）：重新调用 `playRecording` 播放当前片段

### 2.4.14 录制

```java
// 路径必须为沙盒路径
String path = getExternalCacheDir() + "/0_OpenSDK/Records/" + System.currentTimeMillis() + ".mp4";
// 设置回调（录制完成后保存到相册）
player.setStreamDownloadCallback(callback);
// 开始录制
player.startLocalRecordWithFile(path);
// 停止录制
player.stopLocalRecord();
```

录制指示器：红点闪烁 + 时长计时（每秒更新）。页面 onStop 时必须停止录制。

### 2.4.15 截图/录制保存到相册

截图和录制的文件需要通过 `ContentResolver` + `MediaStore` 保存到系统相册，不能直接写相册路径。

#### 截图保存到相册

```java
public static void savePicture2Album(Context context, Bitmap bitmap) {
    try {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 10+ 使用 MediaStore 插入
            Uri uri = context.getContentResolver().insert(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());
            if (uri == null) return;
            OutputStream os = context.getContentResolver().openOutputStream(uri, "rw");
            if (os != null) {
                bitmap.compress(Bitmap.CompressFormat.JPEG, 90, os);
                os.close();
            }
        } else {
            // Android 9 及以下
            MediaStore.Images.Media.insertImage(
                    context.getContentResolver(), bitmap, null, null);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}
```

#### 录制视频保存到相册

录制完成回调 `onSuccess(filepath)` 中，将沙盒路径的 mp4 文件转存到系统相册：

```java
public static void saveVideo2Album(Context context, File file, long durationSeconds) {
    if (file == null || !file.exists()) return;
    new Thread(() -> {
        try {
            ContentResolver resolver = context.getContentResolver();
            ContentValues values = new ContentValues();
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, file.getName());
            values.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4");
            values.put(MediaStore.MediaColumns.DATE_TAKEN, System.currentTimeMillis());
            values.put(MediaStore.MediaColumns.DURATION, durationSeconds * 1000);
            values.put(MediaStore.MediaColumns.SIZE, file.length());
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                values.put(MediaStore.MediaColumns.RELATIVE_PATH,
                        Environment.DIRECTORY_DCIM + File.separator + context.getPackageName());
            }
            Uri uri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
            if (uri == null) return;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                try (OutputStream os = resolver.openOutputStream(uri)) {
                    if (os != null) Files.copy(file.toPath(), os);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
}
```

注意：
- Android 10（Q）及以上必须使用 `MediaStore` API，不能直接操作文件路径
- 双目设备录制会生成两个文件（`xxx.mp4` 和 `xxx_1.mp4`），需分别保存
- 录制视频保存建议在子线程执行，避免阻塞 UI

### 2.4.16 预览暂停截图冻结

用户在预览模式点击暂停时，先截取当前画面作为静态图覆盖在 SurfaceView 上，再停止取流。恢复播放时**不立即隐藏截图**，等取流成功后再移除，避免中间出现黑屏。

```java
// 暂停：先截图再停止
new Thread(() -> {
    Bitmap bmp = previewPlayer.capturePicture();
    runOnUiThread(() -> {
        if (bmp != null) {
            ivPauseSnapshot.setImageBitmap(bmp);
            ivPauseSnapshot.setVisibility(View.VISIBLE);
        }
        previewPlayer.stopRealPlay();
        isPlaying = false;
    });
}).start();

// 恢复播放：保留截图，取流成功后再移除
previewPlayer.startRealPlay(); // 不在这里隐藏截图

// 取流成功回调（Handler）中移除截图
case CYCLOPEDIA_REALPLAY_START:
    ivPauseSnapshot.setVisibility(View.GONE);
    break;
```

双目设备需同时截取两路画面。

### 2.4.17 生命周期管理

```
onCreate:
  - 查询隐私遮蔽状态
  - 如果隐私遮蔽开启：显示覆盖层，不取流
  - 如果隐私遮蔽关闭：开始预览

onStop:
  - 停止录制
  - 停止对讲（直接隐藏面板，不播放动画）
  - 回放模式：暂停回放（不释放 player）
  - 预览模式：停止预览

onRestart:
  - 显示加载UI（loading indicator）
  - 回放模式：重新绑定 Surface，恢复播放
  - 预览模式：重新绑定 Surface，重新开始预览
  - 取流成功后隐藏加载UI

onDestroy:
  - 移除所有 Handler 消息
  - 停止下载任务
  - 释放所有播放器（preview、talk、playback）
```

### 2.4.18 全屏

- 切换屏幕方向触发 `onConfigurationChanged`
- 横屏：隐藏系统栏（沉浸式）、隐藏导航栏和内容区、视频区域填满屏幕、显示全屏返回按钮
- 竖屏：恢复系统栏、恢复导航栏和内容区、视频区域恢复 wrap_content
- 双目设备不支持全屏

## 2.5 注意事项

1. **预览和回放使用独立播放器**：预览用 `previewPlayer`，回放用 `playbackPlayer`，对讲用 `talkPlayer`，互不干扰
2. **SurfaceHolder 必须解绑**：`surfaceDestroyed` 中调用 `setSurfaceHold(null)`，否则下次取流报错
3. **双目设备第二路**：`setSurfaceHold(holder, 1)` 绑定第二路，截图用 `capturePicture(1)`
4. **能力集查询用 EZBusinessTool**：兼容子设备（NVR 通道），不要直接调用 `deviceInfo.isSupportXxx()`
5. **倍速统一用 setPlaybackRate**：`setCloudPlaybackRate` 已废弃
6. **清晰度字段是 public**：`info.videoQualityInfos` 直接访问，不要写 `info.getVideoQualityInfos()`
7. **setDisplayRegion 需 try-catch**：会抛出 `PlaySDKException` 受检异常
8. **验证码用 ErrorCode 常量**：`ErrorCode.ERROR_INNER_VERIFYCODE_NEED`（400035）和 `ErrorCode.ERROR_INNER_VERIFYCODE_ERROR`（400036），不要硬编码数字
9. **深拷贝用 Parcel**：`deepCopy(source, Creator)` 自动复制全部属性，避免遗漏
10. **stopRealPlay/startRealPlay 在主线程**：`setVideoLevel` 在子线程
11. **对讲前关闭预览声音**：避免回音，对讲结束后恢复
12. **录制路径必须为沙盒路径**：不能直接写相册路径，录制完成后通过 ContentResolver 转存
13. **所有用户可见文本必须国际化**：同时提供英文（values/strings.xml）和中文（values-zh/strings.xml）
14. **云录像封面加密解密**：加密设备的云录像封面 URL 需拼接 `&decodekey=验证码` 参数，通过 `EZCloudRecordFile.getEncryption()` 判断是否加密，非空则拼接
15. **SD 卡录像封面 seq 匹配**：`RecordCoverFetcherManager` 回调的 `seq` 是 `requestRecordCover` 时通过 `setSeq(i)` 设置的列表索引，`onBindViewHolder` 中必须用 `sdRecordCache.indexOf(item)` 查找封面，不能用 `df.getSeq()`（SDK 原始值，与手动设置的索引不一致）
16. **动态权限申请**：对讲需要 `RECORD_AUDIO` 权限，录制/下载保存到相册需要存储权限（Android 13+ 用 `READ_MEDIA_VIDEO`，低版本用 `WRITE_EXTERNAL_STORAGE`），必须在功能触发时动态申请
17. **工具栏显示时机**：初始不启动自动隐藏计时器，取流成功后才显示工具栏并启动5秒自动隐藏（`MSG_HIDE_TOOLBAR`）
18. **预览暂停先截图再停流**：避免画面变黑，取流成功后再移除截图封面（`ivPauseSnapshot.setVisibility(View.GONE)`）
19. **生命周期恢复时显示加载UI**：`onRestart` 中重新取流前显示 loading，取流成功后隐藏


## 2.6 Drawable 资源参考

### 背景 Drawable

#### bg_ctrl_icon.xml — 功能按钮圆形背景（默认）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/bg_card" />
    <stroke android:width="1dp" android:color="@color/border_strong" />
    <size android:width="52dp" android:height="52dp" />
</shape>
```

#### bg_ctrl_icon_active.xml — 功能按钮圆形背景（激活）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/text_display" />
    <size android:width="52dp" android:height="52dp" />
</shape>
```

#### bg_back_btn.xml — 返回按钮圆形边框
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@android:color/transparent" />
    <stroke android:width="1dp" android:color="@color/border_strong" />
    <size android:width="36dp" android:height="36dp" />
</shape>
```

#### bg_back_btn_fullscreen.xml — 全屏返回按钮（半透明白色）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="#33FFFFFF" />
    <stroke android:width="1dp" android:color="#55FFFFFF" />
    <size android:width="36dp" android:height="36dp" />
</shape>
```

#### bg_pill_white15.xml — 工具栏标签背景
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#26FFFFFF" />
    <corners android:radius="10dp" />
</shape>
```

#### bg_pill_black50.xml — 缩放比例/录制指示器背景
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#80000000" />
    <corners android:radius="999dp" />
</shape>
```

#### bg_ptz_ring.xml — 云台方向盘圆环
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/bg_card" />
    <stroke android:width="1dp" android:color="@color/border_strong" />
</shape>
```

#### bg_talk_wave_circle.xml — 对讲波形圆形背景
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/bg_card" />
    <size android:width="160dp" android:height="160dp" />
</shape>
```

#### bg_hangup_btn.xml — 挂断按钮红色圆形
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/accent" />
    <size android:width="36dp" android:height="36dp" />
</shape>
```

#### bg_recording_dot.xml — 录制红点
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/accent" />
    <size android:width="8dp" android:height="8dp" />
</shape>
```

#### bg_tab_indicator.xml — Tab 下划线指示器
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/color_blue" />
    <corners android:radius="1dp" />
    <size android:width="60dp" android:height="2dp" />
</shape>
```

#### bg_sheet_top_corners.xml — 底部弹窗顶部圆角
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/bg_primary" />
    <corners android:topLeftRadius="16dp" android:topRightRadius="16dp" />
</shape>
```

#### bg_quality_item.xml — 弹窗选项（未选中）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@android:color/transparent" />
    <stroke android:width="1dp" android:color="@color/border_strong" />
    <corners android:radius="999dp" />
</shape>
```

#### bg_quality_item_selected.xml — 弹窗选项（选中）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/text_display" />
    <corners android:radius="999dp" />
</shape>
```

#### seekbar_progress.xml — 回放进度条轨道
```xml
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background">
        <shape android:shape="rectangle"><solid android:color="#33FFFFFF" /><size android:height="2dp" /></shape>
    </item>
    <item android:id="@android:id/progress">
        <clip><shape android:shape="rectangle"><solid android:color="#FFFFFF" /><size android:height="2dp" /></shape></clip>
    </item>
</layer-list>
```

#### seekbar_thumb.xml — 回放进度条滑块
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="#FFFFFF" />
    <size android:width="10dp" android:height="10dp" />
</shape>
```

### 图标 Drawable

#### 视频工具栏图标（白色/半透明白色，叠加在黑色视频画面上）

##### ic_play.xml — 播放
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M5,3L19,12L5,21Z" android:fillColor="#E6FFFFFF" />
</vector>
```

##### ic_pause.xml — 暂停
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M6,4L10,4L10,20L6,20Z" android:fillColor="#E6FFFFFF" />
    <path android:pathData="M14,4L18,4L18,20L14,20Z" android:fillColor="#E6FFFFFF" />
</vector>
```

##### ic_sound_on.xml — 声音开
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M11,5L6,9L2,9L2,15L6,15L11,19Z" android:fillColor="#26FFFFFF" android:strokeWidth="1.5" android:strokeColor="@color/white" />
    <path android:pathData="M15.54,8.46A5,5 0,0 1,15.54 15.54" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M19.07,4.93A10,10 0,0 1,19.07 19.07" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_sound_off.xml — 声音关
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M11,5L6,9L2,9L2,15L6,15L11,19Z" android:fillColor="#26FFFFFF" android:strokeWidth="1.5" android:strokeColor="@color/white" />
    <path android:pathData="M23,9L17,15" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M17,9L23,15" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_fullscreen.xml — 全屏
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M15,3L21,3L21,9" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M9,21L3,21L3,15" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M21,3L14,10" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M3,21L10,14" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_quality.xml — 清晰度标签图标（12dp）
```xml
<vector android:width="12dp" android:height="12dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M4,4L20,4A2,2 0,0 1,22 6L22,18A2,2 0,0 1,20 20L4,20A2,2 0,0 1,2 18L2,6A2,2 0,0 1,4 4Z" android:strokeWidth="1.5" android:strokeColor="#D9FFFFFF" android:fillColor="@android:color/transparent" />
</vector>
```

##### ic_speed.xml — 倍速标签图标（12dp 闪电）
```xml
<vector android:width="12dp" android:height="12dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M13,2L3,14L12,14L11,22L21,10L12,10L13,2Z" android:fillColor="#FFFFFF" />
</vector>
```

##### ic_rewind10.xml — 倒退10秒
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M1,4V10H7" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M3.51,15A9,9 0,1 0,5.64 5.64L1,10" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M10,11V16" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M9,12L10,11" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M14.5,13.5m-2.5,0a2.5,2.5 0,1 1,5 0a2.5,2.5 0,1 1,-5 0" android:strokeWidth="1.5" android:strokeColor="@color/white" android:fillColor="@android:color/transparent" />
</vector>
```

##### ic_back_white.xml — 全屏返回箭头（白色）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M15,18L9,12L15,6" android:strokeWidth="2" android:strokeColor="#FFFFFF" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>
```

#### 功能按钮图标（彩色，显示在白色背景圆形按钮中）

##### ic_privacy.xml — 隐私遮蔽（蓝色眼睛+斜线）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M1,12C1,12 5,4 12,4C19,4 23,12 23,12C23,12 19,20 12,20C5,20 1,12 1,12Z" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" />
    <path android:pathData="M1,1L23,23" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_talk.xml — 对讲（青色麦克风）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M12,1A3,3 0,0 0,9 4V12A3,3 0,0 0,15 12V4A3,3 0,0 0,12 1Z" android:strokeWidth="1.5" android:strokeColor="@color/color_teal" android:fillColor="@android:color/transparent" />
    <path android:pathData="M19,10V12A7,7 0,0 1,5 12V10" android:strokeWidth="1.5" android:strokeColor="@color/color_teal" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M12,19V23" android:strokeWidth="1.5" android:strokeColor="@color/color_teal" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_capture.xml — 截图（橙色相框）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M5,3L19,3A2,2 0,0 1,21 5L21,19A2,2 0,0 1,19 21L5,21A2,2 0,0 1,3 19L3,5A2,2 0,0 1,5 3Z" android:strokeWidth="1.5" android:strokeColor="@color/color_orange" android:fillColor="@android:color/transparent" />
    <path android:pathData="M8.5,8.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" android:fillColor="@color/color_orange" />
    <path android:pathData="M21,15L16,10L5,21" android:strokeWidth="1.5" android:strokeColor="@color/color_orange" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>
```

##### ic_record.xml — 录制（红色圆环+实心圆）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0" android:strokeWidth="1.5" android:strokeColor="@color/accent" android:fillColor="@android:color/transparent" />
    <path android:pathData="M12,12m-4,0a4,4 0,1 1,8 0a4,4 0,1 1,-8 0" android:fillColor="@color/accent" />
</vector>
```

##### ic_download.xml — 下载（蓝色箭头+底线）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M21,15V19A2,2 0,0 1,19 21L5,21A2,2 0,0 1,3 19V15" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M7,10L12,15L17,10" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M12,15V3" android:strokeWidth="1.5" android:strokeColor="@color/color_blue" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

##### ic_ptz.xml — 云台（紫色十字箭头）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M5,9 L2,12 L5,15" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M9,5 L12,2 L15,5" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M15,19 L12,22 L9,19" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M19,9 L22,12 L19,15" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
    <path android:pathData="M2,12 L22,12" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M12,2 L12,22" android:strokeColor="#7C4DFF" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
</vector>
```

#### 辅助图标

##### ic_calendar.xml — 日历
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M5,4L19,4A2,2 0,0 1,21 6L21,20A2,2 0,0 1,19 22L5,22A2,2 0,0 1,3 20L3,6A2,2 0,0 1,5 4Z" android:strokeWidth="1.5" android:strokeColor="@color/text_display" android:fillColor="@android:color/transparent" />
    <path android:pathData="M16,2V6" android:strokeWidth="1.5" android:strokeColor="@color/text_display" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M8,2V6" android:strokeWidth="1.5" android:strokeColor="@color/text_display" android:fillColor="@android:color/transparent" android:strokeLineCap="round" />
    <path android:pathData="M3,10H21" android:strokeWidth="1.5" android:strokeColor="@color/text_display" android:fillColor="@android:color/transparent" />
</vector>
```

##### ic_prev_day.xml / ic_ptz_left.xml — 左箭头（日期/云台共用 pathData）
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M15,18L9,12L15,6" android:strokeWidth="2" android:strokeColor="颜色" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>
<!-- 日期箭头用 @color/text_display，云台箭头用 @color/color_blue -->
```

##### ic_next_day.xml / ic_ptz_right.xml — 右箭头
```xml
<path android:pathData="M9,6L15,12L9,18" ... />
```

##### ic_ptz_up.xml — 云台上箭头
```xml
<path android:pathData="M18,15L12,9L6,15" android:strokeWidth="2" android:strokeColor="@color/color_blue" ... />
```

##### ic_ptz_down.xml — 云台下箭头
```xml
<path android:pathData="M6,9L12,15L18,9" android:strokeWidth="2" android:strokeColor="@color/color_blue" ... />
```

##### ic_ptz_center.xml — 云台中心圆点
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" android:strokeWidth="1.5" android:strokeColor="@color/text_secondary" android:fillColor="@android:color/transparent" />
</vector>
```

##### ic_hangup_red.xml — 挂断电话（白色，放在红色圆形按钮上）
```xml
<vector android:width="20dp" android:height="20dp" android:viewportWidth="24" android:viewportHeight="24">
    <group android:pivotX="12" android:pivotY="12" android:rotation="135">
        <path android:pathData="M23,16.01L19.66,12.67A2,2 0,0 0,16.84 12.67L15.28,14.23A12.07,12.07 0,0 1,9.77 8.72L11.33,7.16A2,2 0,0 0,11.33 4.34L7.99,1A2,2 0,0 0,5.17 1L3.05,3.12A3,3 0,0 0,2.7 7C5.42,12.53 10.47,17.58 16,20.3A3,3 0,0 0,19.88 19.95L22,17.83A2,2 0,0 0,23 16.01Z" android:fillColor="#FFFFFF" />
    </group>
</vector>
```

##### ic_arrow_down_talk.xml — 对讲面板收起箭头
```xml
<vector android:width="20dp" android:height="20dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:pathData="M6,9L12,15L18,9" android:strokeColor="@color/text_primary" android:strokeWidth="1.5" android:fillColor="@android:color/transparent" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>
```

##### ic_close.xml — 关闭按钮
```xml
<vector android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#999999" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
```

##### ic_privacy_white.xml — 隐私遮蔽覆盖层图标（白色版本）
与 ic_privacy.xml 相同 pathData，strokeColor 改为 `@color/white`。

---

# 三、设备配网流程

## 3.1 功能概述

设备配网是从"扫码 / 输入序列号"开始，引导用户完成设备初始化、选择 Wi-Fi、选择配网方式、最终将设备绑定到用户账号的完整流程。流程设计按"步骤条"可视化推进，视觉上是一个多页向导。

### 配网流程全景图

```
主页[右上角添加按钮]
     │
     ▼
[1] 扫描二维码（ScanQrActivity）
     │ 扫码成功 / 手动输入
     ▼
[1.5] 输入序列号（InputSerialActivity）— 可选
     │ 确认
     ▼
[2] 设备确认（DeviceConfirmActivity）
     │ probeDeviceInfo 查询
     │ ┌── 设备在线未添加 → [添加设备] 直接 addDevice → 回设备列表
     │ └── 设备离线/未注册 → 勾选协议 → 下一步
     ▼
[3] 初始化设备（InitDeviceActivity）
     │ 勾选"指示灯蓝色快闪"
     │   ├── 指示灯未闪 → 重置 Wi-Fi 页
     │   └── 下一步
     ▼
[4] 选择 Wi-Fi（WifiSelectActivity）
     │ ├── 选择网络 → Wi-Fi 列表页
     │ └── 下一步（填好 SSID/密码）
     ▼
[5] 选择配网方式（ConfigMethodActivity）
     │ 根据设备能力集显示 AP / 声波 / Smart / 组合
     │ 点击一种方式
     ▼
[6] 正在配置（ConfiguringActivity）
     │ 进度环 + 步骤列表：
     │   - 成功连接网络 ✓
     │   - 正在注册到平台 → 成功注册到平台 ✓
     │   - 正在绑定账号 → 成功绑定账号 ✓
     │   - 配置完成 ✓
     ▼
[完成] 返回主页设备列表
```

### 涉及的 Activity

| 序号 | Activity | 说明 |
|------|----------|------|
| 1 | `ScanQrActivity` | 扫描二维码（相机 + 相册 + 手动入口） |
| 1.5 | `InputSerialActivity` | 手动输入 9 位序列号 + 6 位验证码 |
| 2 | `DeviceConfirmActivity` | 查询设备信息并展示，含协议勾选 |
| 3 | `InitDeviceActivity` | 提示等待指示灯蓝色快闪 |
| 3.5 | `ResetWifiActivity` | 长按 Reset 重置设备的引导页 |
| 4 | `WifiSelectActivity` | 选择 Wi-Fi 并输入密码 |
| 4.5 | `WifiListActivity` | 周围 Wi-Fi 列表（扫描选择） |
| 5 | `ConfigMethodActivity` | 选择配网方式（AP/声波/Smart/组合） |
| 6 | `ConfiguringActivity` | 正在配网（进度环 + 步骤列表） |

### 共用视觉元素

- **步骤条**：页面顶部 4 段横条，根据当前步骤高亮
  - Scan/Input → `1 完成 / 2-4 待定`
  - DeviceConfirm → `1-2 完成 / 3-4 待定`
  - InitDevice → `1-2 完成 / 3-4 待定`（Init 与 Confirm 共享步骤 2）
  - WifiSelect → `1-2 完成 / 3 待定 / 4 待定`
  - ConfigMethod → `1-3 完成 / 4 待定`
  - Configuring → `1-4 全部完成`
- **底部主按钮**：全圆角（`radius=999dp`）蓝色胶囊按钮（`bg_btn_blue`），不可用状态半透明蓝
- **导航栏**：左侧返回按钮 + 中间标题
- **留白**：`spacing_lg = 24dp` 为主要内边距

## 3.2 入口

主页 Toolbar 右上角菜单项 `action_add_device`，点击后进入扫码页：

```java
binding.toolbar.setOnMenuItemClickListener(item -> {
    if (item.getItemId() == R.id.action_add_device) {
        startActivity(new Intent(this, ScanQrActivity.class));
        return true;
    }
    return false;
});
```

配网完成后，通过 `Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP` 回到 `MainActivity`，`onNewIntent` 中触发设备列表刷新。

## 3.3 扫描二维码页（ScanQrActivity）

### 3.3.1 UI 规范

全屏黑色背景 + 相机预览 + 方形取景框 + 底部操作区。

```
┌─────────────────────────────┐
│ [←]    扫描二维码    [手动]  │  ← 顶部栏（覆盖在相机上）
│                             │
│         ┌─────────┐         │
│         │□       □│         │  ← 220dp 取景框
│         │---扫描线-│         │    4 个角标 + 上下动画扫描线
│         │□       □│         │
│         └─────────┘         │
│                             │
│       [💡闪光]  [🖼相册]      │
│  ┌─────────────────────┐    │
│  │请扫描设备机身...│📷 │    │  ← 底部提示卡片
│  └─────────────────────┘    │
└─────────────────────────────┘
```

- 使用 `com.journeyapps:zxing-android-embedded:4.3.0` 的 `DecoratedBarcodeView`
- 只扫 QR：`DefaultDecoderFactory(Collections.singletonList(BarcodeFormat.QR_CODE))`
- 取景框：220dp × 220dp，4 个角标（24dp × 3dp 白色线条）
- 扫描线：蓝色（`#3B82F6`）2dp，`ObjectAnimator translationY` 无限反复动画（2000ms）
- 闪光灯：`setTorchOn / setTorchOff`
- 提示卡片：半透明黑底（`#CC333333`，`bg_scan_hint`） + 左文字右小图标

### 3.3.2 权限处理

- 进入页面请求相机权限（`Manifest.permission.CAMERA`）
- 用户拒绝：Toast 提示 + `finish()`
- 在 `onResume` 中 `barcodeView.resume()`，`onPause` 中 `barcodeView.pause()`

### 3.3.3 二维码解析规则

扫码结果同时支持两种格式，按顺序尝试：

```java
String serial = "";
String verifyCode = "";

// 格式 1：serial:XXXXX,code:YYYYY
if (qrContent.contains(",")) {
    String[] parts = qrContent.split(",");
    for (String part : parts) {
        String trimmed = part.trim();
        if (trimmed.toLowerCase().startsWith("serial:")) {
            serial = trimmed.substring(7).trim();
        } else if (trimmed.toLowerCase().startsWith("code:")) {
            verifyCode = trimmed.substring(5).trim();
        }
    }
}

// 格式 2：按非字母数字字符分割，9 位序列号，6 位验证码
if (serial.isEmpty()) {
    String[] segments = qrContent.trim().split("[^a-zA-Z0-9]+");
    for (String seg : segments) {
        if (seg.length() == 9 && serial.isEmpty()) serial = seg;
        else if (seg.length() == 6 && verifyCode.isEmpty()) verifyCode = seg;
    }
    if (serial.isEmpty()) serial = qrContent.trim();
}
```

### 3.3.4 相册选图识别

使用 `ActivityResultLauncher` + `ACTION_PICK`，读取到 Uri 后在子线程解码：

```java
// 关键步骤：
// 1. 限制最大边 1500px，用 inSampleSize 做缩放避免 OOM
// 2. 处理 EXIF 旋转（androidx.exifinterface:exifinterface:1.3.7）
// 3. 使用 MultiFormatReader + TRY_HARDER 解码
// 4. 先尝试 HybridBinarizer，失败再尝试 GlobalHistogramBinarizer
// 5. 原图解码失败时，缩放到 max 800px 再试一次
```

`RGBLuminanceSource` + `HybridBinarizer` / `GlobalHistogramBinarizer` + `MultiFormatReader.decode()`。

### 3.3.5 业务跳转

扫码成功 → `DeviceConfirmActivity`（携带 `serial` + `verify_code`）
点击手动输入 → `InputSerialActivity`

### 3.3.6 防重复扫描

```java
private boolean mScanned = false;
mBarcodeView.decodeContinuous(result -> {
    if (mScanned) return;
    mScanned = true;
    onScanSuccess(result.getText());
});
// onResume 中重置为 false
```

## 3.4 输入序列号页（InputSerialActivity）

### 3.4.1 UI 规范

```
┌─────────────────────────────┐
│ [←]    输入设备信息          │
├─────────────────────────────┤
│  在设备上查看设备序列号...    │
│                             │
│  ┌───────────────────┐      │
│  │  [设备标签示意图]   │     │  ← 140dp 引导图 pic_serialnumber_guide
│  └───────────────────┘      │
│                             │
│  ┌───────────────────┐      │
│  │ 输入设备的序列号  ✕│     │  ← maxLength=9, textCapCharacters
│  └───────────────────┘      │
│  ┌───────────────────┐      │
│  │ 输入设备的验证码  ✕│     │  ← maxLength=6, textCapCharacters
│  └───────────────────┘      │
│                             │
│  [         确认         ]    │
└─────────────────────────────┘
```

### 3.4.2 交互逻辑

- 两个输入框右侧的 "✕" 清空按钮：有文本时显示，点击清空文本
- 确认按钮启用条件：**序列号长度 == 9 且 验证码长度 == 6**
- 序列号 `maxLength=9`，`inputType="textCapCharacters"`
- 验证码 `maxLength=6`，`inputType="textCapCharacters"`

```java
binding.btnConfirm.setEnabled(serial.length() == 9 && verify.length() == 6);
```

点击确认 → `DeviceConfirmActivity`。

## 3.5 设备确认页（DeviceConfirmActivity）

### 3.5.1 UI 规范（两种状态）

使用 `ViewFlipper` 切换：加载状态 / 结果状态。

**加载状态（child 0）**：
```
┌─────────────────────────────┐
│       ← 设备确认             │
│                             │
│       ┌──────────┐          │
│       │雷达动画   │          │  ← Lottie 220dp，add_radar.json
│       │ (Lottie) │          │
│       └──────────┘          │
│  正在查询设备信息，请稍等… │
└─────────────────────────────┘
```

**结果状态（child 1）**：
```
┌─────────────────────────────┐
│       ← 设备确认             │
│  ┌───────────────────┐      │
│  │  [设备图片]        │     │  ← defaultPicPath
│  └───────────────────┘      │
│    设备名称 (displayName)   │
│    [红色错误信息 可选]       │
│                             │
│    ⬜ 阅读并同意 产品服务使用协议│  ← 勾选项
│    ⬜ 设备已通电              │  ← 勾选项
│                             │
│  [    下一步 / 添加设备    ]  │
└─────────────────────────────┘
```

### 3.5.2 业务逻辑

进入页面后子线程调用 `probeDeviceInfo(serial, null)`，根据返回结果走不同分支。

```java
binding.lottieRadar.setAnimation("add_radar.json");
binding.lottieRadar.setRepeatCount(LottieDrawable.INFINITE);
binding.lottieRadar.playAnimation();

new Thread(() -> {
    EZProbeDeviceInfoResult result = EZOpenSDK.getInstance().probeDeviceInfo(mSerial, null);
    runOnUiThread(() -> {
        binding.lottieRadar.cancelAnimation();
        if (result == null) { showError("网络异常", null); return; }
        if (result.getBaseException() == null && result.getEZProbeDeviceInfo() != null) {
            showDeviceInfo(result.getEZProbeDeviceInfo());   // 设备在线，可直接添加
        } else {
            handleProbeError(result);                        // 按错误码走分支
        }
    });
}).start();
```

### 3.5.3 错误码分类处理

| 错误码 | 含义 | 处理 |
|-------|------|------|
| 120023 | 设备不在线、未被用户添加 | 进入配网流程（显示设备信息 + 勾选 + 下一步） |
| 120002 | 设备不存在、未被用户添加 | 进入配网流程 |
| 120029 | 设备不在线、已经被自己添加 | 进入配网流程 |
| 120020 | 设备在线、已经被自己添加 | 使用 SDK 错误描述显示红色错误信息 |
| 120022 | 设备在线、已经被别的用户添加 | 使用 SDK 错误描述显示红色错误信息 |
| 120024 | 设备不在线、已经被别的用户添加 | 使用 SDK 错误描述显示红色错误信息 |
| 120021 | 设备在线、未被用户添加（不需要配网） | 使用 SDK 错误描述显示红色错误信息 |
| 102020/102030 | 设备版本/硬件不支持 | 使用 SDK 错误描述显示红色错误信息 |
| 120004 | 硬件特征码验证失败 | 使用 SDK 错误描述显示红色错误信息 |
| 120006 | 网络异常 | 显示错误提示 |
| 150000 | 服务器异常 | 显示错误提示 |
| 110002 | access_token 异常 | 显示错误提示 |

错误描述优先从 `result.getBaseException().getErrorInfo().description` 获取，为空时用默认文案。

### 3.5.4 两种业务分支

**分支 A：设备在线且未被添加**（`showDeviceInfo`）
- 加载设备图片（`deviceInfo.getDefaultPicPath()`）和名称（`deviceInfo.getDisplayName()`）
- 只显示"阅读并同意协议"勾选项（单勾）
- 隐藏"设备已通电"勾选项和"下一步"按钮
- 显示"添加设备"按钮，勾选协议后启用，点击直接 `addDevice(serial, verifyCode)` 添加，返回主页

**分支 B：设备需要配网**（`showDeviceOffline` / 离线错误码）
- 显示设备图片和名称
- 显示双勾选项："协议" + "设备已通电"
- 显示"下一步"按钮，双勾后启用，点击进入 `InitDeviceActivity`

传递给下一页的 `Intent` 带上 `probe_device_info`（`EZProbeDeviceInfo` 实现了 Parcelable）和 `device_pic_url`，后续页面可复用。

### 3.5.5 设备图片加载

使用 Coil（已依赖 `io.coil-kt:coil:2.7.0`）加载：

```java
ImageLoader imageLoader = Coil.imageLoader(this);
ImageRequest request = new ImageRequest.Builder(this)
        .data(picUrl)
        .target(binding.ivDevice)
        .placeholder(R.drawable.ic_device_placeholder)
        .error(R.drawable.ic_device_placeholder)
        .build();
imageLoader.enqueue(request);
```

### 3.5.6 直接添加设备（分支 A）

```java
new Thread(() -> {
    try {
        EZOpenSDK.getInstance().addDevice(mSerial, mVerifyCode);
        runOnUiThread(() -> {
            Toast.makeText(this, R.string.device_confirm_add_success, Toast.LENGTH_SHORT).show();
            Intent intent = new Intent(this, MainActivity.class);
            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
            startActivity(intent);
            finish();
        });
    } catch (Exception e) {
        // 恢复按钮状态 + Toast 错误信息
    }
}).start();
```

## 3.6 初始化设备页（InitDeviceActivity）

### 3.6.1 UI 规范

```
┌─────────────────────────────┐
│       ← 初始化设备           │
│ ■■□□                        │  ← 步骤条
│  ┌───────────────────┐      │
│  │  [设备图片]        │     │  ← 与 DeviceConfirm 一致
│  └───────────────────┘      │
│  请将设备插上电源，耐心等待约1│
│  分钟，直到指示灯呈蓝色快速闪烁│
│                             │
│  ⬜ 指示灯已经蓝色快闪         │
│  [    下一步    ]            │  ← 勾选后启用
│  指示灯没有蓝色快闪？          │  ← 文字链接（蓝色）
└─────────────────────────────┘
```

### 3.6.2 业务逻辑

- "下一步"默认禁用，勾选"指示灯已经蓝色快闪"后启用
- 点击下一步 → `WifiSelectActivity`（透传 `serial / verify_code / probe_device_info`）
- 点击"指示灯没有蓝色快闪？" → `ResetWifiActivity`

## 3.7 重置 Wi-Fi 页（ResetWifiActivity）

```
┌─────────────────────────────┐
│       ← 重置 Wi-Fi           │
│  ┌───────────────────┐      │
│  │ [Reset 按钮示意图] │      │
│  └───────────────────┘      │
│  请根据图示长按 reset 键…    │
│                             │
│  [    下一步    ]            │  ← 返回上一页（finish）
│  我连接了网线                 │  ← 点击 Toast 提示
└─────────────────────────────┘
```

- 引导图：`pic_reset_wifi_guide.png`
- "下一步"点击 `finish()` 返回 `InitDeviceActivity` 让用户继续勾选
- "我连接了网线" Toast 提示（实际项目中可跳转至有线配网流程）

## 3.8 选择 Wi-Fi 页（WifiSelectActivity）

### 3.8.1 UI 规范

```
┌─────────────────────────────┐
│  ←  选择路由器 Wi-Fi 并输入密码│
│ ■■■□                        │  ← 步骤条
│  ┌──────────────────┐       │
│  │ 请选择 Wi-Fi │选择网络│    │  ← SSID 只读
│  └──────────────────┘       │
│  ┌──────────────────┐       │
│  │ 请输入您的 Wi-Fi 密码 👁 │ │  ← 密码 + 可见性切换
│  └──────────────────┘       │
│  若无密码则无需输入           │
│                             │
│  [    下一步    ]            │  ← SSID 非空才启用
└─────────────────────────────┘
```

### 3.8.2 SSID 输入框（只读，点击跳转列表）

```java
binding.etSsid.setFocusable(false);
binding.etSsid.setFocusableInTouchMode(false);
binding.etSsid.setCursorVisible(false);

binding.btnSelectWifi.setOnClickListener(v -> wifiListLauncher.launch(
        new Intent(this, WifiListActivity.class)));
binding.etSsid.setOnClickListener(v -> wifiListLauncher.launch(
        new Intent(this, WifiListActivity.class)));
```

### 3.8.3 密码可见性切换

```java
binding.btnTogglePassword.setOnClickListener(v -> {
    mPasswordVisible = !mPasswordVisible;
    if (mPasswordVisible) {
        binding.etPassword.setInputType(InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
        binding.btnTogglePassword.setImageResource(R.drawable.ic_eye_off);
    } else {
        binding.etPassword.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
        binding.btnTogglePassword.setImageResource(R.drawable.ic_eye);
    }
    binding.etPassword.setSelection(binding.etPassword.length());
});
```

### 3.8.4 自动获取当前 Wi-Fi

进入页面时自动读取手机当前连接的 Wi-Fi，**非 5G 频段**才填入 SSID 输入框（设备大多不支持 5G）：

```java
WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
String ssid = wifiInfo.getSSID();
if (ssid != null && !"<unknown ssid>".equals(ssid)) {
    if (ssid.startsWith("\"") && ssid.endsWith("\"")) {
        ssid = ssid.substring(1, ssid.length() - 1);       // 去掉前后引号
    }
    int freq = wifiInfo.getFrequency();
    boolean is5G = (freq >= 4900 && freq <= 5900);         // 5G 频段
    if (!is5G) {
        binding.etSsid.setText(ssid);
        // 自动填充缓存密码
        String cachedPwd = WifiPasswordCache.get(this, ssid);
        if (cachedPwd != null) binding.etPassword.setText(cachedPwd);
    }
}
```

### 3.8.5 密码本地缓存

用户配网成功后，对应 SSID 的密码缓存到 `SharedPreferences`，下次选择同一 SSID 时自动回填。

```java
public class WifiPasswordCache {
    private static final String PREF_NAME = "wifi_password_cache";
    public static void save(Context ctx, String ssid, String password) {
        if (ssid == null || ssid.isEmpty()) return;
        ctx.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit()
                .putString(ssid, password != null ? password : "").apply();
    }
    public static String get(Context ctx, String ssid) {
        if (ssid == null || ssid.isEmpty()) return null;
        return ctx.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).getString(ssid, null);
    }
}
```

**缓存时机**：`ConfiguringActivity` 收到"成功连接网络"回调（`onStepConnected`）时立即缓存，避免 WiFi 密码错误也被缓存。

## 3.9 Wi-Fi 列表页（WifiListActivity）

### 3.9.1 UI 规范

```
┌─────────────────────────────┐
│       ←  选择 Wi-Fi          │
│  请选择家庭或商铺路由器的 Wi-Fi │
│  如超过一个网络源，请选择…   │
│                             │
│  找不到想连接的网络            [🔄]│
│  ─────────────────────       │
│  HOME-Wi-Fi              📶 │
│  ─────────────────────       │
│  XIAOMI-5G                  │  ← 5G 灰化 + "此设备不支持 5GHz"
│    此设备不支持 5GHz      📶 │
│  ─────────────────────       │
└─────────────────────────────┘
```

### 3.9.2 扫描周围 Wi-Fi

```java
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION);  // 动态权限
mWifiManager.startScan();

IntentFilter filter = new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION);
registerReceiver(mWifiReceiver, filter);   // 监听扫描完成广播
// BroadcastReceiver 中 loadScanResults()

List<ScanResult> results = mWifiManager.getScanResults();
// 去重：按 SSID 去重
// 5G 判定：frequency > 4900
```

### 3.9.3 5G Wi-Fi 处理

- 设备多数不支持 5G 频段
- 列表中的 5G Wi-Fi 条目整体 `alpha=0.5`，标注"此设备不支持 5GHz"
- `itemView.setClickable(false)`，不响应点击

```java
if (item.is5G) {
    holder.binding.tvWifiNote.setVisibility(View.VISIBLE);
    holder.binding.tvWifiNote.setText(R.string.wifi_5g_not_support);
    holder.binding.ivWifiSignal.setAlpha(0.3f);
    holder.itemView.setAlpha(0.5f);
    holder.itemView.setOnClickListener(null);
    holder.itemView.setClickable(false);
}
```

### 3.9.4 返回结果

点击一个非 5G 条目：
```java
Intent data = new Intent();
data.putExtra("ssid", selectedSsid);
setResult(RESULT_OK, data);
finish();
```

`WifiSelectActivity` 通过 `ActivityResultLauncher` 接收结果，自动回填 SSID 并从缓存读取对应密码。

## 3.10 选择配网方式页（ConfigMethodActivity）

### 3.10.1 UI 规范

```
┌─────────────────────────────┐
│       ← 选择配网方式          │
│ ■■■□                        │  ← 步骤条
│  ┌──────────────────────┐   │
│  │🟢  AP配网              ›│ │  ← 绿色图标
│  │    连接设备热点进行配网  │  │
│  └──────────────────────┘   │
│  ┌──────────────────────┐   │
│  │🔵  声波配网            ›│ │  ← 蓝色图标
│  │    通过手机扬声器…     │  │
│  └──────────────────────┘   │
│  ┌──────────────────────┐   │
│  │🟣  Smart配网           ›│ │  ← 紫色图标
│  │    通过 Wi-Fi 广播方式… │  │
│  └──────────────────────┘   │
│  ┌──────────────────────┐   │
│  │🟠  声波/Smart 组合配网  ›│ │  ← 橙色图标
│  │    同时使用…           │  │
│  └──────────────────────┘   │
└─────────────────────────────┘
```

每张卡片：`bg_method_card`（白底圆角边框）+ 左侧 44dp 彩色图标（`bg_method_icon_{green|blue|purple|orange}` + `ic_config_{ap|sound|smart|combo}`）+ 中间名称/描述 + 右侧 `›` 箭头。

### 3.10.2 根据设备能力集动态显示

使用从 `DeviceConfirmActivity` 透传的 `EZProbeDeviceInfo` 判断：

```java
boolean supportAP = info.getSupportAP() == 2;
boolean supportSoundWave = info.getSupportSoundWave() == 1;
boolean supportSmartConfig = info.getSupportWifi() > 0;

binding.cardAp.setVisibility(supportAP ? View.VISIBLE : View.GONE);
binding.cardSoundWave.setVisibility(supportSoundWave ? View.VISIBLE : View.GONE);
binding.cardSmartConfig.setVisibility(supportSmartConfig ? View.VISIBLE : View.GONE);
// 组合配网：声波和 Smart 都支持时才显示
binding.cardCombo.setVisibility(
    (supportSoundWave && supportSmartConfig) ? View.VISIBLE : View.GONE);
```

若 `probeDeviceInfo` 为空（页面直接进入），默认 4 种全部显示。

### 3.10.3 业务跳转

点击一种方式 → `ConfiguringActivity`，通过 `config_mode` 标识：
- AP 配网：`"ap"`
- 声波配网：`"sound"`
- Smart 配网：`"smart"`
- 组合配网：`"combo"`

## 3.11 正在配置页（ConfiguringActivity）

### 3.11.1 UI 规范

```
┌─────────────────────────────┐
│       ← 正在配置             │
│ ■■■■                        │  ← 步骤条 4 段全绿
│                             │
│         ╭──────╮            │
│        │  42 %  │           │  ← 160dp 圆形进度环
│         ╰──────╯            │
│                             │
│  正在配置设备网络，请耐心等待  │  ← 初始提示，连接成功后隐藏
│                             │
│  成功连接网络 ✓              │  ← 步骤 1（连接成功后显示）
│  正在注册到平台               │  ← 步骤 2（→ 完成后带 ✓）
│  正在绑定账号                 │  ← 步骤 3（→ 完成后带 ✓）
│  配置完成 ✓                  │  ← 步骤 4（全部完成后显示）
│                             │
│  [      完成      ]          │  ← 成功或失败时显示
└─────────────────────────────┘
```

### 3.11.2 进度环与自增进度

使用标准 `ProgressBar` + `progressDrawable="@drawable/bg_configuring_ring"`：
- 背景环：灰色（`#F0F0F0`），`innerRadiusRatio=2.5, thicknessRatio=14`
- 进度环：蓝色（`#5B8DEF`），`rotate` 从 `270°` 开始

进度条自动每秒 +1，最大到 99：

```java
private int mCurrentProgress = 0;
private boolean mStopAutoProgress = false;

private final Runnable mProgressRunnable = new Runnable() {
    @Override
    public void run() {
        if (mStopAutoProgress || mCurrentProgress >= 99) return;
        mCurrentProgress++;
        binding.progressRing.setProgress(mCurrentProgress);
        binding.tvPercent.setText(String.valueOf(mCurrentProgress));
        mHandler.postDelayed(this, 1000);
    }
};
```

绑定成功后 `mStopAutoProgress = true` 并直接跳到 100%。

### 3.11.3 4 种配网模式的发起

根据 `config_mode` 参数分支：

```java
private void startConfig() {
    if ("ap".equals(mConfigMode)) {
        startAPConfig();
    } else if ("sound".equals(mConfigMode)) {
        startWifiConfig(EZConstants.EZWiFiConfigMode.EZWiFiConfigWave);
    } else if ("smart".equals(mConfigMode)) {
        startWifiConfig(EZConstants.EZWiFiConfigMode.EZWiFiConfigSmart);
    } else if ("combo".equals(mConfigMode)) {
        startWifiConfig(EZConstants.EZWiFiConfigMode.EZWiFiConfigWave
                | EZConstants.EZWiFiConfigMode.EZWiFiConfigSmart);
    }
}
```

#### AP 配网

```java
EZOpenSDK.getInstance().startAPConfigWifiWithSsid(
        mSsid, mPassword, mSerial, mVerifyCode,
        "", "",                       // 设备热点名/密码空，SDK 自动识别
        true,                         // autoConnect=true 自动连接设备热点
        new APWifiConfig.APConfigCallback() {
            @Override
            public void onSuccess() {
                // WiFi 信息已发送给设备
                runOnUiThread(() -> onStepConnected());
            }
            @Override
            public void onInfo(int code, String message) {
                if (code == EZConfigWifiInfoEnum.CONNECTED_TO_PLATFORM.code) {
                    runOnUiThread(() -> onStepRegistered());  // 已注册到平台
                }
            }
            @Override
            public void OnError(int code) {
                runOnUiThread(() -> onConfigFailed(code));
            }
        });
```

#### 声波 / Smart / 组合

```java
EZOpenSDK.getInstance().startConfigWifi(this, mSerial, mSsid, mPassword, mode,
        (deviceSerial, status) -> runOnUiThread(() -> {
            if (status == EZConstants.EZWifiConfigStatus.DEVICE_WIFI_CONNECTED) {
                onStepConnected();
            } else if (status == EZConstants.EZWifiConfigStatus.DEVICE_PLATFORM_REGISTED) {
                onStepRegistered();
            }
        }));
```

### 3.11.4 步骤联动

```java
// 收到"连接网络"回调
private void onStepConnected() {
    WifiPasswordCache.save(this, mSsid, mPassword);     // 密码缓存
    binding.tvHint.setVisibility(View.GONE);            // 隐藏初始提示
    binding.stepConnect.setVisibility(View.VISIBLE);    // 显示"成功连接网络 ✓"
    binding.stepRegister.setVisibility(View.VISIBLE);   // 显示"正在注册到平台"
}

// 收到"注册到平台"回调
private void onStepRegistered() {
    binding.tvStepRegister.setText(R.string.configuring_step_register_done);
    binding.checkRegister.setVisibility(View.VISIBLE);
    binding.stepBindaccount.setVisibility(View.VISIBLE);
    addDevice();     // 开始绑定
}

// 绑定设备
private void addDevice() {
    new Thread(() -> {
        try {
            EZOpenSDK.getInstance().addDevice(mSerial, mVerifyCode);
            runOnUiThread(() -> onStepBound());
        } catch (Exception e) {
            // 错误码 120023 表示设备已被自己添加，视为成功
            if (e.getMessage() != null && e.getMessage().contains("120023")) {
                runOnUiThread(() -> onStepBound());
            } else {
                runOnUiThread(() -> onBindFailed());
            }
        }
    }).start();
}

// 绑定成功
private void onStepBound() {
    mStopAutoProgress = true;
    mCurrentProgress = 100;
    binding.progressRing.setProgress(100);
    binding.tvPercent.setText("100");
    binding.tvStepBind.setText(R.string.configuring_step_bind_done);
    binding.checkBind.setVisibility(View.VISIBLE);
    binding.stepComplete.setVisibility(View.VISIBLE);
    binding.btnDone.setVisibility(View.VISIBLE);
}
```

### 3.11.5 配网失败处理

```java
private void onConfigFailed(int code) {
    // 手机未连接到目标 WiFi，这是 AP 配网预期状态，忽略
    if (code == EZConfigWifiErrorEnum.PHONE_NOT_CONNECTED_TO_TARGET_WIFI.code) return;

    mStopAutoProgress = true;
    binding.tvHint.setVisibility(View.GONE);
    binding.stepRegister.setVisibility(View.VISIBLE);
    binding.tvStepRegister.setText(R.string.configuring_step_register);
    binding.checkRegister.setVisibility(View.GONE);
    binding.tvFailHint.setVisibility(View.VISIBLE);         // 红色失败提示
    binding.btnDone.setVisibility(View.VISIBLE);
    binding.btnDone.setText(R.string.retry);                // "完成"变"重试"
}
```

常见错误码映射见 `ezopensdk-wifi.md` 的"AP 配网错误码"表。

### 3.11.6 完成后跳转

```java
private void navigateToDeviceList() {
    Intent intent = new Intent(this, MainActivity.class);
    intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
    startActivity(intent);
    finish();
}
```

`MainActivity.onNewIntent` 中调用 `loadDevices(true)` 刷新列表。

### 3.11.7 生命周期清理

```java
@Override
protected void onDestroy() {
    super.onDestroy();
    mStopAutoProgress = true;
    mHandler.removeCallbacksAndMessages(null);
    EZOpenSDK.getInstance().stopConfigWiFi();
    EZOpenSDK.getInstance().stopAPConfigWifiWithSsid();
}
```

## 3.12 Activity 注册（AndroidManifest.xml）

配网流程所有 Activity 必须在 Manifest 中注册，统一 `screenOrientation="portrait"`：

```xml
<activity android:name=".ui.wifi.ScanQrActivity"         android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.InputSerialActivity"    android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.DeviceConfirmActivity"  android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.InitDeviceActivity"     android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.ResetWifiActivity"      android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.WifiSelectActivity"     android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.WifiListActivity"       android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.ConfigMethodActivity"   android:screenOrientation="portrait" />
<activity android:name=".ui.wifi.ConfiguringActivity"    android:screenOrientation="portrait" />
```

## 3.13 权限声明

```xml
<!-- 配网所需 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- 扫码 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 相册选图（Android 13+ 用 READ_MEDIA_IMAGES） -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
```

动态权限申请：
- `ScanQrActivity` onCreate 申请 `CAMERA`
- `WifiListActivity` onCreate 申请 `ACCESS_FINE_LOCATION`

## 3.14 依赖要求

```groovy
// 二维码扫描
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
// 图片加载（设备图）
implementation 'io.coil-kt:coil:2.7.0'
// Lottie 动画（雷达动画）
implementation 'com.airbnb.android:lottie:6.4.0'
// EXIF 处理（相册图片旋转）
implementation 'androidx.exifinterface:exifinterface:1.3.7'

// AndroidX
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.cardview:cardview:1.0.0'
```

## 3.15 二进制资源（PNG / Lottie）

配网流程用到 3 张 PNG 引导图和 1 个 Lottie 动画，这些无法用文本生成的资源已经随 skill 打包，位于 `skills/ezopensdk-android-skill/assets/wifi/` 目录。

### 资源清单

| 文件名 | 目标路径 | 用途 |
|--------|----------|------|
| `add_img_scan_tip.png` | `app/src/main/res/drawable/` | 扫码页底部"二维码位置"提示小图 |
| `pic_serialnumber_guide.png` | `app/src/main/res/drawable/` | 输入序列号页的"设备标签示意图" |
| `pic_reset_wifi_guide.png` | `app/src/main/res/drawable/` | 重置 Wi-Fi 页的"Reset 按钮示意图" |
| `add_radar.json` | `app/src/main/assets/` | 设备确认页的"雷达搜索动画" |

### 生成配网工程时必须执行的复制步骤

```bash
# 假设 SKILL_DIR=~/.kiro 或当前 workspace 下 skills 目录
cp $SKILL_DIR/ezopensdk-android-skill/assets/wifi/*.png app/src/main/res/drawable/
cp $SKILL_DIR/ezopensdk-android-skill/assets/wifi/add_radar.json app/src/main/assets/
```

> **重要**：生成配网页面代码时，AI 必须同时把上述 4 个文件从 skill 资源目录复制到 Android 工程对应目录，否则会导致编译报错或运行时资源缺失。

### Lottie 引用方式

```java
binding.lottieRadar.setAnimation("add_radar.json");   // 相对 assets/ 的路径
binding.lottieRadar.setRepeatCount(LottieDrawable.INFINITE);
binding.lottieRadar.playAnimation();
```

### 降级方案（资源文件丢失时）

| 资源 | 降级替代 |
|------|----------|
| 3 张 PNG 引导图 | 纯色矩形 + 对应文字提示占位 |
| `add_radar.json` | 使用 `ProgressBar` 旋转动画替代 |

详细说明见 `assets/wifi/README.md`。

#[[file:assets/wifi/README.md]]


## 3.16 Drawable 资源参考

### 3.16.1 步骤条

#### bg_step_done.xml — 步骤完成（蓝色）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#3B82F6" />
    <corners android:radius="2dp" />
</shape>
```

#### bg_step_pending.xml — 步骤未完成（灰色）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/border" />
    <corners android:radius="2dp" />
</shape>
```

### 3.16.2 通用控件

#### bg_btn_blue.xml — 底部主按钮（全圆角胶囊 + 3 态）
```xml
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_enabled="false">
        <shape android:shape="rectangle">
            <solid android:color="#A8C4F0" /><corners android:radius="999dp" />
        </shape>
    </item>
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <solid android:color="#1E6AD4" /><corners android:radius="999dp" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="#3B82F6" /><corners android:radius="999dp" />
        </shape>
    </item>
</selector>
```

#### bg_input_field.xml — 输入框背景
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/white" />
    <stroke android:width="1dp" android:color="@color/border" />
    <corners android:radius="10dp" />
</shape>
```

#### bg_device_img.xml — 设备图片占位背景
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/bg_secondary" />
    <corners android:radius="10dp" />
</shape>
```

#### bg_check_circle.xml — 勾选框（选中/未选中）
```xml
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true" android:drawable="@drawable/ic_check_selected" />
    <item android:drawable="@drawable/ic_check_unselected" />
</selector>
```

`ic_check_selected.xml` 绘制蓝色实心圆 + 白色对勾，`ic_check_unselected.xml` 绘制灰色空心圆环。

### 3.16.3 配网方式卡片

#### bg_method_card.xml — 卡片背景（白底圆角边框）
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="@color/white" />
    <stroke android:width="1dp" android:color="@color/border" />
    <corners android:radius="10dp" />
</shape>
```

#### bg_method_icon_{color}.xml — 配网方式图标背景（半透明色块 10dp 圆角）
```xml
<!-- bg_method_icon_green：AP 配网（#1A22C55E 绿色 10% 透明度） -->
<!-- bg_method_icon_blue：声波配网（#1A3B82F6 蓝色 10% 透明度） -->
<!-- bg_method_icon_purple：Smart 配网（#1A8B5CF6 紫色 10% 透明度） -->
<!-- bg_method_icon_orange：组合配网（#1AF59E0B 橙色 10% 透明度） -->
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#1A3B82F6" />
    <corners android:radius="10dp" />
</shape>
```

#### ic_config_ap.xml / ic_config_sound.xml / ic_config_smart.xml / ic_config_combo.xml

配网方式图标使用 Material Design 风格 vector（路由器、声波、广播、组合），strokeColor 对应主题色（绿/蓝/紫/橙）。

### 3.16.4 正在配置页

#### bg_configuring_ring.xml — 圆形进度环
```xml
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background">
        <shape android:shape="ring" android:innerRadiusRatio="2.5"
               android:thicknessRatio="14" android:useLevel="false">
            <solid android:color="#F0F0F0" />
        </shape>
    </item>
    <item android:id="@android:id/progress">
        <rotate android:fromDegrees="270" android:toDegrees="270"
                android:pivotX="50%" android:pivotY="50%">
            <shape android:shape="ring" android:innerRadiusRatio="2.5"
                   android:thicknessRatio="14" android:useLevel="true">
                <solid android:color="#5B8DEF" />
            </shape>
        </rotate>
    </item>
</layer-list>
```

#### ic_check_blue.xml — 步骤完成勾（蓝色 16dp）
```xml
<vector android:width="16dp" android:height="16dp" android:viewportWidth="24" android:viewportHeight="24">
    <path android:fillColor="#3B82F6"
          android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41z" />
</vector>
```

### 3.16.5 扫码页

#### bg_scan_hint.xml — 底部提示卡片半透明黑底
```xml
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
    <solid android:color="#CC333333" />
    <corners android:radius="12dp" />
</shape>
```

#### ic_back_white.xml / ic_flashlight.xml / ic_album.xml / ic_manual_input.xml / ic_qrcode.xml

扫码页工具按钮图标（白色线条 vector，24dp）。

### 3.16.6 引导图片（PNG 二进制资源）

以下 3 张 PNG 位图由 skill 提供，位于 `skills/ezopensdk-android-skill/assets/wifi/`，生成工程时直接复制到目标工程的 `app/src/main/res/drawable/`：

- `pic_serialnumber_guide.png` — 输入序列号页的设备标签示意图
- `pic_reset_wifi_guide.png` — 重置 Wi-Fi 页的 Reset 按钮示意图
- `add_img_scan_tip.png` — 扫码页的二维码位置提示小图

Lottie 动画 `add_radar.json` 同样来自 skill 资源，需复制到 `app/src/main/assets/`。

资源使用说明详见 3.15 节和 `assets/wifi/README.md`。

## 3.17 国际化字符串

### values/strings.xml（英文）

```xml
<!-- 扫描二维码页 -->
<string name="scan_title">Scan QR Code</string>
<string name="scan_back">Back</string>
<string name="scan_manual_input">Manual Input</string>
<string name="scan_flashlight">Flashlight</string>
<string name="scan_album">Album</string>
<string name="scan_hint_qr">QR Code</string>
<string name="scan_hint_text">Please scan the QR code on the device body or manual to add the device</string>
<string name="scan_camera_permission_denied">Camera permission is required for scanning</string>
<string name="scan_album_decode_fail">No QR code found in the image</string>

<!-- 输入序列号页 -->
<string name="input_serial_title">Enter Device Info</string>
<string name="input_serial_subtitle">Find the device serial number and verify code on the device and enter them here</string>
<string name="input_serial_device_img">Device label</string>
<string name="input_serial_hint">Enter device serial number</string>
<string name="input_serial_verify_hint">Enter device verify code</string>
<string name="input_serial_clear">Clear</string>
<string name="input_serial_confirm">Confirm</string>

<!-- 设备确认页 -->
<string name="device_confirm_title">Device Confirm</string>
<string name="device_confirm_loading">Querying device info, please wait…</string>
<string name="device_confirm_query_fail">Failed to query device info</string>
<string name="device_confirm_error_network">Network unavailable, please check your connection</string>
<string name="device_confirm_error_added_self">Device is already added to your account</string>
<string name="device_confirm_error_added_other">Device has been added by another user</string>
<string name="device_confirm_error_offline">Device is offline, you can configure Wi-Fi</string>
<string name="device_confirm_device_img">Device image</string>
<string name="device_confirm_check">Check</string>
<string name="device_confirm_check_owner_prefix">Read and agree to </string>
<string name="device_confirm_check_owner_link">Product Service Agreement</string>
<string name="device_confirm_check_reset">Device is powered on</string>
<string name="device_confirm_next">Next</string>
<string name="device_confirm_add_device">Add Device</string>
<string name="device_confirm_adding">Adding device…</string>
<string name="device_confirm_add_success">Device added successfully</string>
<string name="device_confirm_add_fail">Failed to add device: %s</string>

<!-- 初始化设备页 -->
<string name="init_device_title">Initialize Device</string>
<string name="init_device_img">Device image</string>
<string name="init_device_desc">Please power on the device and wait about 1 minute until the indicator light flashes blue rapidly</string>
<string name="init_device_check_led">Indicator light is flashing blue rapidly</string>
<string name="init_device_next">Next</string>
<string name="init_device_led_not_flash">Indicator light is not flashing blue?</string>

<!-- 选择Wi-Fi页 -->
<string name="wifi_select_title">Select Router Wi-Fi and Enter Password</string>
<string name="wifi_select_ssid_hint">Please select Wi-Fi</string>
<string name="wifi_select_choose_network">Select Network</string>
<string name="wifi_select_pwd_hint">Please enter your Wi-Fi password</string>
<string name="wifi_select_toggle_pwd">Toggle password visibility</string>
<string name="wifi_select_no_pwd_hint">Leave empty if no password required</string>
<string name="wifi_select_next">Next</string>

<!-- Wi-Fi列表页 -->
<string name="wifi_list_title">Select Wi-Fi</string>
<string name="wifi_list_desc">Please select the Wi-Fi of your home or shop router</string>
<string name="wifi_list_sub_desc">If there are multiple networks, select the one closest to the device installation location</string>
<string name="wifi_list_not_found">Can\'t find the network you want?</string>
<string name="wifi_list_refresh">Refresh</string>
<string name="wifi_list_signal">Wi-Fi signal</string>
<string name="wifi_5g_not_support">This device does not support 5GHz</string>

<!-- 重置Wi-Fi页 -->
<string name="reset_wifi_title">Reset Wi-Fi</string>
<string name="reset_wifi_device_img">Device reset image</string>
<string name="reset_wifi_desc">Please long press the reset button as shown until the device prompts reset success, then release</string>
<string name="reset_wifi_next">Next</string>
<string name="reset_wifi_wired">I connected via Ethernet cable</string>

<!-- 选择配网方式页 -->
<string name="config_method_title">Select Config Method</string>
<string name="config_method_sound">Sound Wave Config</string>
<string name="config_method_sound_desc">Configure via phone speaker sending sound wave signal</string>
<string name="config_method_ap">AP Config</string>
<string name="config_method_ap_desc">Connect to device hotspot for configuration, wider compatibility</string>
<string name="config_method_smart">Smart Config</string>
<string name="config_method_smart_desc">Quick config via Wi-Fi broadcast</string>
<string name="config_method_combo">Sound/Smart Combo Config</string>
<string name="config_method_combo_desc">Use both sound wave and Smart config simultaneously for higher success rate</string>

<!-- 正在配置页 -->
<string name="configuring_title">Configuring</string>
<string name="configuring_hint">Configuring device network, please wait.</string>
<string name="configuring_step_connect_done">Successfully connected to device hotspot</string>
<string name="configuring_step_register">Device connecting to network and registering</string>
<string name="configuring_step_register_done">Device connected and registered to platform</string>
<string name="configuring_step_bind">Binding account</string>
<string name="configuring_step_bind_done">Successfully bound account</string>
<string name="configuring_step_complete">Configuration completed</string>
<string name="configuring_done">Done</string>
<string name="configuring_config_fail_retry">Configuration failed, please retry</string>
<string name="configuring_bind_fail">Failed to bind device</string>
<string name="retry">Retry</string>
```

### values-zh/strings.xml（中文）

```xml
<!-- 扫描二维码页 -->
<string name="scan_title">扫描二维码</string>
<string name="scan_back">返回</string>
<string name="scan_manual_input">手动输入</string>
<string name="scan_flashlight">闪光灯</string>
<string name="scan_album">相册</string>
<string name="scan_hint_qr">二维码</string>
<string name="scan_hint_text">请扫描设备机身或说明书上的二维码进行设备添加</string>
<string name="scan_camera_permission_denied">需要相机权限才能扫码</string>
<string name="scan_album_decode_fail">未在图片中识别到二维码</string>

<!-- 输入序列号页 -->
<string name="input_serial_title">输入设备信息</string>
<string name="input_serial_subtitle">在设备上查看设备序列号、验证码并在此输入</string>
<string name="input_serial_device_img">设备标签</string>
<string name="input_serial_hint">输入设备的序列号</string>
<string name="input_serial_verify_hint">输入设备的验证码</string>
<string name="input_serial_clear">清空</string>
<string name="input_serial_confirm">确认</string>

<!-- 设备确认页 -->
<string name="device_confirm_title">设备确认</string>
<string name="device_confirm_loading">正在查询设备信息，请稍等…</string>
<string name="device_confirm_query_fail">查询设备信息失败</string>
<string name="device_confirm_error_network">网络不可用，请检查网络连接</string>
<string name="device_confirm_error_added_self">设备已被您添加</string>
<string name="device_confirm_error_added_other">设备已被其他用户添加</string>
<string name="device_confirm_error_offline">设备不在线，可进行Wi-Fi配网</string>
<string name="device_confirm_device_img">设备图片</string>
<string name="device_confirm_check">勾选</string>
<string name="device_confirm_check_owner_prefix">阅读并同意 </string>
<string name="device_confirm_check_owner_link">产品服务使用协议</string>
<string name="device_confirm_check_reset">设备已通电</string>
<string name="device_confirm_next">下一步</string>
<string name="device_confirm_add_device">添加设备</string>
<string name="device_confirm_adding">正在添加设备…</string>
<string name="device_confirm_add_success">设备添加成功</string>
<string name="device_confirm_add_fail">添加设备失败：%s</string>

<!-- 初始化设备页 -->
<string name="init_device_title">初始化设备</string>
<string name="init_device_img">设备图片</string>
<string name="init_device_desc">请将设备插上电源，耐心等待约1分钟，直到指示灯呈蓝色快速闪烁</string>
<string name="init_device_check_led">指示灯已经蓝色快闪</string>
<string name="init_device_next">下一步</string>
<string name="init_device_led_not_flash">指示灯没有蓝色快闪？</string>

<!-- 选择Wi-Fi页 -->
<string name="wifi_select_title">选择路由器Wi-Fi并输入密码</string>
<string name="wifi_select_ssid_hint">请选择Wi-Fi</string>
<string name="wifi_select_choose_network">选择网络</string>
<string name="wifi_select_pwd_hint">请输入您的Wi-Fi密码</string>
<string name="wifi_select_toggle_pwd">切换密码可见性</string>
<string name="wifi_select_no_pwd_hint">若无密码则无需输入</string>
<string name="wifi_select_next">下一步</string>

<!-- Wi-Fi列表页 -->
<string name="wifi_list_title">选择Wi-Fi</string>
<string name="wifi_list_desc">请选择家庭或商铺路由器的Wi-Fi</string>
<string name="wifi_list_sub_desc">如超过一个网络源，请选择离设备安装地点最近的网络</string>
<string name="wifi_list_not_found">找不到想连接的网络</string>
<string name="wifi_list_refresh">刷新</string>
<string name="wifi_list_signal">Wi-Fi信号</string>
<string name="wifi_5g_not_support">此设备不支持5GHz</string>

<!-- 重置Wi-Fi页 -->
<string name="reset_wifi_title">重置Wi-Fi</string>
<string name="reset_wifi_device_img">设备重置图片</string>
<string name="reset_wifi_desc">请根据图示长按reset键，直到设备提示重置成功后松开</string>
<string name="reset_wifi_next">下一步</string>
<string name="reset_wifi_wired">我连接了网线</string>

<!-- 选择配网方式页 -->
<string name="config_method_title">选择配网方式</string>
<string name="config_method_sound">声波配网</string>
<string name="config_method_sound_desc">通过手机扬声器发送声波信号完成配网</string>
<string name="config_method_ap">AP配网</string>
<string name="config_method_ap_desc">连接设备热点进行配网，适用范围更广</string>
<string name="config_method_smart">Smart配网</string>
<string name="config_method_smart_desc">通过Wi-Fi广播方式快速配网</string>
<string name="config_method_combo">声波/Smart组合配网</string>
<string name="config_method_combo_desc">同时使用声波和Smart方式配网，提高成功率</string>

<!-- 正在配置页 -->
<string name="configuring_title">正在配置</string>
<string name="configuring_hint">正在配置设备网络，请耐心等待。</string>
<string name="configuring_step_connect_done">成功连接设备热点</string>
<string name="configuring_step_register">设备正在连接网络并注册到平台</string>
<string name="configuring_step_register_done">设备成功连接网络并注册到平台</string>
<string name="configuring_step_bind">正在绑定账号</string>
<string name="configuring_step_bind_done">成功绑定账号</string>
<string name="configuring_step_complete">配置完成</string>
<string name="configuring_done">完成</string>
<string name="configuring_config_fail_retry">配网失败，请重试</string>
<string name="configuring_bind_fail">绑定设备失败</string>
<string name="retry">重试</string>
```

## 3.18 注意事项

1. **`probeDeviceInfo` 必须在子线程**：同步网络 IO 会阻塞 UI。配合 Lottie 动画作为加载状态提示
2. **设备已添加的错误码要透传**：`120020/120022` 等表示设备已被绑定，应显示 SDK 返回的描述，不要盖上通用提示
3. **AP 配网密码错误也会"成功"**：`onSuccess` 仅代表"WiFi 信息发送给设备"，真正看 `onInfo(CONNECTED_TO_PLATFORM)` 才算完成。超时 90 秒后会走 `OnError`
4. **配网轮询 502 错误要忽略**：`PHONE_NOT_CONNECTED_TO_TARGET_WIFI` 表示 AP 配网过程中手机未回到目标 WiFi，是正常流程不要展示为失败
5. **密码缓存时机**：必须在"成功连接网络"之后再缓存，避免错误密码被保存
6. **addDevice 抛 120023 视为成功**：部分设备可能在注册平台后立即被平台绑定到当前用户，此时再调 addDevice 会抛 120023 异常，应视作成功
7. **5G Wi-Fi 排除**：设备大多不支持 5GHz，`WifiSelectActivity` 初始读到 5G 频段不要自动填入；`WifiListActivity` 中 5G 条目禁用点击
8. **返回主页用 CLEAR_TOP + SINGLE_TOP**：配网完成后要清栈返回 `MainActivity` 并触发 `onNewIntent` 刷新设备列表
9. **`EZProbeDeviceInfo` 用 Parcelable 传递**：可以跨 Activity 传递减少重复查询
10. **SSID 输入框必须只读**：Android 系统限制无法直接获取 Wi-Fi 密码，必须通过 `WifiListActivity` 选择
11. **相册解码处理 EXIF 旋转**：iOS 拍摄的照片有 EXIF 旋转信息，不处理会导致解码失败
12. **相册解码要多重尝试**：先用 `HybridBinarizer`，失败再用 `GlobalHistogramBinarizer`；原图失败再缩放到 800px 重试
13. **扫码页防重复回调**：`decodeContinuous` 会连续回调，必须用 `mScanned` 标志位控制；`onResume` 时重置
14. **配网进度自增上限 99**：剩余 1% 留给"绑定成功"瞬间，避免进度满了但业务未完成的违和感
15. **所有用户可见文本必须国际化**：同时提供英文（`values/strings.xml`）和中文（`values-zh/strings.xml`）
16. **动态权限申请时机**：扫码页申请 `CAMERA`，Wi-Fi 列表页申请 `ACCESS_FINE_LOCATION`，必须在 `onCreate` 中请求；相册图片选取 Android 13+ 需额外申请 `READ_MEDIA_IMAGES`
17. **必须复制 skill 提供的二进制资源**：生成配网工程时，务必把 `skills/ezopensdk-android-skill/assets/wifi/` 下的 3 张 PNG 复制到 `app/src/main/res/drawable/`，`add_radar.json` 复制到 `app/src/main/assets/`。不复制会导致编译失败。详见 3.15 节
