# EZOpenSDK Android 视频通话（ERTC）

## ERTC 模块 import 参考

```java
// ERTC 引擎
import com.ez.ertcengine.ERTCEngine;           // 引擎主类（注意：不是 com.ezviz.sdk.ertc）
import com.ez.basertc.RTCConstant;              // 常量定义（EnterParam、Scene、ERTCVideoEncParam 等）
import com.ez.basertc.RTCListener;              // 事件监听器
import com.ez.basertc.view.VideoCanvasView;     // 视频渲染 View（XML 中使用 com.ez.basertc.view.VideoCanvasView）
import com.ez.baselib.utils.LogUtil;            // 日志工具

// 邀请设备入会（通过 EZOpenSDK 调用）
import com.videogo.openapi.EZOpenSDK;
```

## 一、概述

视频通话功能基于 ERTC SDK 实现，用于萤石屏类设备（如 S10）的远程视频通话。

注意事项：
- EZOpenSDK v5.21 起支持视频通话，已封装 ERTC SDK
- 如需同时实现 IPC 取流和视频会议，使用 v5.21+ 即可，无需单独集成 ERTC SDK
- v5.21 以下版本与 ERTC SDK 一起集成可能有编译/运行问题
- ERTC 能力已包含在 `ezviz-sdk` 库中，无需额外引入依赖：
  ```groovy
  implementation 'io.github.ezviz-open:ezviz-sdk:5.28.3'  // 已包含 ERTC 视频通话能力
  ```
- 视频渲染 View 在 XML 中使用全类名引用即可，无需特殊属性：
  ```xml
  <com.ez.basertc.view.VideoCanvasView
      android:id="@+id/vcs_local_view"
      android:layout_width="match_parent"
      android:layout_height="match_parent" />
  ```

## 二、名词解释

| 名词 | 说明 |
|------|------|
| 项目 ID（AppId） | ERTC 的 AppId（不是 AppKey），在萤石开放平台控制台创建 |
| 客户端 token（ertcClientToken） | 客户端入会权限校验 token，由服务端生成 |
| 设备端 token（ertcDeviceToken） | 邀请设备入会用的 token，由服务端生成 |
| 房间 ID（roomId） | 视频通话房间号，由服务端生成 |
| 用户 ID（userId） | 视频通话用户 ID，由服务端生成 |

## 三、能力集判断

在展示视频通话入口前，必须先判断设备是否支持视频通话：

```java
// 通过 EZDeviceInfo 的 isSupportVideoMeeting() 方法判断
// 同时设备必须在线（status == 1）
if (deviceInfo.isSupportVideoMeeting() && deviceInfo.getStatus() == 1) {
    // 显示视频通话按钮
}
```

**注意**：`isSupportVideoMeeting()` 是判断视频通话能力的唯一依据，不要与对讲能力 `isSupportTalk()` 混淆。

## 四、ERTC 引擎初始化

```java
RTCConstant.RTCEngineConfig config = new RTCConstant.RTCEngineConfig();
config.appId = appId;
config.context = context.getApplicationContext();
config.audioCodeType = RTCConstant.ErtcAudioCodeType.AAC; // 必须，S10 使用 AAC
config.logLevel = LogUtil.ERTC_LOG_LEVEL_DEBUG; // 可选
config.logPath = context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).getPath(); // 可选

ERTCEngine.init(config, new ERTCEngine.OnInitListener() {
    @Override
    public void onInitialization(ERTCEngine engine) {
        // 初始化成功，保存 engine 引用
        ertcEngine = engine;
    }
    @Override
    public void onError(int code) {
        // 初始化失败
    }
});
```

## 五、客户端进入房间

```java
// 设置视频参数
RTCConstant.ERTCVideoEncParam videoParam = new RTCConstant.ERTCVideoEncParam();
videoParam.videoResolution = RTCConstant.ERTCVideoResolution.ERTCVideoResolution_1280_720;
videoParam.videoFps = 10;
videoParam.videoBitrate = 500 * 1024;
ertcEngine.setVideoEncoderParam(videoParam, true);

// 开启本地视频
ertcEngine.enableLocalVideo(true);

// 使用硬件回音消除（必须）
ertcEngine.enableHardAec(true);

// 【重要】显式开启本地音频，否则对端听不到声音
ertcEngine.enableLocalAudio(true);

// 设置本地预览
textureView.setRenderType(RTCConstant.ERTC_VIDEO_RENDER_MODE_FILL);
ertcEngine.setLocalView(textureView);

// 设置事件监听
ertcEngine.setRTCListener(rtcListener);

// 进入房间
RTCConstant.EnterParam param = new RTCConstant.EnterParam();
param.roomId = roomId;
param.userId = userId;
param.token = ertcClientToken;
param.appId = appId;
ertcEngine.enterRoom(param, RTCConstant.Scene.VideoCall);
```

**⚠️ 关键注意事项**：
- `enableLocalAudio(true)` 必须在进入房间前/后显式调用，SDK 默认不会自动开启本地音频
- 如果不调用，对端将听不到手机端声音，表现为需要手动点击两次麦克风开关才生效

## 六、邀请设备入会

客户端发起视频通话时，进入房间成功后邀请设备：

```java
// 在 onEnterRoomSuccess 回调中调用
new Thread(() -> {
    try {
        EZOpenSDK.getInstance().inviteDeviceEnterMeeting(
            appId, ertcDeviceToken, roomId, deviceSerial, cameraNo, userId);
    } catch (BaseException e) {
        // 邀请失败
    }
}).start();
```

### 取消邀请
设备未接听时，超时后取消邀请：
```java
new Thread(() -> {
    try {
        EZOpenSDK.getInstance().cancelInviteDeviceEnterMeeting(
            appId, ertcDeviceToken, roomId, deviceSerial, cameraNo, userId);
    } catch (BaseException e) {
        // 取消失败
    }
}).start();
```

### 拒绝设备呼叫
```java
// 返回 boolean 表示拒接是否成功
boolean ret = EZOpenSDK.getInstance().rejectVideoCallReqFromDevice(
    appId, ertcToken, roomId, deviceSerial, cameraNo, userId);
```

## 七、订阅设备端码流

```java
ertcEngine.setRTCListener(new RTCListener() {

    @Override
    public void onEnterRoomSuccess() {
        // 进入房间成功
        if (isClientCallDevice) {
            inviteDeviceEnterMeeting();
        }
    }

    @Override
    public void onRemoteUserEnterRoom(String userId) {
        // 远端用户（设备）进入房间
        isRemoteUserEnterRoom = true;
    }

    @Override
    public void onRemoteUserLeaveRoom(String userId, int reason) {
        // 远端用户离开房间，0-主动退出，1-超时退出
        toggleHangUp();
    }

    @Override
    public void onUserVideoAvailable(String userId, boolean available, int streamType) {
        // 远端视频流可用/不可用
        // 注意：相同 userId + available 的重复回调需要去重过滤
        if (available) {
            remoteTextureView.setRenderType(RTCConstant.ERTC_VIDEO_RENDER_MODE_FIT);
            ertcEngine.setRemoteView(userId, RTCConstant.ERTC_VIDEO_STREAM_TYPE_BIG, remoteTextureView);
        } else {
            ertcEngine.setRemoteView(userId, RTCConstant.ERTC_VIDEO_STREAM_TYPE_BIG, null);
        }
    }

    @Override
    public void onExitRoom(int reason) {
        // 非主动退出房间
    }

    @Override
    public void onError(int errorCode) {
        // ERTC 错误
    }
});
```

**注意**：`onUserVideoAvailable` 可能对同一个 userId 重复回调相同状态，建议用 Map 记录上次状态做去重过滤。

## 八、工具栏功能

### 麦克风开关
```java
ertcEngine.enableLocalAudio(boolean enable);
```

### 扬声器/耳机切换
```java
ertcEngine.setSpeakerPhoneOn(boolean enable); // true-扬声器，false-耳机
```

### 前后摄像头切换
```java
ertcEngine.switchCamera();
```

### 摄像头禁用/启用
```java
ertcEngine.enableLocalVideo(boolean enable);
```

### 挂断（资源释放）
```java
public void releaseERTC() {
    // 【关键】先将引擎引用保存到局部 final 变量，再置空字段
    // 避免新线程中访问已被主线程置空的字段导致 NPE
    final ERTCEngine engine = ertcEngine;
    ertcEngine = null;

    if (engine != null) {
        engine.setLocalView(null);
        engine.enableLocalVideo(false);
        engine.setRTCListener(null);
        new Thread(() -> {
            engine.exitRoom();
            ERTCEngine.destroyEngine();
        }).start();
    }
}
```

**⚠️ 线程安全注意事项**：
- `exitRoom()` 和 `destroyEngine()` 必须在子线程中调用
- 释放方法中所有通过新线程访问的变量，必须先保存为局部 final 变量
- 主线程置空字段和子线程访问之间存在竞争，直接使用成员变量会导致 NPE 崩溃
- `cancelInviteDeviceEnterMeeting()` 等异步方法中也需要同样处理：先保存 appId/roomId/userId 等参数为局部变量

## 九、超时处理

客户端呼叫设备时，建议设置 15 秒超时：
```java
// 启动计时器
mUpdateTimer = new Timer();
mUpdateTimerTask = new TimerTask() {
    @Override
    public void run() {
        mVideoTalkSecond++;
        if (mVideoTalkSecond > 15 && !isRemoteUserEnterRoom) {
            // 设备未接听，取消邀请并退出
            cancelInviteDeviceEnterMeeting();
            releaseERTC();
        }
    }
};
mUpdateTimer.schedule(mUpdateTimerTask, 0, 1000);
```

## 十、设备端呼叫客户端

设备端发起呼叫时，开发者的服务端会接收到呼叫消息，通过长链接通知客户端。客户端收到通知后使用上述方法进入对应房间即可。

注意：应用与服务端的长链接需要开发者自行实现。

## 十一、常见问题

### 1. 对端听不到手机端声音
**原因**：进入房间后未显式调用 `enableLocalAudio(true)`，SDK 默认本地音频未开启。
**解决**：在 `enterRoom` 前后调用 `ertcEngine.enableLocalAudio(true)`。

### 2. 挂断时应用崩溃（NPE）
**原因**：`release()` 方法中将 `ertcEngine` 字段置空后，子线程的 lambda 中仍引用该字段。
**解决**：释放前将引擎引用保存为局部 `final` 变量，子线程中使用局部变量而非字段。

### 3. onUserVideoAvailable 重复回调
**原因**：SDK 可能对同一 userId 重复发送相同状态的回调。
**解决**：使用 `Map<String, Boolean>` 记录上次状态，相同则过滤。

### 4. Activity 多次调用 release 导致崩溃
**原因**：`hangUp()` 中调用 `release()` 后 `finish()`，`onDestroy()` 又调用一次。
**解决**：释放后将 manager 引用置空，或在 `release()` 中加入重复调用防护。
