diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..af39f90f7
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,56 @@
+# AGENTS.md
+
+Entry point for AI agents working on the Agora RTC Native SDK API-Examples repository.
+Read this file first, then navigate to the relevant platform directory.
+
+## Repository Overview
+
+This repository contains sample projects demonstrating Agora RTC Native SDK APIs across four independent platforms. Each platform is self-contained — do not share source files, build scripts, or dependencies across platforms.
+
+| Platform | Language(s) | Directory | SDK |
+|----------|-------------|-----------|-----|
+| Android | Java / Kotlin | `Android/` | RTC Java SDK (full / voice) |
+| iOS | Swift / Objective-C | `iOS/` | RTC Objective-C SDK (full / audio) |
+| macOS | Swift | `macOS/` | RTC Objective-C SDK (full) |
+| Windows | C++ | `windows/` | RTC C++ SDK (full) |
+
+## Navigation
+
+Each platform directory contains its own `AGENTS.md` with platform-specific rules, project selection guidance, and architecture constraints. Always read the platform-level `AGENTS.md` before making any changes.
+
+| Platform | Entry Point |
+|----------|-------------|
+| Android | `Android/AGENTS.md` |
+| iOS | `iOS/AGENTS.md` |
+| macOS | `macOS/AGENTS.md` |
+| Windows | `windows/AGENTS.md` |
+
+## Cross-Platform Rules
+
+1. Never share source files, build scripts, or SDK dependencies between platforms.
+2. Each platform manages its own SDK version — check the platform-level config file before assuming a version.
+3. All examples follow the same structural pattern within their platform: one self-contained class per API feature, managing its own engine lifecycle.
+4. Always call the SDK's leave-channel and destroy APIs when an example screen is closed.
+5. SDK event/delegate callbacks may arrive on a background thread — always dispatch UI updates to the main thread.
+
+## Repository-Level Files
+
+| File | Purpose |
+|------|---------|
+| `HOOKS-GUIDE.md` | Git hook installation (sensitive-info detection, commit-message rules) |
+| `.pre-commit-config.yaml` | Pre-commit hook configuration |
+| `.gitleaks.toml` | Gitleaks allowlist configuration |
+| `azure-pipelines.yml` | CI/CD pipeline definition |
+
+## Git Hooks
+
+This repository enforces two rules via Git hooks:
+- No sensitive information (API keys, tokens) in committed code.
+- Commit messages must be in English only (no Chinese characters).
+
+Run `.git-hooks/install-hooks.sh` once after cloning to activate the hooks.
+See `HOOKS-GUIDE.md` for details and troubleshooting.
+
+## Sensitive Configuration
+
+API keys and App IDs are never committed. Each platform stores them in a `KeyCenter` file (Swift/OC) or `KeyCenter.java` / `KeyCenter.kt` (Android) or `CConfig` (Windows). These files are git-ignored and must be populated locally before building.
diff --git a/Android/AGENTS.md b/Android/AGENTS.md
new file mode 100644
index 000000000..77d601ec8
--- /dev/null
+++ b/Android/AGENTS.md
@@ -0,0 +1,30 @@
+# AGENTS.md — Android
+
+Android platform entry point. Read this first, then go to the relevant project's `AGENTS.md`.
+
+## Projects
+
+| Project | SDK | Language | Purpose |
+|---------|-----|----------|---------|
+| `APIExample/` | full-sdk | Java / Kotlin + XML | All APIs — default choice |
+| `APIExample-Audio/` | voice-sdk | Java + XML | Audio-only — no video APIs |
+| `APIExample-Compose/` | full-sdk | Kotlin + Compose | Compose UI, mirrors APIExample cases |
+
+SDK version: each project's `gradle.properties` → `rtc_sdk_version` (currently `4.6.3`)
+
+## Project Selection
+
+- Video / screen sharing / beauty / extensions → `APIExample/`
+- Audio-only → `APIExample-Audio/`
+- Compose UI or porting an existing case → `APIExample-Compose/`
+- No project specified → default to `APIExample/`
+
+Never share source files between projects.
+
+## Navigation
+
+| Project | Entry Point |
+|---------|-------------|
+| `APIExample/` | `APIExample/AGENTS.md` |
+| `APIExample-Audio/` | `APIExample-Audio/AGENTS.md` |
+| `APIExample-Compose/` | `APIExample-Compose/AGENTS.md` |
diff --git a/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md b/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..4636e2b71
--- /dev/null
+++ b/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,114 @@
+---
+name: query-cases
+description: >
+ Query and browse existing API example cases in the APIExample-Audio Android demo —
+ lists cases by group, finds which case demonstrates a specific Agora audio API,
+ checks sort index availability, and resolves display names from string resources.
+ Use when: someone asks what cases exist, which audio APIs are demonstrated, wants
+ to find a case by name or API (e.g. setVoiceBeautifierPreset, enableSpatialAudio),
+ needs a free sort index before adding a new case, or wants to know if an audio
+ feature is already implemented. This project uses voice-sdk — no video APIs.
+ Keywords: list cases, find case, query cases, @Example, sort index, BASIC, ADVANCED,
+ available cases, existing cases, which case, is there a case, audio case.
+---
+
+# Query Cases — APIExample-Audio
+
+## How cases are registered
+
+Every case is a Fragment under `app/src/main/java/io/agora/api/example/examples/{basic|advanced|audio}/` with an `@Example` annotation:
+
+```java
+@Example(
+ index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+
+ group = ADVANCED,
+ name = R.string.item_xxx,
+ actionId = R.id.action_mainFragment_to_xxx,
+ tipsId = R.string.xxx_tips
+)
+```
+
+A commented-out `@Example` (`//@Example`) means the case is disabled and won't appear in the app.
+
+This project uses `voice-sdk` — all cases are audio-only, no video APIs exist.
+
+---
+
+## Query procedure
+
+### Step 1: Decide scope before scanning
+
+Before listing files, ask:
+- **Looking for a specific API?** — scan Javadoc comments for the API name; no need to read all files
+- **Need a free sort index?** — collect all `index` values for the target group, then find the gap
+- **Listing all cases?** — scan all three directories and collect annotations
+
+### Step 2: Read ARCHITECTURE.md first
+
+Read `ARCHITECTURE.md` (the `examples/` section of the Directory Layout). It contains a pre-built index of all cases with group, index, display name, and key API — no file scanning needed for most queries.
+
+Use ARCHITECTURE.md as the primary source. Fall back to scanning the source directories only when:
+- The query requires data not in ARCHITECTURE.md (e.g. full `@Example` field values, `tipsId`)
+- ARCHITECTURE.md appears stale (a case exists in source but not in the doc)
+- The output involves free-index claims, index collisions, or "is index X available?" decisions — these must be validated from source immediately before final output
+
+### Step 3: Scan case directories (fallback only)
+
+| Directory | Group | Contents |
+|-----------|-------|----------|
+| `examples/basic/` | BASIC | Core audio join/leave patterns |
+| `examples/advanced/` | ADVANCED | Feature-specific audio APIs |
+| `examples/audio/` | ADVANCED | Audio visualization (still grouped ADVANCED) |
+
+Each `.java` file is a case. Subdirectories (e.g. `customaudio/`) contain multi-file cases — the main class is the file whose name matches the directory name; if no name match, look for the file containing `@Example`.
+
+### Step 4: Extract `@Example` fields
+
+For each file, read the annotation for `group`, `index`, `name` (string resource ID), and `tipsId`. If the annotation is commented out, the case is disabled.
+
+Resolve display names from `app/src/main/res/values/strings.xml`:
+`R.string.item_voice_effects` → `Voice Effects`
+
+### Step 5: Read class Javadoc for API mapping
+
+The Javadoc above each class lists the key APIs demonstrated:
+
+```java
+/**
+ * This demo demonstrates how to apply voice beautifier effects.
+ *
+ * Key APIs used:
+ * - RtcEngine.setVoiceBeautifierPreset()
+ */
+```
+
+Use this to answer "which case uses X?" queries without reading the full implementation.
+
+If no Javadoc is present, scan the method body for the API name as a method call. If still not found, note "API mapping unavailable" in the results table.
+
+### Step 6: Present results
+
+Full listing — table format:
+
+| Group | Index | Case Name | File | Key APIs |
+|-------|-------|-----------|------|----------|
+| BASIC | 0 | Join Channel Audio | JoinChannelAudio.java | joinChannel() |
+| ADVANCED | 4 | Voice Effects | VoiceEffects.java | setVoiceBeautifierPreset() |
+
+For a specific query (e.g. "which case uses enableSpatialAudio?"), return only matching rows.
+
+For a free-index query, list all used indices in the target group and identify the next available slot:
+> BASIC range: 0–9. ADVANCED range: 10+.
+> ADVANCED indices in use: 10, 11, 12, 15, 20 → next free: 13
+
+Before returning any free-index/collision result, re-scan source registration points (`@Example` across `basic/`, `advanced/`, `audio/`) and recompute once from source-of-truth data.
+
+---
+
+## NEVER
+
+- **NEVER** count a commented-out `@Example` (`//@Example`) as an active case — it is disabled and won't appear in the app.
+- **NEVER** mix index spaces across groups — `audio/` cases use `group=ADVANCED` but share the same index namespace as `advanced/`; always scan both directories together when finding a free index.
+- **NEVER** use filename alone to identify a subdirectory case — the main class is the file whose name matches the directory name; if no match, look for the file with `@Example`.
+- **NEVER** report a free index without scanning all three directories (`basic/`, `advanced/`, `audio/`) for the target group — missing one causes index collisions.
+- **NEVER** suggest video APIs — this project uses voice-sdk only; video APIs do not exist.
diff --git a/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md b/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..ba517c3ea
--- /dev/null
+++ b/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,57 @@
+---
+name: review-case
+description: >
+ Review an existing case implementation against project-specific red lines
+ and coding standards. Use after implementing or modifying a case.
+ Use when: reviewing a case for correctness, checking red-line compliance,
+ verifying lifecycle and threading patterns, auditing an existing Fragment.
+ Keywords: review, audit, check, red lines, lifecycle, threading, compliance.
+---
+
+# Review Case — APIExample-Audio
+
+Run through every item below before considering a case implementation complete.
+Open the case's Fragment source file and verify each point against the actual code.
+
+## Checklist
+
+### Teardown & Lifecycle
+
+- [ ] **leaveChannel before destroy** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the teardown path (typically `onDestroy()`). Destroying without leaving first leaks the channel session on the server side.
+
+- [ ] **handler.post for destroy** — `RtcEngine.destroy()` is invoked via `handler.post(RtcEngine::destroy)` and **not** called directly on the main thread. A direct call blocks the UI thread and causes ANR.
+
+### Threading
+
+- [ ] **runOnUIThread for callbacks** — All `IRtcEngineEventHandler` callbacks that update UI are wrapped with `runOnUIThread()`. SDK callbacks arrive on a background thread; touching Views without dispatching to the main thread causes crashes or silent rendering corruption.
+
+### Permissions
+
+- [ ] **Permission check before join** — `checkOrRequestPermission()` is called before `joinChannel()`. Joining without the required permissions (RECORD_AUDIO) causes a silent failure — no error callback, just no audio.
+
+### Backend Reporting
+
+- [ ] **setParameters present** — `setParameters(...)` is called during engine initialisation. This is required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally.
+
+### Private Cloud
+
+- [ ] **getPrivateCloudConfig null-check** — `getPrivateCloudConfig()` is null-checked before `setLocalAccessPoint()` is called. The method returns `null` on standard (non-private-cloud) builds, so calling `setLocalAccessPoint()` without the guard causes a NullPointerException.
+
+### Audio-Only Constraint
+
+- [ ] **No video APIs** — The case must not call `enableVideo()`, `setupLocalVideo()`, or reference `VideoCanvas`. APIExample-Audio uses the voice-SDK which has no video module; calling video APIs causes a compile error or runtime crash.
+
+## If a Check Fails
+
+- Teardown order wrong (`destroy` before `leaveChannel`) — fix teardown to `leaveChannel()` first, then `handler.post(RtcEngine::destroy)`, and re-test back navigation.
+- UI touched in SDK callback without main-thread dispatch — wrap UI updates in `runOnUIThread()` and re-run to verify no thread exceptions.
+- Permission flow missing before `joinChannel()` — add `checkOrRequestPermission()` gate and verify join only after `RECORD_AUDIO` is granted.
+- Any video API appears in code — remove all video API calls/usages immediately and replace with audio-only equivalents.
+- Missing `setParameters(...)` or private-cloud null-check — add both safeguards in engine init and re-run initialization.
+
+## NEVER
+
+- **NEVER** approve a case review if any video API (`enableVideo`, `setupLocalVideo`, `VideoCanvas`) exists in APIExample-Audio.
+- **NEVER** approve a case review with direct `RtcEngine.destroy()` on main thread.
+- **NEVER** approve a case review when `leaveChannel()` is missing before destroy.
+- **NEVER** ignore background-thread UI updates inside `IRtcEngineEventHandler` callbacks.
diff --git a/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md b/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..9cb3d65e2
--- /dev/null
+++ b/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,231 @@
+---
+name: upsert-case
+description: >
+ Add a new audio API example case or modify an existing one in the APIExample-Audio Android demo —
+ creates or updates Fragment class, XML layout, string resources, and nav_graph registration.
+ Use when: adding a new Agora audio API demo screen, modifying an existing case's implementation
+ or registration, implementing a new audio feature example in Java + XML layouts, registering a new
+ case via @Example annotation, subclassing BaseFragment for a new audio demo screen, or updating
+ an existing case's strings, layout, or nav entry.
+ This project uses voice-sdk — no video APIs available.
+ Keywords: add case, modify case, update case, new fragment, nav_graph, @Example, BaseFragment,
+ APIExample-Audio, audio case, voice-sdk, new screen, audio demo, upsert case.
+---
+
+# Upsert Case — APIExample-Audio
+
+## Adding a New Case
+
+Touch exactly 4 files (all paths relative to `app/src/main/`):
+
+| File | What to add |
+|---|---|
+| `java/.../examples/{basic\|advanced\|audio}/YourCaseName.java` | Fragment class |
+| `res/layout/fragment_your_case_name.xml` | XML layout |
+| `res/values/strings.xml` | 2 strings |
+| `res/navigation/nav_graph.xml` | 1 action + 1 destination |
+
+Registration is automatic via reflection — no other files needed.
+
+**voice-sdk constraint**: Do NOT call `enableVideo()`, `setupLocalVideo()`, `VideoCanvas`, or any video API — the module does not exist and will crash at runtime.
+
+---
+
+### Step 1: Clarify before coding
+
+Before writing a single line, ask:
+- **What audio API am I demonstrating?** — determines which existing case is the closest reference to copy patterns from
+- **BASIC or ADVANCED group?** — BASIC for fundamental join/leave audio patterns; ADVANCED for feature-specific audio APIs
+- **What's the sort index?** — index must be unique within the group. BASIC uses 0–9, ADVANCED starts from 10. Run `query-cases` skill first; a collision causes silent ordering bugs at runtime
+- **Any special permissions beyond `RECORD_AUDIO`?** — most audio cases only need `RECORD_AUDIO`; check if the API requires anything else
+
+---
+
+### Step 2: Create the Fragment
+
+**MANDATORY — READ ENTIRE FILE before writing any code**:
+[`references/fragment-template.java`](references/fragment-template.java)
+
+Do NOT skip — the `setParameters`, `handler.post`, `getPrivateCloudConfig()` null-check, `AudioSeatManager` wiring, and voice-sdk constraints are only fully shown there and are required in every case.
+
+**Do NOT load** any other reference files for this task.
+
+Non-obvious points the template highlights:
+
+- `setParameters(...)` for app scenario reporting — **required in every case**, do not remove
+- `handler.post(RtcEngine::destroy)` — NOT `RtcEngine.destroy()` directly; direct call blocks UI thread (ANR)
+- `getPrivateCloudConfig()` null-check before `setLocalAccessPoint()` — returns null on non-private-cloud builds (NPE)
+- All `IRtcEngineEventHandler` callbacks run on a **background thread** — always `runOnUIThread()` for UI
+- `onActivityCreated` → create engine; `onDestroy` → `leaveChannel()` then `handler.post(RtcEngine::destroy)`
+- `ChannelMediaOptions` must NOT set `publishCameraTrack` or `autoSubscribeVideo` — voice-sdk has no video module
+- Use `AudioSeatManager` (not `VideoReportLayout`) to visualize remote participants
+
+---
+
+### Step 3: Create the XML layout
+
+Typical audio layout — channel input + join button + audio controls:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+For waveform visualization, copy the `WaveformView` pattern from `fragment_join_channel_audio.xml`.
+
+---
+
+### Step 4: Add nav entries
+
+File: `res/navigation/nav_graph.xml`
+
+**Action** — inside `` (NOT mainFragment — mainFragment only has one action, to Ready):
+
+```xml
+
+```
+
+**Destination** — at root `` level:
+
+```xml
+
+```
+
+`action android:id` must exactly match `actionId` in `@Example`.
+
+---
+
+### Step 5: Update ARCHITECTURE.md
+
+Add one line to the case list in `ARCHITECTURE.md` under the correct directory section (`basic/`, `advanced/`, or `audio/`):
+
+```
+├── YourCaseName.java # [index] "Display Name" — key API description
+```
+
+Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans.
+
+---
+
+## Modifying an Existing Case
+
+When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating:
+
+| What changed | Files to touch |
+|---|---|
+| Implementation logic (API calls, event handling) | `java/.../examples/{basic\|advanced\|audio}/CaseName.java` |
+| UI layout (views, controls) | `res/layout/fragment_case_name.xml` |
+| Display name or tips text | `res/values/strings.xml` |
+| Sort index or group (BASIC ↔ ADVANCED) | `@Example` annotation in the Fragment class |
+| Navigation label | `res/navigation/nav_graph.xml` (fragment label attribute) |
+| Class rename or package move | Fragment class, `nav_graph.xml` (android:name + destination id), `@Example` annotation (actionId), layout file name, `ARCHITECTURE.md` |
+
+After making changes:
+
+1. **Verify `@Example` annotation consistency** — ensure `index`, `group`, `name`, `actionId`, and `tipsId` still match the actual string resources, nav action ID, and intended group/position. A mismatch causes the case to silently disappear from the list or navigate to the wrong screen.
+2. **Update `res/values/strings.xml`** if the display name or tips text changed.
+3. **Update `res/navigation/nav_graph.xml`** if the class name, package, or label changed.
+4. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description.
+
+---
+
+## Verify
+
+```bash
+./gradlew assembleDebug
+```
+
+- [ ] Case appears in correct group at expected sort position
+- [ ] Tap navigates to the case screen (silent failure = nav action in wrong fragment)
+- [ ] `onJoinChannelSuccess` fires in Logcat
+- [ ] After pressing back, check Logcat for `RtcEngine.destroy` within ~2 seconds — if missing, there is a lifecycle bug in `onDestroy`
+- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description
+- [ ] `@Example` annotation fields (`index`, `group`, `name`, `actionId`, `tipsId`) are consistent with string resources and nav_graph entries
+
+---
+
+## When to Use a Spec Instead
+
+If the case meets any of the following criteria, create a Spec rather than using this skill directly:
+
+1. Involves coordinated calls across two or more Agora API modules
+2. Requires a custom UI layout (not one of the standard templates above)
+3. Manages multiple channels or multiple engine instances
+4. Requires a foreground Service or background thread coordination
+5. Involves developing new shared components (widget/utils, etc.)
+6. Requires optional module integration (e.g. streamEncrypt)
+
+If none apply → use this skill directly; no Spec needed.
+
+### Spec Requirements Document Must Include
+
+- List of APIs the case demonstrates (audio APIs only)
+- User interaction flow description
+- Expected RtcEngine lifecycle behavior
+- Required permissions (typically only `RECORD_AUDIO`)
+
+### Spec Design Document Must Include
+
+- Target project identifier: `APIExample-Audio`
+- Class/file structure design
+- API call sequence (Mermaid sequence diagram recommended)
+- State management approach
+- UI layout plan
+- Integration points with existing shared components
+- Case registration info: class name, display name, group (BASIC/ADVANCED), sort index — finalize during design to avoid conflicts
+- Generate `@Example` annotation parameters, `nav_graph.xml` action + destination, `strings.xml` key names (`item_` prefix)
+- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing indices
+- voice-sdk checks: no video APIs (`enableVideo`, `setupLocalVideo`, `setupRemoteVideo`, `VideoCanvas`, `startScreenCapture`) — violations must be eliminated at design time
+- Risk identification and mitigation (API availability, permissions, thread safety, performance)
+
+### Spec Task List Integration
+
+- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters
+- Mark which sub-tasks require manual coding, and provide target file paths and change summaries
+- New shared component creation tasks must come before case implementation tasks
+
+---
+
+## NEVER
+
+- **NEVER** call any video API (`enableVideo`, `setupLocalVideo`, `VideoCanvas`) — voice-sdk has no video module; crash is immediate.
+- **NEVER** put the nav action inside `` — it belongs in ``. mainFragment only routes to Ready; all case actions live in Ready. Wrong placement causes silent navigation failure at runtime.
+- **NEVER** call `RtcEngine.destroy()` directly on the main thread — always `handler.post(RtcEngine::destroy)`. Direct call blocks the UI thread and causes ANR.
+- **NEVER** call `setLocalAccessPoint()` without null-checking `getPrivateCloudConfig()` first — it returns null on standard builds, causing NPE.
+- **NEVER** update UI directly inside `IRtcEngineEventHandler` callbacks — they run on a background thread. Always wrap with `runOnUIThread()`.
+- **NEVER** omit `setParameters(...)` — it's required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally.
diff --git a/Android/APIExample-Audio/AGENTS.md b/Android/APIExample-Audio/AGENTS.md
new file mode 100644
index 000000000..092f45f59
--- /dev/null
+++ b/Android/APIExample-Audio/AGENTS.md
@@ -0,0 +1,38 @@
+# AGENTS.md — APIExample-Audio
+
+Audio-only demo project. Uses `voice-sdk` — the video module is not available.
+Use this project for audio-only features.
+
+## Build Commands
+
+```bash
+./gradlew assembleDebug # build debug APK
+./gradlew installDebug # build + install to connected device
+./gradlew test # unit tests
+./gradlew connectedAndroidTest # instrumented tests (device required)
+```
+
+## App ID Configuration
+
+See [README.md — Obtain an App Id](README.md#obtain-an-app-id).
+
+## Architecture Red Lines
+
+- Do NOT call `enableVideo()`, `setupLocalVideo()`, or `VideoCanvas` — `voice-sdk` has no video module and will crash at runtime.
+- Do NOT add video, screen sharing, or beauty cases — use `APIExample/` instead.
+- Each case Fragment must create and destroy its own `RtcEngine` instance.
+- Always call `engine.leaveChannel()` before `RtcEngine.destroy()` in `onDestroy()`.
+- All `IRtcEngineEventHandler` callbacks run on a background thread — use `handler.post {}` for UI updates.
+- Always call `checkOrRequestPermission()` before `joinChannel()`. Audio cases only need `RECORD_AUDIO`.
+
+## Skills
+
+| Skill | Path | Description |
+|-------|------|-------------|
+| upsert-case | `.agent/skills/upsert-case/` | Add a new audio case or modify an existing one |
+| query-cases | `.agent/skills/query-cases/` | Query and browse existing audio cases |
+| review-case | `.agent/skills/review-case/` | Review a case against project red lines |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout and case registration details
diff --git a/Android/APIExample-Audio/ARCHITECTURE.md b/Android/APIExample-Audio/ARCHITECTURE.md
new file mode 100644
index 000000000..1aab8f934
--- /dev/null
+++ b/Android/APIExample-Audio/ARCHITECTURE.md
@@ -0,0 +1,140 @@
+# ARCHITECTURE.md — APIExample-Audio
+
+## Directory Layout
+
+```
+APIExample-Audio/
+├── gradle.properties # rtc_sdk_version
+└── app/src/main/
+ ├── AndroidManifest.xml
+ ├── assets/ # Audio sample files
+ ├── res/
+ │ ├── navigation/nav_graph.xml # Single nav graph — all case destinations live here
+ │ ├── values/strings.xml # All display names and tips strings
+ │ └── layout/ # XML layouts for each case Fragment
+ └── java/io/agora/api/example/
+ ├── MainApplication.java # Scans DEX and registers all @Example cases at startup
+ ├── MainActivity.java # Single-Activity host, owns NavController
+ ├── MainFragment.java # Home screen — renders BASIC / ADVANCED section list
+ ├── ReadyFragment.java # Splash / config check screen
+ ├── SettingActivity.java # Global settings (area code, audio profile)
+ │
+ ├── annotation/
+ │ └── Example.java # @Example annotation — identical to APIExample
+ │
+ ├── common/
+ │ ├── BaseFragment.java # Base class ALL case Fragments must extend
+ │ ├── Constant.java # App-wide constants
+ │ ├── adapter/
+ │ │ └── SectionAdapter.java # RecyclerView adapter for the grouped case list
+ │ ├── model/
+ │ │ ├── Examples.java # Static registry: ITEM_MAP keyed by group name
+ │ │ ├── GlobalSettings.java # Audio config shared across cases
+ │ │ ├── ExampleBean.java
+ │ │ ├── Peer.java
+ │ │ └── StatisticsInfo.java
+ │ ├── widget/
+ │ │ ├── AudioOnlyLayout.java # Audio seat layout (no video surface)
+ │ │ ├── AudioSeatManager.java
+ │ │ └── WaveformView.java
+ │ └── gles/ # OpenGL ES helpers (for waveform visualization)
+ │
+ ├── examples/ # All cases live here — ClassUtils scans this package
+ │ ├── basic/ # group = "BASIC" (index 0–9)
+ │ │ ├── JoinChannelAudioByToken.java # [0] "Live Interactive Audio Streaming(Token Verify)"
+ │ │ └── JoinChannelAudio.java # [1] "Live Interactive Audio Streaming"
+ │ ├── advanced/ # group = "ADVANCED" (index 10+)
+ │ │ ├── VoiceEffects.java # [10] "Set the Voice Beautifier and Effects" — setVoiceBeautifierPreset
+ │ │ ├── customaudio/CustomAudioSource.java # [11] "Custom Audio Sources" — push external audio
+ │ │ ├── customaudio/CustomAudioRender.java # [12] "Custom Audio Render" — pull audio for custom rendering
+ │ │ ├── customaudio/AudioPlayer.java # helper for CustomAudioRender
+ │ │ ├── ProcessAudioRawData.java # [13] "Raw Audio Data" — audio raw data processing
+ │ │ ├── PlayAudioFiles.java # [14] "Play Audio Files" — audio mixing
+ │ │ ├── PreCallTest.java # [15] "Pre-call Tests" — network/device test before joining
+ │ │ ├── RhythmPlayer.java # [16] "Rhythm Player" — metronome/rhythm playback
+ │ │ └── SpatialSound.java # [17] "Spatial Audio" — 3D spatial audio
+ │ └── audio/ # Audio-specific cases (grouped as ADVANCED)
+ │ └── AudioWaveform.java # [18] "Audio Waveform" — audio visualization
+ │
+ └── utils/
+ ├── ClassUtils.java # DEX scanner — auto-discovers @Example classes
+ ├── TokenUtils.java # Fetches RTC tokens from Agora token server
+ ├── PermissonUtils.java # Permission check/request helpers
+ ├── CommonUtil.java
+ ├── ErrorUtil.java
+ ├── FileUtils.java
+ ├── AudioFileReader.java
+ └── YUVUtils.java
+```
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| Live Interactive Audio Streaming(Token Verify) | `basic/JoinChannelAudioByToken.java` | `RtcEngine.create()`, `joinChannel()`, `setClientRole()` | Demonstrates audio-only calling with manual App ID and token input |
+| Live Interactive Audio Streaming | `basic/JoinChannelAudio.java` | `RtcEngine.create()`, `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `muteLocalAudioStream()`, `enableInEarMonitoring()`, `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()` | Demonstrates audio-only calling with volume controls, in-ear monitoring, and audio routing |
+| Set the Voice Beautifier and Effects | `advanced/VoiceEffects.java` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAudioEffectParameters()`, `setLocalVoicePitch()`, `setLocalVoiceEqualization()`, `setLocalVoiceReverb()`, `setLocalVoiceFormant()`, `setAINSMode()`, `enableVoiceAITuner()` | Demonstrates voice beautifier presets, audio effects, voice conversion, and AI noise suppression |
+| Custom Audio Sources | `advanced/customaudio/CustomAudioSource.java` | `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `enableCustomAudioLocalPlayback()`, `destroyCustomAudioTrack()` | Demonstrates pushing external audio frames via a custom audio track |
+| Custom Audio Render | `advanced/customaudio/CustomAudioRender.java` | `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Demonstrates pulling audio frames for custom audio rendering |
+| Raw Audio Data | `advanced/ProcessAudioRawData.java` | `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Demonstrates processing raw audio data through the audio frame observer |
+| Play Audio Files | `advanced/PlayAudioFiles.java` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()`, `getAudioEffectManager()`, `adjustAudioMixingVolume()` | Demonstrates audio mixing and sound effect playback |
+| Pre-call Tests | `advanced/PreCallTest.java` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()` | Demonstrates network quality probing and echo testing before joining a channel |
+| Rhythm Player | `advanced/RhythmPlayer.java` | `startRhythmPlayer()`, `stopRhythmPlayer()`, `enableAudioVolumeIndication()` | Demonstrates metronome/rhythm playback synchronized with audio streaming |
+| Spatial Audio | `advanced/SpatialSound.java` | `ILocalSpatialAudioEngine.create()`, `updateSelfPosition()`, `updateRemotePosition()`, `updatePlayerPositionInfo()`, `setZones()`, `createMediaPlayer()` | Demonstrates 3D spatial audio positioning for remote users and media players |
+| Audio Waveform | `audio/AudioWaveform.java` | `enableAudio()`, `enableAudioVolumeIndication()` | Demonstrates real-time audio waveform visualization |
+
+## Case Registration Mechanism
+
+Identical to `APIExample` — automatic via reflection, no manual list.
+
+**Startup flow:**
+1. `MainApplication.onCreate()` calls `ClassUtils.getFileNameByPackageName(context, "io.agora.api.example.examples")`.
+2. `ClassUtils` scans all DEX entries whose class name starts with that prefix.
+3. For each class, it checks for `@Example` annotation and calls `Examples.addItem(annotation)`.
+4. `Examples.sortItem()` sorts each group by `index`.
+5. `MainFragment` reads `Examples.ITEM_MAP` and renders the list.
+
+**`@Example` annotation — all four fields are required:**
+```java
+@Example(
+ index = 2, // sort order within the group; BASIC: 0–9, ADVANCED: 10+
+ group = BASIC, // "BASIC" or "ADVANCED"
+ name = R.string.item_my_case, // display name string resource
+ actionId = R.id.action_mainFragment_to_myCase, // nav action ID in nav_graph.xml
+ tipsId = R.string.my_case_tips // description string resource
+)
+public class MyCase extends BaseFragment { … }
+```
+
+## Navigation
+
+Identical to `APIExample` — single `nav_graph.xml` with Jetpack Navigation Component.
+
+Every case needs:
+- A `` destination entry in `nav_graph.xml`
+- An `` inside ``
+- The action `id` must exactly match `actionId` in `@Example`
+
+## RtcEngine Lifecycle
+
+```
+onActivityCreated → RtcEngine.create() (voice-sdk — no video APIs)
+ → engine.setAudioProfile / setAudioScenario
+ → joinChannel() (after RECORD_AUDIO permission granted)
+ ↓
+ [IRtcEngineEventHandler callbacks — background thread]
+ ↓
+onDestroy → engine.leaveChannel()
+ → RtcEngine.destroy()
+ → engine = null
+```
+
+## Token Flow
+
+```java
+TokenUtils.gen(requireContext(), channelId, uid, token -> {
+ engine.joinChannel(token, channelId, uid, options);
+});
+```
+
+`TokenUtils` reads `AGORA_APP_ID` and `AGORA_APP_CERT` from `local.properties` via `BuildConfig`. If `AGORA_APP_CERT` is empty, token generation is skipped — valid for projects without certificate.
diff --git a/Android/APIExample-Audio/CLAUDE.md b/Android/APIExample-Audio/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/Android/APIExample-Audio/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/Android/APIExample-Audio/README.md b/Android/APIExample-Audio/README.md
index 6cbdf868a..f2e1d131a 100644
--- a/Android/APIExample-Audio/README.md
+++ b/Android/APIExample-Audio/README.md
@@ -22,19 +22,16 @@ To build and run the sample application, get an App Id:
2. Navigate in the Dashboard tree on the left to **Projects** > **Project List**.
3. Save the **App Id** from the Dashboard for later use.
4. Save the **App Certificate** from the Dashboard for later use.
-5. Generate a temp **Access Token** (valid for 24 hours) from dashboard page with given channel name, save for later use.
-
-6. Open `Android/APIExample` and edit the `app/src/main/res/values/string-config.xml` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard, and update `YOUR ACCESS TOKEN` with the temp Access Token generated from dashboard. Note you can leave the token and certificate variable `null` if your project has not turned on security token.
+5. Open `Android/APIExample-Audio` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well.
```
- YOUR APP ID
- // assign token and certificate to null if you have not enabled app certificate
- YOUR APP CERTIFICATE
- // assign token and certificate to null if you have not enabled app certificate or you have set the certificate above.
- // PS:It is unsafe to place the App Certificate on the client side, it is recommended to place it on the server side to ensure that the App Certificate is not leaked.
- YOUR ACCESS TOKEN
+ sdk.dir=/path/to/Android/sdk
+ AGORA_APP_ID=YOUR APP ID
+ AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you prefer to generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the `JoinChannelAudio(Token)` example to paste the token at runtime.
+
You are all set. Now connect your Android device and run the project.
diff --git a/Android/APIExample-Audio/README.zh.md b/Android/APIExample-Audio/README.zh.md
index 6828cc087..38204654d 100644
--- a/Android/APIExample-Audio/README.zh.md
+++ b/Android/APIExample-Audio/README.zh.md
@@ -22,20 +22,16 @@
2. 前往后台页面,点击左部导航栏的 **项目 > 项目列表** 菜单
3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它
4. 复制后台的 **App Certificate** 并备注,稍后启动应用时会用到它
-5. 在项目页面生成临时 **Access Token** (24小时内有效)并备注,注意生成的Token只能适用于对应的频道名。
-
-6. 打开 `Android/APIExample` 并编辑 `app/src/main/res/values/string-config.xml`,将你的 AppID 、App主证书、 临时Token 分别替换到 `Your App Id` 、 `YOUR ACCESS TOKEN` 和 `YOUR APP CERTIFICATE`
+5. 打开 `Android/APIExample-Audio` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE`
```
- YOUR APP ID
- // 如果你没有打开Token功能,certificate可以直接不填
- YOUR APP CERTIFICATE
- // 如果你没有打开Token功能或者已经配置了certificate,token可以直接不填
- // 注意:App证书放在客户端不安全,推荐放在服务端以确保 App 证书不会泄露。
- YOUR ACCESS TOKEN
-
+ sdk.dir=/path/to/Android/sdk
+ AGORA_APP_ID=YOUR APP ID
+ AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 `JoinChannelAudio(Token)` 示例在运行时粘贴 token。
+
然后你就可以编译并运行项目了。
## 联系我们
diff --git a/Android/APIExample-Audio/app/build.gradle b/Android/APIExample-Audio/app/build.gradle
index 97830bac4..a12490b62 100644
--- a/Android/APIExample-Audio/app/build.gradle
+++ b/Android/APIExample-Audio/app/build.gradle
@@ -9,6 +9,18 @@ sdkVersionFile.withInputStream { stream ->
def agoraSdkVersion = properties.getProperty("rtc_sdk_version")
println("${rootProject.project.name} agoraSdkVersion: ${agoraSdkVersion}")
def localSdkPath= "${rootProject.projectDir.absolutePath}/../../sdk"
+def localPropertiesFile = rootProject.file("local.properties")
+def localProperties = new Properties()
+if (localPropertiesFile.exists()) {
+ localPropertiesFile.withInputStream { stream ->
+ localProperties.load(stream)
+ }
+}
+def agoraAppId = localProperties.getProperty("AGORA_APP_ID", "")
+if (agoraAppId.isEmpty()) {
+ throw new GradleException("Please configure correctly in the local.properties file in the project root directory: AGORA_APP_ID=")
+}
+def agoraAppCert = localProperties.getProperty("AGORA_APP_CERT", "")
android {
namespace "io.agora.api.example"
@@ -22,6 +34,8 @@ android {
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ buildConfigField "String", "AGORA_APP_ID", "\"${agoraAppId}\""
+ buildConfigField "String", "AGORA_APP_CERT", "\"${agoraAppCert}\""
}
signingConfigs {
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java
index cc64acdeb..02f1bfdfd 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/common/BaseFragment.java
@@ -20,6 +20,7 @@
import java.util.Map;
+import io.agora.api.example.utils.AgoraConfig;
import io.agora.api.example.utils.PermissonUtils;
/**
@@ -153,6 +154,14 @@ protected final void showShortToast(final String msg) {
});
}
+ protected final String getAgoraAppId() {
+ return AgoraConfig.getAppId();
+ }
+
+ protected final String getAgoraAppCertificate() {
+ return AgoraConfig.getAppCertificate();
+ }
+
/**
* Run on ui thread.
*
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
index 40ea64b86..e4a5bcb2e 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
@@ -160,7 +160,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/**
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/** Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -330,7 +330,7 @@ private void joinChannel(String channelId) {
Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))
);
- /**Please configure accessToken in the string_config file.
+ /**
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
index c541d602b..cefd9f124 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
@@ -82,7 +82,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/**
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/** Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
index fa2112fe8..c953bffd1 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
@@ -149,7 +149,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/**
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/** Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -268,7 +268,7 @@ private void joinChannel(String channelId) {
engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /**Please configure accessToken in the string_config file.
+ /**
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
index 1f6b9ebf8..70beefdaf 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
@@ -102,7 +102,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/**
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/** Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -227,7 +227,7 @@ private void joinChannel(String channelId) {
engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
engine.enableAudioVolumeIndication(1000, 3, true);
- /**Please configure accessToken in the string_config file.
+ /**
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
index 36e5fd7c3..616988e25 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
@@ -104,7 +104,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
* How to get the App ID
* @param handler IRtcEngineEventHandler is an abstract class providing default implementation.
* The SDK uses this class to report to the app on SDK runtime events.*/
- String appId = getString(R.string.agora_app_id);
+ String appId = getAgoraAppId();
RtcEngineConfig config = new RtcEngineConfig();
config.mContext = getContext().getApplicationContext();
config.mAppId = appId;
@@ -219,7 +219,7 @@ private void joinChannel() {
engine.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
index 39780ae2c..888b457db 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
@@ -249,7 +249,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -397,7 +397,7 @@ private void joinChannel(String channelId) {
Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))
);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
index 9d3be1823..d92c0633e 100755
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
@@ -106,7 +106,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -233,7 +233,7 @@ private void joinChannel(String channelId) {
engine.setExternalAudioSink(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
index aa49bb8ff..c10b8e986 100755
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
@@ -124,7 +124,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/**
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/** Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -278,7 +278,7 @@ private void joinChannel(String channelId) {
config.enableLocalPlayback = false;
customAudioTrack = engine.createCustomAudioTrack(Constants.AudioTrackType.AUDIO_TRACK_MIXABLE, config);
- /**Please configure accessToken in the string_config file.
+ /**
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
index b2c134dc6..472d340b5 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
@@ -58,7 +58,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -168,7 +168,7 @@ private void joinChannel(String channelId) {
option.autoSubscribeVideo = true;
option.publishMicrophoneTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
index 258cc4b13..477311248 100755
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
@@ -251,7 +251,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -463,7 +463,7 @@ private void joinChannel(String channelId) {
option.autoSubscribeAudio = true;
option.autoSubscribeVideo = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java
new file mode 100644
index 000000000..4f1d3e4e5
--- /dev/null
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java
@@ -0,0 +1,22 @@
+package io.agora.api.example.utils;
+
+import android.text.TextUtils;
+
+import io.agora.api.example.BuildConfig;
+
+public final class AgoraConfig {
+ private AgoraConfig() {
+ }
+
+ public static String getAppId() {
+ return BuildConfig.AGORA_APP_ID;
+ }
+
+ public static String getAppCertificate() {
+ return BuildConfig.AGORA_APP_CERT;
+ }
+
+ public static boolean hasAppCertificate() {
+ return !TextUtils.isEmpty(getAppCertificate());
+ }
+}
diff --git a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
index 7d11f19e6..0b8a4a2df 100644
--- a/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
+++ b/Android/APIExample-Audio/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
@@ -14,7 +14,6 @@
import java.io.IOException;
import java.util.Objects;
-import io.agora.api.example.R;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
@@ -37,11 +36,11 @@ public class TokenUtils {
}
public static void genToken(Context context, String channelName, int uid, OnTokenGenCallback onGetToken) {
- String cert = context.getString(R.string.agora_app_certificate);
+ String cert = AgoraConfig.getAppCertificate();
if (cert.isEmpty()) {
onGetToken.onTokenGen("");
} else {
- gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), cert, channelName, uid, ret -> {
if (onGetToken != null) {
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
@@ -59,7 +58,7 @@ public static void genToken(Context context, String channelName, int uid, OnToke
}
public static void gen(Context context, String channelName, int uid, OnTokenGenCallback onGetToken){
- gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), AgoraConfig.getAppCertificate(), channelName, uid, ret -> {
if(onGetToken != null){
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
diff --git a/Android/APIExample-Audio/app/src/main/res/values/string_configs.xml b/Android/APIExample-Audio/app/src/main/res/values/string_configs.xml
deleted file mode 100644
index 767727190..000000000
--- a/Android/APIExample-Audio/app/src/main/res/values/string_configs.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
- YOUR APP ID
-
-
-
- YOUR APP CERTIFICATE
-
-
diff --git a/Android/APIExample-Audio/ci.env.py b/Android/APIExample-Audio/ci.env.py
index dd130dd6f..645cf0b1a 100644
--- a/Android/APIExample-Audio/ci.env.py
+++ b/Android/APIExample-Audio/ci.env.py
@@ -1,22 +1,30 @@
-#!/usr/bin/python
-# -*- coding: UTF-8 -*-
import os
-import re
+from pathlib import Path
-def main():
- appId = ""
- if "AGORA_APP_ID" in os.environ:
- appId = os.environ["AGORA_APP_ID"]
- token = ""
+def upsert_property(lines, key, value):
+ target = f"{key}="
+ replaced = False
+ new_lines = []
+ for line in lines:
+ if line.startswith(target):
+ new_lines.append(f"{target}{value}\n")
+ replaced = True
+ else:
+ new_lines.append(line)
+ if not replaced and value:
+ new_lines.append(f"{target}{value}\n")
+ return new_lines
+
- f = open("./app/src/main/res/values/string_configs.xml", 'r+')
- content = f.read()
- contentNew = re.sub(r'YOUR APP ID', appId, content)
- contentNew = re.sub(r'YOUR ACCESS TOKEN', token, contentNew)
- f.seek(0)
- f.write(contentNew)
- f.truncate()
+def main():
+ app_id = os.environ.get("AGORA_APP_ID", "")
+ app_cert = os.environ.get("AGORA_APP_CERT", "") or os.environ.get("AGORA_APP_CERTIFICATE", "")
+ local_properties = Path("./local.properties")
+ lines = local_properties.read_text().splitlines(keepends=True) if local_properties.exists() else []
+ lines = upsert_property(lines, "AGORA_APP_ID", app_id)
+ lines = upsert_property(lines, "AGORA_APP_CERT", app_cert)
+ local_properties.write_text("".join(lines))
if __name__ == "__main__":
diff --git a/Android/APIExample-Audio/cloud_build.sh b/Android/APIExample-Audio/cloud_build.sh
index 675ad98f6..a6dd49ba7 100755
--- a/Android/APIExample-Audio/cloud_build.sh
+++ b/Android/APIExample-Audio/cloud_build.sh
@@ -37,11 +37,23 @@ fi
#sed -ie "s#google()#maven { url \"https\://maven.aliyun.com/repository/public\" }\n google()#g" settings.gradle
#sed -ie "s#https://services.gradle.org/distributions#https://mirrors.cloud.tencent.com/gradle#g" gradle/wrapper/gradle-wrapper.properties
+set_local_property() {
+ key="$1"
+ value="$2"
+ file="local.properties"
+ touch "$file"
+ if grep -q "^${key}=" "$file"; then
+ sed -i.bak "s#^${key}=.*#${key}=${value}#g" "$file"
+ rm -f "${file}.bak"
+ elif [ -n "$value" ]; then
+ echo "${key}=${value}" >> "$file"
+ fi
+}
+
## config appId
-sed -i -e "s#YOUR APP ID#${APP_ID}#g" app/src/main/res/values/string_configs.xml
-sed -i -e "s#YOUR APP CERTIFICATE##g" app/src/main/res/values/string_configs.xml
-sed -i -e "s#YOUR ACCESS TOKEN##g" app/src/main/res/values/string_configs.xml
-rm -f app/src/main/res/values/string_configs.xml-e
+set_local_property "AGORA_APP_ID" "${APP_ID}"
+APP_CERT_VALUE="${APP_CERT:-${AGORA_APP_CERT:-${AGORA_APP_CERTIFICATE:-}}}"
+set_local_property "AGORA_APP_CERT" "${APP_CERT_VALUE}"
./gradlew clean || exit 1
./gradlew :app:assembleRelease || exit 1
@@ -68,4 +80,4 @@ if [ "$WORKSPACE" != "" ]; then
APK_NAME="${PROJECT_NAME}_${BUILD_NUMBER}_${SDK_VERSION}_$(date "+%Y%m%d%H%M%S").apk"
echo "Copying APK to: $WORKSPACE/$APK_NAME"
cp app/build/outputs/apk/release/*.apk "$WORKSPACE/$APK_NAME"
-fi
\ No newline at end of file
+fi
diff --git a/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md b/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..e4bb11024
--- /dev/null
+++ b/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,117 @@
+---
+name: query-cases
+description: >
+ Query and browse existing API example cases in the APIExample-Compose Android demo —
+ lists cases by group, finds which case demonstrates a specific Agora API, checks list
+ position availability, and resolves display names from string resources. Use when:
+ someone asks what Compose cases exist, which APIs are demonstrated, wants to find a
+ case by name or API (e.g. takeSnapshot, setClientRole), needs to know the current
+ list position before adding a new case, or wants to know if a feature is already
+ implemented in Compose. Registration is manual via Examples.kt — no @Example annotation.
+ Keywords: list cases, find case, query cases, Examples.kt, BasicExampleList,
+ AdvanceExampleList, available cases, existing cases, which case, is there a case,
+ Compose case, Jetpack Compose.
+---
+
+# Query Cases — APIExample-Compose
+
+## How cases are registered
+
+Unlike APIExample, this project does NOT use reflection. Cases are manually registered in:
+
+`app/src/main/java/io/agora/api/example/compose/model/Examples.kt`
+
+Two lists define the groups:
+
+```kotlin
+val BasicExampleList = listOf(
+ Example(R.string.example_join_channel_video) { JoinChannelVideo() },
+ // …
+)
+
+val AdvanceExampleList = listOf(
+ Example(R.string.example_live_streaming) { LiveStreaming() },
+ // …
+)
+```
+
+List position is display order — there is no `index` field. String keys use the `example_` prefix.
+
+---
+
+## Query procedure
+
+### Step 1: Decide scope before scanning
+
+Before reading files, ask:
+- **Looking for a specific API?** — read Composable KDoc comments for the API name; no need to read all files
+- **Need to know current list positions?** — read Examples.kt only; positions are 1-based list indices
+- **Listing all cases?** — read Examples.kt for the full registry, then resolve names from strings.xml
+
+### Step 2: Read ARCHITECTURE.md first
+
+Read `ARCHITECTURE.md` (the `samples/` section of the Directory Layout). It contains a pre-built index of all cases with group, position, display name, and key API — no file scanning needed for most queries.
+
+Use ARCHITECTURE.md as the primary source. Fall back to reading Examples.kt only when:
+- The query requires data not in ARCHITECTURE.md (e.g. exact list position, `description` field)
+- ARCHITECTURE.md appears stale (a case exists in Examples.kt but not in the doc)
+- The output involves list position availability, duplicate registration checks, or "is this case already registered?" decisions — these must be validated from `Examples.kt` immediately before final output
+
+### Step 3: Read Examples.kt (fallback / position queries)
+
+File: `app/src/main/java/io/agora/api/example/compose/model/Examples.kt`
+
+Parse `BasicExampleList` and `AdvanceExampleList`. Each entry is:
+
+```kotlin
+Example(R.string.example_your_case_name) { YourCaseName() }
+```
+
+Position in the list (1-based) is the display order. There is no `index` field and no disabled/commented-out mechanism equivalent to `//@Example`.
+
+### Step 4: Resolve display names
+
+Resolve `R.string.example_*` from `app/src/main/res/values/strings.xml`:
+`R.string.example_video_snapshot` → `Video Snapshot`
+
+### Step 5: Read Composable KDoc for API mapping
+
+Case implementations are in `app/src/main/java/io/agora/api/example/compose/samples/`. The KDoc above each public Composable lists key APIs:
+
+```kotlin
+/**
+ * Demonstrates how to take a snapshot of the local video stream.
+ *
+ * Key APIs used:
+ * - RtcEngine.takeSnapshot()
+ */
+@Composable
+fun VideoSnapshot() { … }
+```
+
+Use this to answer "which case uses X?" without reading the full implementation. If no KDoc, scan the function body for the API name.
+
+### Step 6: Present results
+
+Full listing — table format:
+
+| Group | Position | Case Name | File | Key APIs |
+|-------|----------|-----------|------|----------|
+| Basic | 1 | Join Channel Video | JoinChannelVideo.kt | joinChannel(), setupLocalVideo() |
+| Advanced | 3 | Video Snapshot | VideoSnapshot.kt | takeSnapshot() |
+
+For a specific query, return only matching rows.
+
+For a position query, list current entries in the target list and identify the next available slot:
+> AdvanceExampleList has 12 entries → next position: 13 (append at end)
+
+Before returning any position/registration-conflict result, re-read `Examples.kt` and recompute from the current list entries.
+
+---
+
+## NEVER
+
+- **NEVER** look for `@Example` annotations — this project uses manual registration in Examples.kt, not reflection.
+- **NEVER** treat list position as a unique ID that must be gap-free — position is just list order; new cases always append at the end of the appropriate list.
+- **NEVER** use the `item_` string prefix — Compose cases use `example_` prefix; `item_` belongs to APIExample.
+- **NEVER** scan `nav_graph.xml` for case registration — Compose navigation is position-based and requires no nav graph changes.
diff --git a/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md b/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..fd0b84bad
--- /dev/null
+++ b/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: review-case
+description: >
+ Review an existing case implementation against project-specific red lines
+ and coding standards. Use after implementing or modifying a case.
+ Use when: reviewing a Compose case for correctness, checking red-line compliance,
+ verifying lifecycle and state patterns, auditing an existing Composable.
+ Keywords: review, audit, check, red lines, lifecycle, state, compliance, Compose.
+---
+
+# Review Case — APIExample-Compose
+
+Run through every item below before considering a case implementation complete.
+Open the case's Composable source file and verify each point against the actual code.
+
+## Checklist
+
+### Teardown & Lifecycle
+
+- [ ] **leaveChannel before destroy in onDispose** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the `onDispose` block. Destroying without leaving first leaks the channel session on the server side.
+
+- [ ] **DisposableEffect key is lifecycleOwner not Unit** — `DisposableEffect(lifecycleOwner)` not `DisposableEffect(Unit)`. Using `Unit` fires only once and won't clean up on back navigation; the `onDispose` block never re-executes when the lifecycle owner changes.
+
+### State Management
+
+- [ ] **rememberSaveable for channelName/isJoined/uid and remember for RtcEngine** — `channelName`, `isJoined`, `uid` use `rememberSaveable`; `RtcEngine` uses `remember`. `rememberSaveable` survives configuration changes (rotation); `RtcEngine` is not serializable and will crash if placed in `rememberSaveable`.
+
+### Threading
+
+- [ ] **Callbacks dispatch to main thread via coroutineScope.launch(Dispatchers.Main)** — `IRtcEngineEventHandler` callbacks that show `Toast`, `Dialog`, or `AlertDialog` dispatch to the main thread via `coroutineScope.launch(Dispatchers.Main)`. SDK callbacks arrive on a background thread; `Toast` and dialog APIs require the main thread or they throw `CalledFromWrongThreadException`. Note: simple Compose state mutations (e.g. `isJoined = true`) are thread-safe via the snapshot system and do **not** need main-thread dispatch.
+
+### Permissions
+
+- [ ] **Permission check before joinChannel** — Permission launcher (`rememberLauncherForActivityResult`) is called before `joinChannel()`. Joining without the required permissions (`RECORD_AUDIO`, and `CAMERA` for video cases) causes a silent failure — no error callback, just no audio/video.
+
+## If a Check Fails
+
+- `DisposableEffect(Unit)` is used — change key to `lifecycleOwner`, then verify back navigation triggers cleanup.
+- `RtcEngine` stored in `rememberSaveable` or state fields in `remember` only — fix to `RtcEngine -> remember`, UI/session state -> `rememberSaveable`, then verify rotation.
+- Toast/Dialog shown directly in callback — move UI-thread-only calls into `coroutineScope.launch(Dispatchers.Main)`.
+- Permission launcher bypassed before `joinChannel()` — gate join flow behind permission callback and re-test denied/granted paths.
+
+## NEVER
+
+- **NEVER** approve a review when `DisposableEffect` key is `Unit` for case teardown logic.
+- **NEVER** approve a review when `RtcEngine` uses `rememberSaveable`.
+- **NEVER** treat Compose callback state safety as permission to call Toast/Dialog off main thread.
+- **NEVER** skip rotation and back-navigation checks for lifecycle-sensitive Compose cases.
diff --git a/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md b/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..3faecf254
--- /dev/null
+++ b/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,185 @@
+---
+name: upsert-case
+description: >
+ Add a new API example case or modify an existing one in the APIExample-Compose Android demo —
+ creates or updates a Kotlin Composable file, registers or updates it in Examples.kt, and manages
+ string resources. Use when: adding a new Agora RTC API demo screen in Jetpack Compose, modifying
+ an existing case's implementation or registration, porting an existing APIExample case to Compose,
+ implementing a new feature example in Kotlin + Compose UI, registering a new entry in
+ BasicExampleList or AdvanceExampleList, or updating an existing case's strings or Examples.kt entry.
+ Kotlin only — no XML layouts, no Fragments. Keywords: add case, modify case, update case,
+ new composable, Examples.kt, BasicExampleList, AdvanceExampleList, APIExample-Compose, Compose case,
+ new screen, Jetpack Compose, RTC API example, upsert case.
+---
+
+# Upsert Case — APIExample-Compose
+
+## Adding a New Case
+
+Touch exactly 3 files (all paths relative to `app/src/main/`):
+
+| File | What to add |
+|---|---|
+| `java/.../compose/samples/YourCaseName.kt` | Composable file |
+| `java/.../compose/model/Examples.kt` | 1 list entry |
+| `res/values/strings.xml` | 1 string |
+
+No `nav_graph.xml` changes — navigation routes by list position automatically.
+
+---
+
+### Step 1: Clarify before coding
+
+Before writing a single line, ask:
+- **What API am I demonstrating?** — determines which existing case is the closest reference (`JoinChannelVideo.kt` for video, `JoinChannelAudio.kt` for audio)
+- **Video or audio-only?** — determines permissions (`CAMERA` + `RECORD_AUDIO` vs `RECORD_AUDIO` only), whether `enableVideo()` and `VideoGrid` are needed
+- **BasicExampleList or AdvanceExampleList?** — Basic for fundamental join/leave patterns; Advance for feature-specific APIs
+- **List position?** — run `query-cases` skill to see current entries; list order is display order
+
+---
+
+### Step 2: Create the Composable file
+
+**MANDATORY — READ ENTIRE FILE before writing any code**:
+[`references/composable-template.kt`](references/composable-template.kt)
+
+Do NOT skip — the `SettingPreferences.getArea()`, `DisposableEffect` key, `rememberSaveable` vs `remember` rules, and `@Preview` placement are only fully shown there and are required in every case.
+
+**Do NOT load** any other reference files for this task.
+
+Non-obvious points the template highlights:
+
+- `mAreaCode = SettingPreferences.getArea()` — **required**, do not hardcode or omit
+- `DisposableEffect(lifecycleOwner)` — key must be `lifecycleOwner`, not `Unit`; wrong key means cleanup never fires on back navigation
+- `rememberSaveable` for channelName, isJoined, uid, videoIdList — survives rotation
+- `remember` for RtcEngine — must NOT be `rememberSaveable` (engine is not serializable)
+- `IRtcEngineEventHandler` callbacks can mutate Compose state directly — snapshot system is thread-safe, no `runOnUIThread()` needed
+- `Toast`/`Dialog`/`AlertDialog` inside callbacks still need main thread — use `coroutineScope.launch(Dispatchers.Main) { }`
+- `@Preview` goes on the **private** `*View` function only — never on the public stateful entry
+
+---
+
+### Step 3: Register in Examples.kt
+
+File: `app/src/main/java/io/agora/api/example/compose/model/Examples.kt`
+
+```kotlin
+val AdvanceExampleList = listOf(
+ // … existing entries …
+ Example(R.string.example_your_case_name) { YourCaseName() }
+)
+```
+
+List order is display order — position determines where the case appears in the UI.
+
+---
+
+### Step 4: Add string resource
+
+File: `app/src/main/res/values/strings.xml`
+
+```xml
+Your Case Name
+```
+
+String key must use the `example_` prefix. No separate tips string needed (unlike APIExample).
+
+---
+
+### Step 5: Update ARCHITECTURE.md
+
+Add one line to the case list in `ARCHITECTURE.md` under the correct directory section:
+
+```
+├── YourCaseName.kt # "Display Name" — key API description
+```
+
+Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans.
+
+---
+
+## Modifying an Existing Case
+
+When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating:
+
+| What changed | Files to touch |
+|---|---|
+| Implementation logic (API calls, event handling, Compose state) | `java/.../compose/samples/CaseName.kt` |
+| Display name | `res/values/strings.xml` |
+| List group (Basic ↔ Advance) or position | `java/.../compose/model/Examples.kt` (move entry between lists or reorder) |
+| Composable function rename | `CaseName.kt` (file + function name), `Examples.kt` (lambda reference), `ARCHITECTURE.md` |
+
+After making changes:
+
+1. **Verify `Examples.kt` entry consistency** — ensure the string resource reference, composable lambda, and list placement (`BasicExampleList` or `AdvanceExampleList`) still match the actual case. A mismatch causes the case to silently disappear from the list or render the wrong screen.
+2. **Update `res/values/strings.xml`** if the display name changed.
+3. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description.
+
+---
+
+## Verify
+
+```bash
+./gradlew assembleDebug
+```
+
+- [ ] Case appears in the correct group at the expected list position
+- [ ] Tap navigates to the case screen
+- [ ] Channel join succeeds and `isJoined` flips to `true`
+- [ ] Press back — check Logcat for `RtcEngine.destroy` within ~2 seconds; if missing, `DisposableEffect` key is wrong or `onDispose` is incomplete
+- [ ] Rotate screen — `channelName` and `isJoined` survive (`rememberSaveable` working)
+- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description
+- [ ] `Examples.kt` entry is consistent — string resource, composable lambda, and list placement match the actual case
+
+---
+
+## When to Use a Spec Instead
+
+If the case meets any of the following criteria, create a Spec rather than using this skill directly:
+
+1. Involves coordinated calls across two or more Agora API modules
+2. Requires a custom Composable layout not covered by the standard template above
+3. Manages multiple channels or multiple engine instances
+4. Requires a foreground Service or background coroutine coordination
+5. Involves developing new shared components (shared Composables / utils)
+6. Requires optional module integration (simpleFilter / streamEncrypt)
+
+If none apply → use this skill directly; no Spec needed.
+
+### Spec Requirements Document Must Include
+
+- List of APIs the case demonstrates
+- User interaction flow description
+- Expected RtcEngine lifecycle behavior
+- Required permissions list
+
+### Spec Design Document Must Include
+
+- Target project identifier: `APIExample-Compose`
+- Composable function structure design
+- API call sequence (Mermaid sequence diagram recommended)
+- State management plan (`remember` vs `rememberSaveable` boundaries)
+- UI layout plan
+- Integration points with existing shared components
+- Case registration info: `Examples.kt` list entry, `strings.xml` key (`example_` prefix) — finalize during design to avoid conflicts
+- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing entries
+- Compose-specific checks: `DisposableEffect(lifecycleOwner)`, `rememberSaveable` vs `remember`, main-thread dispatch for Toast/Dialog
+- Risk identification and mitigation (API compatibility, performance, permissions, thread safety, rotation/config changes)
+
+### Spec Task List Integration
+
+- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters
+- Mark which sub-tasks require manual coding, and provide target file paths and change summaries
+- Tasks for creating new shared Composables must come before case implementation tasks
+
+---
+
+## NEVER
+
+- **NEVER** use XML layouts, `Fragment`, or `ViewBinding` — Compose only.
+- **NEVER** use `remember` for channelName, isJoined, or uid — they must be `rememberSaveable` to survive rotation.
+- **NEVER** use `rememberSaveable` for `RtcEngine` — it is not serializable and will crash on rotation.
+- **NEVER** use `Unit` as the `DisposableEffect` key — it fires only once and won't clean up on back navigation. Always use `lifecycleOwner`.
+- **NEVER** put `@Preview` on the public stateful function — it will crash because `LocalContext` and `LocalLifecycleOwner` are unavailable in preview. Only preview the private `*View` function.
+- **NEVER** call `Toast`/`Dialog`/`AlertDialog` directly inside `IRtcEngineEventHandler` callbacks — they require the main thread. Use `coroutineScope.launch(Dispatchers.Main) { }`.
+- **NEVER** hardcode `mAreaCode` — always use `SettingPreferences.getArea()`.
diff --git a/Android/APIExample-Compose/AGENTS.md b/Android/APIExample-Compose/AGENTS.md
new file mode 100644
index 000000000..3e63c9283
--- /dev/null
+++ b/Android/APIExample-Compose/AGENTS.md
@@ -0,0 +1,38 @@
+# AGENTS.md — APIExample-Compose
+
+Jetpack Compose version of the API demo. Mirrors cases from `APIExample/` but uses
+`@Composable` functions instead of Fragments + XML layouts. Kotlin only.
+
+## Build Commands
+
+```bash
+./gradlew assembleDebug # build debug APK
+./gradlew installDebug # build + install to connected device
+./gradlew test # unit tests
+./gradlew connectedAndroidTest # instrumented tests (device required)
+```
+
+## App ID Configuration
+
+See [README.md — Obtain an App Id](README.md#obtain-an-app-id).
+
+## Architecture Red Lines
+
+- Do NOT use XML layouts, `Fragment`, or `ViewBinding` — Compose only.
+- Do NOT use `View`-based widgets directly in Compose UI — wrap with `AndroidView` if unavoidable.
+- `RtcEngine` must be created inside `remember { }` and destroyed inside `DisposableEffect(lifecycleOwner) { onDispose { } }` — key must be `lifecycleOwner`, not `Unit`; wrong key means cleanup never fires on back navigation.
+- Always call `rtcEngine.leaveChannel()` before `RtcEngine.destroy()` in `onDispose`.
+- Permissions use `rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions())`.
+- `IRtcEngineEventHandler` callbacks are safe to mutate Compose state directly (snapshot system is thread-safe).
+
+## Skills
+
+| Skill | Path | Description |
+|-------|------|-------------|
+| upsert-case | `.agent/skills/upsert-case/` | Add a new Compose case or modify an existing one |
+| query-cases | `.agent/skills/query-cases/` | Query and browse existing Compose cases |
+| review-case | `.agent/skills/review-case/` | Review a case against project red lines |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, Composable case pattern, registration details
diff --git a/Android/APIExample-Compose/ARCHITECTURE.md b/Android/APIExample-Compose/ARCHITECTURE.md
new file mode 100644
index 000000000..3b70c3991
--- /dev/null
+++ b/Android/APIExample-Compose/ARCHITECTURE.md
@@ -0,0 +1,197 @@
+# ARCHITECTURE.md — APIExample-Compose
+
+## Directory Layout
+
+```
+APIExample-Compose/
+├── gradle.properties # rtc_sdk_version
+├── AGENTS.md # Agent entry point — build commands, red lines, skill index
+├── ARCHITECTURE.md # This file — directory layout, patterns, registration
+├── .kiro/
+│ ├── hooks/
+│ │ └── build-on-task-complete.json # Runs assembleDebug after each spec task completes
+│ ├── skills/
+│ │ ├── add-new-case/SKILL.md # Step-by-step guide for adding a new Compose case
+│ │ └── query-cases/SKILL.md # Query existing cases by API, group, or list position
+│ └── steering/
+│ ├── project-routing.md # Which sub-project to use; hard constraints (always included)
+│ ├── coding-standards.md # RtcEngine lifecycle, Kotlin/Compose rules (always included)
+│ └── complex-case-spec.md # Spec workflow for complex cases (manual inclusion)
+└── app/src/main/
+ ├── AndroidManifest.xml
+ ├── assets/ # Audio/video sample files
+ ├── res/
+ │ └── values/strings.xml # Display name strings (prefix: example_*)
+ └── java/io/agora/api/example/compose/
+ ├── APIExampleApp.kt # Application class
+ ├── MainActivity.kt # Single-Activity, sets content to NavGraph()
+ ├── NavGraph.kt # Compose Navigation host — home / settings / example
+ │
+ ├── model/
+ │ ├── Example.kt # data class: name: Int, content: @Composable
+ │ ├── Examples.kt # Hardcoded lists: BasicExampleList, AdvanceExampleList
+ │ └── Components.kt # Groups the two lists into Components for the home screen
+ │
+ ├── samples/ # One .kt file per case — all @Composable
+ │ ├── JoinChannelVideoToken.kt # Basic: "Join Video Channel (With Token)"
+ │ ├── JoinChannelVideo.kt # Basic: "Join Video Channel" — canonical reference
+ │ ├── JoinChannelAudio.kt # Basic: "Join Audio Channel"
+ │ ├── LiveStreaming.kt # Advanced: "Live Streaming" — setClientRole
+ │ ├── RTMPStreaming.kt # Advanced: "RTMP Streaming" — push to CDN
+ │ ├── MediaMetadata.kt # Advanced: "Media Metadata" — send/receive metadata
+ │ ├── VoiceEffects.kt # Advanced: "Voice Effects" — voice beautifier/effects
+ │ ├── OriginAudioData.kt # Advanced: "Origin Audio Data" — raw audio processing
+ │ ├── CustomAudioSource.kt # Advanced: "Custom Audio Source" — push external audio
+ │ ├── CustomAudioRender.kt # Advanced: "Custom Audio Render" — pull audio rendering
+ │ ├── OriginVideoData.kt # Advanced: "Origin Video Data" — raw video processing
+ │ ├── CustomVideoSource.kt # Advanced: "Custom Video Source" — push external video
+ │ ├── CustomVideoRender.kt # Advanced: "Custom Video Render" — custom video rendering
+ │ ├── PictureInPicture.kt # Advanced: "Picture In Picture" — PiP mode
+ │ ├── JoinMultiChannel.kt # Advanced: "Join Multi Channel" — multi-channel join
+ │ ├── ChannelEncryption.kt # Advanced: "Channel Encryption" — built-in encryption
+ │ ├── PlayAudioFiles.kt # Advanced: "Play Audio Files" — audio mixing
+ │ ├── PreCallTest.kt # Advanced: "Pre Call Test" — network/device test
+ │ ├── MediaRecorder.kt # Advanced: "Media Recorder" — record media streams
+ │ ├── MediaPlayer.kt # Advanced: "Media Player" — play media files
+ │ ├── ScreenSharing.kt # Advanced: "Screen Sharing" — screen capture & share
+ │ ├── VideoProcessExtension.kt # Advanced: "Video Process Extension" — video filter
+ │ ├── RhythmPlayer.kt # Advanced: "Rhythm Player" — metronome playback
+ │ ├── LocalVideoTranscoding.kt # Advanced: "Local Video Transcoding" — local compositing
+ │ ├── SendDataStream.kt # Advanced: "Send Data Stream" — data channel messaging
+ │ ├── HostAcrossChannel.kt # Advanced: "Host Across Channel" — cross-channel relay
+ │ ├── SpatialSound.kt # Advanced: "Spatial Sound" — 3D spatial audio
+ │
+ ├── ui/
+ │ ├── home/
+ │ │ └── Home.kt # Home screen — renders grouped example list
+ │ ├── example/
+ │ │ ├── Example.kt # Wrapper screen: calls example.content(back)
+ │ │ └── ExampleItem.kt # Single row in the example list
+ │ ├── settings/
+ │ │ └── Settings.kt # Settings screen (area, resolution, frame rate)
+ │ ├── common/
+ │ │ ├── APIExampleScaffold.kt # Shared scaffold with top bar
+ │ │ ├── APIExampleTopAppBar.kt
+ │ │ └── Widgets.kt # ChannelNameInput, VideoGrid, VideoStatsInfo, etc.
+ │ └── theme/
+ │ └── Theme.kt
+ │
+ ├── data/
+ │ └── SettingPreferences.kt # DataStore-backed settings (area, resolution, frame rate)
+ │
+ └── utils/
+ ├── TokenUtils.java # Fetches RTC tokens from Agora token server
+ ├── AudioFileReader.java
+ ├── AudioPlayer.java
+ ├── VideoFileReader.java
+ ├── FileUtils.java
+ ├── YUVUtils.java
+ ├── YuvFboProgram.java
+ ├── YuvUploader.java
+ └── GLTextureView.java
+```
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| Join Video Channel (With Token) | `JoinChannelVideoToken.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()` | Joins a video channel using a manually provided token instead of fetching one automatically |
+| Join Video Channel | `JoinChannelVideo.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `setVideoEncoderConfiguration()` | Canonical reference for joining a video channel with token generation and basic video rendering |
+| Join Audio Channel | `JoinChannelAudio.kt` | `joinChannel()`, `enableAudio()`, `setChannelProfile()`, `setAudioScenario()`, `setAudioProfile()`, `enableInEarMonitoring()` | Joins an audio-only channel with audio route, in-ear monitoring, and volume controls |
+| Live Streaming | `LiveStreaming.kt` | `joinChannel()`, `enableVideo()`, `setClientRole()`, `setDualStreamMode()`, `setVideoScenario()`, `addVideoWatermark()`, `setVideoEncoderConfiguration()` | Demonstrates live streaming with client role switching, dual stream, watermark, and encoder options |
+| RTMP Streaming | `RTMPStreaming.kt` | `joinChannel()`, `enableVideo()`, `startRtmpStreamWithTranscoding()`, `startRtmpStreamWithoutTranscoding()`, `stopRtmpStream()`, `updateRtmpTranscoding()` | Pushes a live stream to a CDN via RTMP with optional transcoding |
+| Media Metadata | `MediaMetadata.kt` | `joinChannel()`, `enableVideo()`, `registerMediaMetadataObserver()` | Sends and receives video metadata through the IMetadataObserver interface |
+| Voice Effects | `VoiceEffects.kt` | `joinChannel()`, `enableAudio()`, `setVoiceBeautifierPreset()`, `setVoiceConversionPreset()`, `setAudioEffectPreset()`, `setAudioEffectParameters()`, `setAINSMode()` | Applies voice beautifier, voice changer, style transformation, and noise suppression presets |
+| Origin Audio Data | `OriginAudioData.kt` | `joinChannel()`, `enableAudio()`, `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Accesses and rewrites raw audio frames via the IAudioFrameObserver interface |
+| Custom Audio Source | `CustomAudioSource.kt` | `joinChannel()`, `enableAudio()`, `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `destroyCustomAudioTrack()`, `enableCustomAudioLocalPlayback()` | Pushes external audio from a file into a custom audio track |
+| Custom Audio Render | `CustomAudioRender.kt` | `joinChannel()`, `enableAudio()`, `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Pulls remote audio frames and renders them through a custom AudioTrack player |
+| Origin Video Data | `OriginVideoData.kt` | `joinChannel()`, `enableVideo()`, `registerVideoFrameObserver()` | Captures raw video frames via IVideoFrameObserver for screenshot functionality |
+| Custom Video Source | `CustomVideoSource.kt` | `joinChannel()`, `enableVideo()`, `pushExternalVideoFrameById()` | Pushes external video frames in I420, NV21, NV12, or Texture2D format |
+| Custom Video Render | `CustomVideoRender.kt` | `joinChannel()`, `enableVideo()`, `registerVideoFrameObserver()` | Renders remote video frames using a custom OpenGL renderer via IVideoFrameObserver |
+| Picture In Picture | `PictureInPicture.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `enterPictureInPictureMode()` | Demonstrates Android Picture-in-Picture mode during a video call |
+| Join Multi Channel | `JoinMultiChannel.kt` | `joinChannel()`, `joinChannelEx()`, `leaveChannelEx()`, `enableVideo()`, `setupRemoteVideoEx()`, `takeSnapshotEx()` | Joins two channels simultaneously using RtcEngineEx multi-channel APIs |
+| Channel Encryption | `ChannelEncryption.kt` | `joinChannel()`, `enableVideo()`, `enableEncryption()` | Enables built-in media encryption before joining a channel |
+| Play Audio Files | `PlayAudioFiles.kt` | `joinChannel()`, `enableAudio()`, `startAudioMixing()`, `stopAudioMixing()`, `playEffect()`, `preloadEffect()`, `setAudioProfile()` | Plays audio mixing and sound effect files with volume controls |
+| Pre Call Test | `PreCallTest.kt` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()`, `enableVideo()` | Runs network quality probe and audio/video echo tests before joining a channel |
+| Media Recorder | `MediaRecorder.kt` | `joinChannel()`, `enableVideo()`, `createMediaRecorder()`, `startRecording()`, `stopRecording()` | Records local or remote media streams to MP4 files using AgoraMediaRecorder |
+| Media Player | `MediaPlayer.kt` | `joinChannel()`, `enableVideo()`, `createMediaPlayer()`, `open()`, `play()`, `stop()`, `updateChannelMediaOptions()` | Plays media files and publishes the player track to the channel |
+| Screen Sharing | `ScreenSharing.kt` | `joinChannel()`, `enableVideo()`, `startScreenCapture()`, `stopScreenCapture()`, `updateScreenCaptureParameters()`, `setScreenCaptureScenario()` | Captures and shares the device screen with scenario and audio options |
+| Video Process Extension | `VideoProcessExtension.kt` | `joinChannel()`, `enableVideo()`, `setBeautyEffectOptions()`, `setLowlightEnhanceOptions()`, `setColorEnhanceOptions()`, `setVideoDenoiserOptions()`, `enableVirtualBackground()`, `enableExtension()` | Applies beauty filters, low-light enhancement, color enhancement, denoiser, and virtual background |
+| Rhythm Player | `RhythmPlayer.kt` | `joinChannel()`, `startRhythmPlayer()`, `stopRhythmPlayer()`, `updateChannelMediaOptions()` | Plays a metronome beat track and publishes it to the channel |
+| Local Video Transcoding | `LocalVideoTranscoding.kt` | `joinChannel()`, `enableVideo()`, `startLocalVideoTranscoder()`, `stopLocalVideoTranscoder()`, `startCameraCapture()`, `stopCameraCapture()` | Composites camera and media player streams into a single transcoded video |
+| Send Data Stream | `SendDataStream.kt` | `joinChannel()`, `enableVideo()`, `createDataStream()`, `sendStreamMessage()` | Sends and receives real-time data messages through a data channel |
+| Host Across Channel | `HostAcrossChannel.kt` | `joinChannel()`, `enableVideo()`, `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Relays media streams from one channel to another for cross-channel hosting |
+| Spatial Sound | `SpatialSound.kt` | `joinChannel()`, `enableAudio()`, `ILocalSpatialAudioEngine.initialize()`, `updateSelfPosition()`, `updateRemotePosition()`, `updatePlayerPositionInfo()` | Demonstrates 3D spatial audio with draggable sound source positioning |
+
+## Case Registration Mechanism
+
+Registration is **manual** — no reflection, no annotation scanning.
+
+**To add a case, edit exactly two files:**
+
+**1. `model/Examples.kt`** — append to `BasicExampleList` or `AdvanceExampleList`:
+```kotlin
+val AdvanceExampleList = listOf(
+ // … existing entries …
+ Example(R.string.example_my_new_case) { MyNewCase() }
+)
+```
+
+**2. `samples/MyNewCase.kt`** — create the Composable:
+```kotlin
+@Composable
+fun MyNewCase() { … }
+```
+
+No `nav_graph.xml`, no `@Example` annotation, no action ID. `NavGraph.kt` routes to cases by their
+index in the list — the order in `Examples.kt` is the display order.
+
+## Composable Case Pattern
+
+Every case follows a two-function structure. `JoinChannelVideo.kt` is the canonical reference.
+
+```
+MyNewCase() ← public, stateful: owns RtcEngine, state, permissions
+ └── MyNewCaseView(...) ← private, stateless: receives data + lambdas, pure UI
+```
+
+**Engine creation and cleanup:**
+```kotlin
+val rtcEngine = remember {
+ RtcEngine.create(RtcEngineConfig().apply {
+ mContext = context
+ mAppId = AgoraConfig.getAppId()
+ mEventHandler = object : IRtcEngineEventHandler() { … }
+ })
+}
+DisposableEffect(lifecycleOwner) { // key must be lifecycleOwner, not Unit
+ onDispose {
+ if (isJoined) rtcEngine.leaveChannel()
+ RtcEngine.destroy()
+ }
+}
+```
+
+**Permissions:**
+```kotlin
+val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+) { grantedMap ->
+ if (grantedMap.values.all { it }) { /* join channel */ }
+}
+// trigger:
+permissionLauncher.launch(arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA))
+```
+
+**State rules:**
+- `rememberSaveable` — values that must survive rotation (channelName, isJoined, uid)
+- `remember` — objects that must not be recreated (RtcEngine, collections)
+- `IRtcEngineEventHandler` callbacks can mutate Compose state directly — the snapshot system is thread-safe
+
+## Token Flow
+
+```kotlin
+TokenUtils.gen(channelName, uid) { token ->
+ rtcEngine.joinChannel(token, channelName, uid, options)
+}
+```
diff --git a/Android/APIExample-Compose/CLAUDE.md b/Android/APIExample-Compose/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/Android/APIExample-Compose/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/Android/APIExample-Compose/README.md b/Android/APIExample-Compose/README.md
index e2ee7b506..e5941d0a1 100644
--- a/Android/APIExample-Compose/README.md
+++ b/Android/APIExample-Compose/README.md
@@ -23,15 +23,16 @@ To build and run the sample application, get an App Id:
3. Save the **App Id** from the Dashboard for later use.
4. Save the **App Certificate** from the Dashboard for later use.
-5. Open `Android/APIExample-Compose` and edit the `local.properties` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard. Note you can leave the certificate variable `null` if your project has not turned on security token.
+5. Open `Android/APIExample-Compose` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well.
```
- // Agora APP ID.
+ sdk.dir=/path/to/Android/sdk
AGORA_APP_ID=YOUR APP ID
- // Agora APP Certificate. If the project does not have certificates enabled, leave this field blank.
AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the token-based examples to paste the token at runtime.
+
You are all set. Now connect your Android device and run the project.
diff --git a/Android/APIExample-Compose/README.zh.md b/Android/APIExample-Compose/README.zh.md
index 21769f0e2..36e8db7d0 100644
--- a/Android/APIExample-Compose/README.zh.md
+++ b/Android/APIExample-Compose/README.zh.md
@@ -23,15 +23,16 @@
3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它
4. 复制后台的 **App 证书** 并备注,稍后启动应用时会用到它
-5. 打开 `Android/APIExample` 并编辑 `local.properties`,将你的 AppID 、App主证书 分别替换到 `Your App Id` 和 `YOUR APP CERTIFICATE`
+5. 打开 `Android/APIExample-Compose` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE`
```
- // 声网APP ID。
+ sdk.dir=/path/to/Android/sdk
AGORA_APP_ID=YOUR APP ID
- // 声网APP证书。如果项目没有开启证书鉴权,这个字段留空。
AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 token 方式的示例在运行时粘贴 token。
+
然后你就可以编译并运行项目了。
## 联系我们
diff --git a/Android/APIExample/.agent/skills/query-cases/SKILL.md b/Android/APIExample/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..ee1944957
--- /dev/null
+++ b/Android/APIExample/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,110 @@
+---
+name: query-cases
+description: >
+ Query and browse existing API example cases in the APIExample Android demo — lists
+ cases by group, finds which case demonstrates a specific Agora API, checks sort
+ index availability, and resolves display names from string resources. Use when:
+ someone asks what cases exist, which APIs are demonstrated, wants to find a case
+ by name or API (e.g. takeSnapshot, setClientRole), needs a free sort index before
+ adding a new case, or wants to know if a feature is already implemented.
+ Keywords: list cases, find case, query cases, @Example, sort index, BASIC, ADVANCED,
+ available cases, existing cases, which case, is there a case.
+---
+
+# Query Cases — APIExample
+
+## How cases are registered
+
+Every case is a Fragment under `app/src/main/java/io/agora/api/example/examples/{basic|advanced|audio}/` with an `@Example` annotation:
+
+```java
+@Example(
+ index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+
+ group = ADVANCED,
+ name = R.string.item_xxx,
+ actionId = R.id.action_mainFragment_to_xxx,
+ tipsId = R.string.xxx_tips
+)
+```
+
+A commented-out `@Example` (`//@Example`) means the case is disabled and won't appear in the app.
+
+---
+
+## Query procedure
+
+### Step 1: Decide scope before scanning
+
+Before listing files, ask:
+- **Looking for a specific API?** — scan Javadoc comments for the API name; no need to read all files
+- **Need a free sort index?** — collect all `index` values for the target group, then find the gap
+- **Listing all cases?** — scan all three directories and collect annotations
+
+### Step 2: Read ARCHITECTURE.md first
+
+Read `ARCHITECTURE.md` (the `examples/` section of the Directory Layout). It contains a pre-built index of all cases with group, index, display name, and key API — no file scanning needed for most queries.
+
+Use ARCHITECTURE.md as the primary source. Fall back to scanning the source directories only when:
+- The query requires data not in ARCHITECTURE.md (e.g. full `@Example` field values, `tipsId`)
+- ARCHITECTURE.md appears stale (a case exists in source but not in the doc)
+- The output involves free-index claims, index collisions, or "is index X available?" decisions — these must be validated from source immediately before final output
+
+### Step 3: Scan case directories (fallback only)
+
+| Directory | Group | Contents |
+|-----------|-------|----------|
+| `examples/basic/` | BASIC | Core join/leave patterns |
+| `examples/advanced/` | ADVANCED | Feature-specific APIs |
+| `examples/audio/` | ADVANCED | Audio-specific cases (still grouped ADVANCED) |
+
+Each `.java` file is a case. Subdirectories (e.g. `customaudio/`) contain multi-file cases — the main class is the file whose name matches the directory name (e.g. `customaudio/CustomAudioSource.java`). If no name match, look for the file containing `@Example`.
+
+### Step 4: Extract `@Example` fields
+
+For each file, read the annotation for `group`, `index`, `name` (string resource ID), and `tipsId`. If the annotation is commented out, the case is disabled.
+
+Resolve display names from `app/src/main/res/values/strings.xml`:
+`R.string.item_video_snapshot` → `Video Snapshot`
+
+### Step 5: Read class Javadoc for API mapping
+
+The Javadoc above each class lists the key APIs demonstrated:
+
+```java
+/**
+ * This demo demonstrates how to take a snapshot of the local video stream.
+ *
+ * Key APIs used:
+ * - RtcEngine.takeSnapshot()
+ */
+```
+
+Use this to answer "which case uses X?" queries without reading the full implementation.
+
+If no Javadoc is present, scan the method body for the API name as a method call. If still not found, note "API mapping unavailable" in the results table.
+
+### Step 6: Present results
+
+Full listing — table format:
+
+| Group | Index | Case Name | File | Key APIs |
+|-------|-------|-----------|------|----------|
+| BASIC | 0 | Join Channel Video | JoinChannelVideo.java | joinChannel(), setupLocalVideo() |
+| ADVANCED | 10 | Video Snapshot | VideoSnapshot.java | takeSnapshot() |
+
+For a specific query (e.g. "which case uses takeSnapshot?"), return only matching rows.
+
+For a free-index query, list all used indices in the target group and identify the next available slot:
+> BASIC range: 0–9. ADVANCED range: 10+.
+> ADVANCED indices in use: 10, 11, 12, 15, 20 → next free: 13
+
+Before returning any free-index/collision result, re-scan source registration points (`@Example` across `basic/`, `advanced/`, `audio/`) and recompute once from source-of-truth data.
+
+---
+
+## NEVER
+
+- **NEVER** count a commented-out `@Example` (`//@Example`) as an active case — it is disabled and won't appear in the app.
+- **NEVER** mix index spaces across groups — `audio/` cases use `group=ADVANCED` but share the same index namespace as `advanced/`; always scan both directories together when finding a free index.
+- **NEVER** use filename alone to identify a subdirectory case — the main class is the file whose name matches the directory name; if no match, look for the file with `@Example`.
+- **NEVER** report a free index without scanning all three directories (`basic/`, `advanced/`, `audio/`) for the target group — missing one causes index collisions.
diff --git a/Android/APIExample/.agent/skills/review-case/SKILL.md b/Android/APIExample/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..6caabc6ce
--- /dev/null
+++ b/Android/APIExample/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,52 @@
+---
+name: review-case
+description: >
+ Review an existing case implementation against project-specific red lines
+ and coding standards. Use after implementing or modifying a case.
+ Use when: reviewing a case for correctness, checking red-line compliance,
+ verifying lifecycle and threading patterns, auditing an existing Fragment.
+ Keywords: review, audit, check, red lines, lifecycle, threading, compliance.
+---
+
+# Review Case — APIExample
+
+Run through every item below before considering a case implementation complete.
+Open the case's Fragment source file and verify each point against the actual code.
+
+## Checklist
+
+### Teardown & Lifecycle
+
+- [ ] **leaveChannel before destroy** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the teardown path (typically `onDestroy()`). Destroying without leaving first leaks the channel session on the server side.
+
+- [ ] **handler.post for destroy** — `RtcEngine.destroy()` is invoked via `handler.post(RtcEngine::destroy)` and **not** called directly on the main thread. A direct call blocks the UI thread and causes ANR.
+
+### Threading
+
+- [ ] **runOnUIThread for callbacks** — All `IRtcEngineEventHandler` callbacks that update UI are wrapped with `runOnUIThread()`. SDK callbacks arrive on a background thread; touching Views without dispatching to the main thread causes crashes or silent rendering corruption.
+
+### Permissions
+
+- [ ] **Permission check before join** — `checkOrRequestPermission()` is called before `joinChannel()`. Joining without the required permissions (RECORD_AUDIO, and CAMERA for video cases) causes a silent failure — no error callback, just no audio/video.
+
+### Backend Reporting
+
+- [ ] **setParameters present** — `setParameters(...)` is called during engine initialisation. This is required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally.
+
+### Private Cloud
+
+- [ ] **getPrivateCloudConfig null-check** — `getPrivateCloudConfig()` is null-checked before `setLocalAccessPoint()` is called. The method returns `null` on standard (non-private-cloud) builds, so calling `setLocalAccessPoint()` without the guard causes a NullPointerException.
+
+## If a Check Fails
+
+- Teardown order wrong (`destroy` before `leaveChannel`) — fix teardown to `leaveChannel()` first, then `handler.post(RtcEngine::destroy)`, and re-test back navigation.
+- UI touched in SDK callback without main-thread dispatch — wrap UI updates in `runOnUIThread()` and re-run the case to verify no thread exceptions.
+- Permission flow missing before `joinChannel()` — add `checkOrRequestPermission()` gate and verify join succeeds only after permission is granted.
+- Missing `setParameters(...)` or private-cloud null-check — add both safeguards in engine init and re-run the init path once.
+
+## NEVER
+
+- **NEVER** approve a case review with direct `RtcEngine.destroy()` on main thread.
+- **NEVER** approve a case review when `leaveChannel()` is missing before destroy.
+- **NEVER** ignore background-thread UI updates inside `IRtcEngineEventHandler` callbacks.
+- **NEVER** assume runtime behavior is correct without at least one back-navigation teardown check in Logcat.
diff --git a/Android/APIExample/.agent/skills/upsert-case/SKILL.md b/Android/APIExample/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..25211787d
--- /dev/null
+++ b/Android/APIExample/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,342 @@
+---
+name: upsert-case
+description: >
+ Add a new API example case or modify an existing one in the APIExample Android demo —
+ creates or updates Fragment class, XML layout, string resources, and nav_graph registration.
+ Use when: adding a new Agora RTC API demo screen, modifying an existing case's implementation
+ or registration, implementing a new feature example in Java + XML layouts, registering a new
+ case via @Example annotation, subclassing BaseFragment for a new demo screen, or updating
+ an existing case's strings, layout, or nav entry. Keywords: add case, modify case, update case,
+ new fragment, nav_graph, @Example, BaseFragment, APIExample, new screen, demo case, RTC API example.
+---
+
+# Upsert Case — APIExample
+
+## Adding a New Case
+
+Touch exactly 4 files (all paths relative to `app/src/main/`):
+
+| File | What to add |
+|---|---|
+| `java/.../examples/{basic\|advanced\|audio}/YourCaseName.java` | Fragment class |
+| `res/layout/fragment_your_case_name.xml` | XML layout |
+| `res/values/strings.xml` | 2 strings |
+| `res/navigation/nav_graph.xml` | 1 action + 1 destination |
+
+Registration is automatic via reflection — no other files needed.
+
+---
+
+### Step 1: Clarify before coding
+
+Before writing a single line, ask:
+- **What API am I demonstrating?** — determines which existing case is the closest reference to copy patterns from
+- **Video or audio-only?** — determines permissions (`CAMERA` + `RECORD_AUDIO` vs `RECORD_AUDIO` only), layout complexity, and whether `VideoReportLayout` is needed
+- **BASIC or ADVANCED group?** — BASIC for fundamental channel join/leave patterns; ADVANCED for feature-specific APIs
+- **What's the sort index?** — index must be unique within the group. BASIC uses 0–9, ADVANCED starts from 10. Run `query-cases` skill first; a collision causes silent ordering bugs at runtime
+
+---
+
+### Step 2: Create the Fragment
+
+**MANDATORY — READ ENTIRE FILE before writing any code**:
+[`references/fragment-template.java`](references/fragment-template.java)
+
+Do NOT skip — the `setParameters`, `handler.post`, and `getPrivateCloudConfig()` null-check patterns are only fully shown there and are required in every case.
+
+**Do NOT load** any other reference files for this task.
+
+Non-obvious points the template highlights:
+
+- `setParameters(...)` for app scenario reporting — **required in every case**, do not remove
+- `handler.post(RtcEngine::destroy)` — NOT `RtcEngine.destroy()` directly; direct call blocks UI thread (ANR)
+- `getPrivateCloudConfig()` null-check before `setLocalAccessPoint()` — returns null on non-private-cloud builds (NPE)
+- All `IRtcEngineEventHandler` callbacks run on a **background thread** — always `runOnUIThread()` for UI
+- `onActivityCreated` → create engine; `onDestroy` → `leaveChannel()` then `handler.post(RtcEngine::destroy)`
+
+For video cases, add `VideoReportLayout` fields and wire `setupRemoteVideo` in `onUserJoined`/`onUserOffline`.
+
+---
+
+### Step 3: Create the XML layout
+
+Minimum structure — channel input + join button at bottom:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+For video cases, use `VideoReportLayout` for each video slot. Pick one of the four standard layouts below — they cover the vast majority of cases.
+
+**General rules (apply to all layouts):**
+- Video containers must sit **above** the bottom control bar. In `RelativeLayout` use `android:layout_above="@id/ll_join"`; in `ConstraintLayout` use `app:layout_constraintBottom_toTopOf="@id/ll_join"`.
+- Each `VideoReportLayout` needs a unique `android:id` (`fl_local`, `fl_remote`, `fl_remote2`, …).
+
+---
+
+**Layout A — Single broadcaster (local fullscreen)**
+Use when: broadcaster-only demo, no remote video needed.
+
+```xml
+
+
+```
+
+---
+
+**Layout B — 1v1 (local left, remote right, side by side)**
+Use when: two-party call, equal-weight split.
+
+```xml
+
+
+
+
+
+
+
+```
+
+---
+
+**Layout C — Audience co-hosting (remote fullscreen background + local PiP top-right)**
+Use when: live streaming where audience co-hosts; remote/host fills screen, local is a small overlay.
+
+```xml
+
+
+
+
+```
+
+---
+
+**Layout D — 2×2 grid (up to 4 participants)**
+Use when: multi-party call with up to 4 streams.
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+### Step 4: Add nav entries
+
+File: `res/navigation/nav_graph.xml`
+
+**Action** — inside `` (NOT mainFragment — mainFragment only has one action, to Ready):
+
+```xml
+
+```
+
+**Destination** — at root `` level:
+
+```xml
+
+```
+
+`action android:id` must exactly match `actionId` in `@Example`.
+
+---
+
+### Step 5: Update ARCHITECTURE.md
+
+Add one line to the case list in `ARCHITECTURE.md` under the correct directory section (`basic/`, `advanced/`, or `audio/`):
+
+```
+├── YourCaseName.java # [index] "Display Name" — key API description
+```
+
+Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans.
+
+---
+
+
+## Modifying an Existing Case
+
+When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating:
+
+| What changed | Files to touch |
+|---|---|
+| Implementation logic (API calls, event handling) | `java/.../examples/{basic\|advanced\|audio}/CaseName.java` |
+| UI layout (views, controls, video containers) | `res/layout/fragment_case_name.xml` |
+| Display name or tips text | `res/values/strings.xml` |
+| Sort index or group (BASIC ↔ ADVANCED) | `@Example` annotation in the Fragment class |
+| Navigation label | `res/navigation/nav_graph.xml` (fragment label attribute) |
+| Class rename or package move | Fragment class, `nav_graph.xml` (android:name + destination id), `@Example` annotation (actionId), layout file name, `ARCHITECTURE.md` |
+
+After making changes:
+
+1. **Verify `@Example` annotation consistency** — ensure `index`, `group`, `name`, `actionId`, and `tipsId` still match the actual string resources, nav action ID, and intended group/position. A mismatch causes the case to silently disappear from the list or navigate to the wrong screen.
+2. **Update `res/values/strings.xml`** if the display name or tips text changed.
+3. **Update `res/navigation/nav_graph.xml`** if the class name, package, or label changed.
+4. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description.
+
+---
+
+## Verify
+
+```bash
+./gradlew assembleDebug
+```
+
+- [ ] Case appears in correct group at expected sort position
+- [ ] Tap navigates to the case screen (silent failure = nav action in wrong fragment)
+- [ ] `onJoinChannelSuccess` fires in Logcat
+- [ ] After pressing back, check Logcat for `RtcEngine.destroy` within ~2 seconds — if missing, there is a lifecycle bug in `onDestroy`
+- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description
+- [ ] `@Example` annotation fields (`index`, `group`, `name`, `actionId`, `tipsId`) are consistent with string resources and nav_graph entries
+
+---
+
+## When to Use a Spec Instead
+
+If the case meets any of the following criteria, create a Spec rather than using this skill directly:
+
+1. Involves coordinated calls across two or more Agora API modules
+2. Requires a custom UI layout (not one of the standard Layout A/B/C/D templates above)
+3. Involves multi-channel or multi-engine instance management
+4. Requires a foreground Service or background thread coordination
+5. Involves developing new shared components (widgets/utils, etc.)
+6. Requires optional module integration (simpleFilter/streamEncrypt)
+
+If none apply → use this skill directly; no Spec needed.
+
+### Spec Requirements Document Must Include
+
+- List of APIs the case demonstrates
+- User interaction flow description
+- Expected RtcEngine lifecycle behavior
+- Required permissions list
+
+### Spec Design Document Must Include
+
+- Target project identifier: `APIExample`
+- Class/file structure design
+- API call sequence (Mermaid sequence diagram recommended)
+- State management approach
+- UI layout plan
+- Integration points with existing shared components
+- Case registration info: class name, display name, group (BASIC/ADVANCED), sort index — finalize during design to avoid conflicts
+- Generate `@Example` annotation parameters, `nav_graph.xml` action + destination, `strings.xml` key names (`item_` prefix)
+- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing indices
+- Risk identification and mitigation (API compatibility, performance, permissions, thread safety)
+
+### Spec Task List Integration
+
+- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters
+- Mark which sub-tasks require manual coding, and provide target file paths and change summaries
+- Tasks for creating new shared components must come before case implementation tasks
+
+---
+
+## NEVER
+
+- **NEVER** put the nav action inside `` — it belongs in ``. mainFragment only routes to Ready; all case actions live in Ready. Wrong placement causes silent navigation failure at runtime.
+- **NEVER** call `RtcEngine.destroy()` directly on the main thread — always `handler.post(RtcEngine::destroy)`. Direct call blocks the UI thread and causes ANR.
+- **NEVER** call `setLocalAccessPoint()` without null-checking `getPrivateCloudConfig()` first — it returns null on standard builds, causing NPE.
+- **NEVER** update UI directly inside `IRtcEngineEventHandler` callbacks — they run on a background thread. Always wrap with `runOnUIThread()`.
+- **NEVER** omit `setParameters(...)` — it's required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally.
diff --git a/Android/APIExample/AGENTS.md b/Android/APIExample/AGENTS.md
new file mode 100644
index 000000000..b5c9e8bd7
--- /dev/null
+++ b/Android/APIExample/AGENTS.md
@@ -0,0 +1,49 @@
+# AGENTS.md — APIExample
+
+Full demo project. Covers all Agora RTC APIs using Java/Kotlin + XML layouts.
+Default project for video, screen sharing, beauty, or extension demos.
+
+## Build Commands
+
+```bash
+./gradlew assembleDebug # build debug APK
+./gradlew installDebug # build + install to connected device
+./gradlew test # unit tests
+./gradlew connectedAndroidTest # instrumented tests (device required)
+```
+
+## App ID Configuration
+
+See [README.md — Obtain an App Id](README.md#obtain-an-app-id).
+
+## Optional Modules
+
+Controlled via `gradle.properties`:
+- `simpleFilter = true` — enables the C++ video extension module (`agora-simple-filter`). Requires OpenCV and Agora C++ SDK headers. See README for setup.
+- `streamEncrypt = true` — enables the custom stream encryption module (`agora-stream-encrypt`). Requires Agora C++ SDK headers. See README for setup.
+
+Both are `false` by default. Do not enable unless the feature explicitly requires it.
+
+## Architecture Red Lines
+
+- Do NOT add audio-only cases that require `voice-sdk` exclusivity — use `APIExample-Audio/` instead.
+- Do NOT use Jetpack Compose — this project is XML + ViewBinding only.
+- Each case Fragment must create and destroy its own `RtcEngine` instance.
+- Always call `engine.leaveChannel()` before `RtcEngine.destroy()` in `onDestroy()`.
+- Call `RtcEngine.destroy()` via `handler.post(RtcEngine::destroy)` — direct call blocks the main thread (ANR).
+- All `IRtcEngineEventHandler` callbacks run on a background thread — use `runOnUIThread()` for UI updates.
+- Always call `checkOrRequestPermission()` before `joinChannel()`.
+- `setParameters(...)` is required in every case for backend reporting — do not omit it.
+- Always null-check `getPrivateCloudConfig()` before calling `setLocalAccessPoint()` — returns null on non-private-cloud builds.
+
+## Skills
+
+| Skill | Path | Description |
+|-------|------|-------------|
+| upsert-case | `.agent/skills/upsert-case/` | Add a new case or modify an existing one |
+| query-cases | `.agent/skills/query-cases/` | Query and browse existing cases |
+| review-case | `.agent/skills/review-case/` | Review a case against project red lines |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, case registration internals, navigation details
diff --git a/Android/APIExample/ARCHITECTURE.md b/Android/APIExample/ARCHITECTURE.md
new file mode 100644
index 000000000..e541dfee6
--- /dev/null
+++ b/Android/APIExample/ARCHITECTURE.md
@@ -0,0 +1,215 @@
+# ARCHITECTURE.md — APIExample
+
+## Directory Layout
+
+```
+APIExample/
+├── gradle.properties # rtc_sdk_version, simpleFilter, streamEncrypt flags
+├── agora-simple-filter/ # Optional C++ video extension module
+├── agora-stream-encrypt/ # Optional custom stream encryption module
+└── app/src/main/
+ ├── AndroidManifest.xml
+ ├── assets/ # Audio/video sample files, beauty resources
+ ├── res/
+ │ ├── navigation/nav_graph.xml # Single nav graph — all case destinations live here
+ │ ├── values/strings.xml # All display names and tips strings
+ │ └── layout/ # XML layouts for each case Fragment
+ └── java/io/agora/api/example/
+ ├── MainApplication.java # Scans DEX and registers all @Example cases at startup
+ ├── MainActivity.java # Single-Activity host, owns NavController
+ ├── MainFragment.java # Home screen — renders BASIC / ADVANCED section list
+ ├── ReadyFragment.java # Splash / config check screen
+ ├── SettingActivity.java # Global settings (resolution, frame rate, area code)
+ │
+ ├── annotation/
+ │ └── Example.java # @Example annotation — the case registration contract
+ │
+ ├── common/
+ │ ├── BaseFragment.java # Base class ALL case Fragments must extend
+ │ ├── BaseVbFragment.java # ViewBinding variant of BaseFragment
+ │ ├── Constant.java # App-wide constants
+ │ ├── adapter/
+ │ │ └── SectionAdapter.java # RecyclerView adapter for the grouped case list
+ │ ├── model/
+ │ │ ├── Examples.java # Static registry: ITEM_MAP keyed by group name
+ │ │ ├── GlobalSettings.java # Video/audio config shared across cases
+ │ │ ├── ExampleBean.java
+ │ │ └── StatisticsInfo.java
+ │ ├── widget/
+ │ │ ├── VideoReportLayout.java # Video container with stats overlay
+ │ │ ├── AudioOnlyLayout.java # Audio-only seat layout
+ │ │ ├── AudioSeatManager.java
+ │ │ └── WaveformView.java
+ │ ├── floatwindow/ # Floating window helper for in-call overlay
+ │ └── gles/ # OpenGL ES helpers for custom video rendering
+ │
+ ├── examples/ # All cases live here — ClassUtils scans this package
+ │ ├── basic/ # group = "BASIC" (index 0–9)
+ │ │ ├── JoinChannelVideoByToken.java # [0] "Live Interactive Video Streaming(Token Verify)"
+ │ │ ├── JoinChannelVideo.java # [1] "Live Interactive Video Streaming"
+ │ │ └── JoinChannelAudio.java # [2] "Live Interactive Audio Streaming"
+ │ ├── advanced/ # group = "ADVANCED" (index 10+)
+ │ │ ├── LiveStreaming.java # [10] "RTC Live Streaming" — setClientRole, broadcaster/audience
+ │ │ ├── RTMPStreaming.java # [11] "Push Streams to CDN" — RTMP push streaming
+ │ │ ├── MediaMetadata.java # [12] "Media Metadata" — send/receive metadata in video stream
+ │ │ ├── VoiceEffects.java # [13] "Set the Voice Beautifier and Effects" — setVoiceBeautifierPreset
+ │ │ ├── customaudio/CustomAudioSource.java # [14] "Custom Audio Sources" — push external audio
+ │ │ ├── customaudio/CustomAudioRender.java # [15] "Custom Audio Render" — pull audio for custom rendering
+ │ │ ├── PushExternalVideoYUV.java # [16] "Custom Video Source" — push YUV external video
+ │ │ ├── CustomRemoteVideoRender.java # [17] "Custom Video Renderer" — custom remote video rendering
+ │ │ ├── ProcessAudioRawData.java # [18] "Raw Audio Data" — audio raw data processing
+ │ │ ├── MultiVideoSourceTracks.java # [19] "Multi Video Source Tracks" — multiple video sources
+ │ │ ├── ProcessRawData.java # [20] "Raw Video Data" — video raw data processing
+ │ │ ├── SimpleExtension.java # [21] "Simple Extension" — custom video extension
+ │ │ ├── PictureInPicture.java # [22] "Picture In Picture" — PiP mode
+ │ │ ├── FaceCapture.java # [23] "Face Capture" — face detection
+ │ │ ├── VideoQuickSwitch.java # [24] "Quick Switch Channel" — fast channel switching
+ │ │ ├── JoinMultipleChannel.java # [25] "Join Multiple Channel" — multi-channel join
+ │ │ ├── ChannelEncryption.java # [26] "Media Stream Encryption" — built-in encryption
+ │ │ ├── PlayAudioFiles.java # [27] "Play Audio Files" — audio mixing
+ │ │ ├── PreCallTest.java # [28] "Pre-call Tests" — network/device test before joining
+ │ │ ├── MediaPlayer.java # [29] "MediaPlayer" — play media files
+ │ │ ├── MediaRecorder.java # [30] "Local/Remote MediaRecorder" — record media streams
+ │ │ ├── ScreenSharing.java # [31] "Screen Sharing" — screen capture & share
+ │ │ ├── VideoProcessExtension.java # [32] "Video Process Extension" — video filter extension
+ │ │ ├── LocalVideoTranscoding.java # [33] "LocalVideoTranscoding" — local video compositing
+ │ │ ├── RhythmPlayer.java # [34] "Rhythm Player" — metronome/rhythm playback
+ │ │ ├── SendDataStream.java # [35] "Send Data Stream" — data channel messaging
+ │ │ ├── HostAcrossChannel.java # [36] "Relay Streams across Channels" — cross-channel relay
+ │ │ ├── SpatialSound.java # [37] "Spatial Audio" — 3D spatial audio
+ │ │ ├── ContentInspect.java # [38] "Content Inspect" — content moderation
+ │ │ ├── ThirdPartyBeauty.java # [39] "Third-party beauty" — third-party beauty SDK
+ │ │ ├── KtvCopyrightMusic.java # [40] "KTV Copyright Music" — licensed music
+ │ │ ├── TransparentRendering.java # [41] "TransparentRendering" — alpha channel rendering
+ │ │ ├── UrlLiveStream.java # [42] "Ultra Live Streaming with Url" — URL-based live stream
+ │ │ ├── AgoraBeauty.java # [43] "Agora beauty 2.0" — built-in beauty effects
+ │ │ ├── Simulcast.java # [44] "Simulcast" — multi-quality stream publishing
+ │ │ ├── Multipath.java # [45] "Multipath" — multi-path transmission
+ │ │ ├── beauty/ # Third-party beauty integrations
+ │ │ └── videoRender/ # Custom video rendering helpers
+ │ └── audio/ # Audio-specific cases (grouped as ADVANCED)
+ │ ├── AudioWaveform.java # [5] "Audio Waveform" — audio visualization
+ │ ├── AudioRouterPlayer.java # [6] "AudioRouter(Third Party Player)" — third-party audio routing
+ │ └── AudioRouterPlayer*.java # Exo / Ijk / Native variants
+ │
+ ├── service/
+ │ └── MediaProjectionService.java # Foreground service required for screen sharing
+ │
+ └── utils/
+ ├── ClassUtils.java # DEX scanner — auto-discovers @Example classes
+ ├── TokenUtils.java # Fetches RTC tokens from Agora token server
+ ├── PermissonUtils.java # Permission check/request helpers
+ ├── CommonUtil.java
+ ├── ErrorUtil.java
+ ├── FileUtils.java
+ ├── FileKtUtils.kt
+ ├── AudioFileReader.java
+ ├── VideoFileReader.java
+ └── YUVUtils.java
+```
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| Live Interactive Video Streaming(Token Verify) | `basic/JoinChannelVideoByToken.java` | `RtcEngine.create()`, `joinChannel()`, `setupLocalVideo()`, `enableVideo()`, `setVideoEncoderConfiguration()` | Demonstrates one-to-one video calling with manual App ID and token input |
+| Live Interactive Video Streaming | `basic/JoinChannelVideo.java` | `RtcEngine.create()`, `joinChannel()`, `setupLocalVideo()`, `enableVideo()`, `setVideoEncoderConfiguration()` | Demonstrates basic one-to-one video calling with auto-generated token |
+| Live Interactive Audio Streaming | `basic/JoinChannelAudio.java` | `RtcEngine.create()`, `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `muteLocalAudioStream()`, `enableInEarMonitoring()`, `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()` | Demonstrates audio-only calling with volume controls, in-ear monitoring, and audio routing |
+| RTC Live Streaming | `advanced/LiveStreaming.java` | `setClientRole()`, `enableDualStreamMode()`, `startPreview()`, `preloadChannel()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()`, `addVideoWatermark()`, `setRemoteDefaultVideoStreamType()`, `takeSnapshot()`, `enableVideoImageSource()` | Demonstrates broadcaster/audience role switching with dual-stream, watermark, and snapshot features |
+| Streaming from RTC to CDN | `advanced/RTMPStreaming.java` | `startRtmpStreamWithTranscoding()`, `startRtmpStreamWithoutTranscoding()`, `stopRtmpStream()`, `updateRtmpTranscoding()` | Demonstrates pushing media streams from RTC to a CDN via RTMP |
+| Media Metadata | `advanced/MediaMetadata.java` | `registerMediaMetadataObserver()`, `sendAudioMetadata()` | Demonstrates sending and receiving metadata alongside video streams |
+| Set the Voice Beautifier and Effects | `advanced/VoiceEffects.java` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAudioEffectParameters()`, `setLocalVoicePitch()`, `setLocalVoiceEqualization()`, `setLocalVoiceReverb()`, `setLocalVoiceFormant()`, `setAINSMode()`, `enableVoiceAITuner()` | Demonstrates voice beautifier presets, audio effects, voice conversion, and AI noise suppression |
+| Custom Audio Sources | `advanced/customaudio/CustomAudioSource.java` | `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `enableCustomAudioLocalPlayback()`, `destroyCustomAudioTrack()` | Demonstrates pushing external audio frames via a custom audio track |
+| Custom Audio Render | `advanced/customaudio/CustomAudioRender.java` | `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Demonstrates pulling audio frames for custom audio rendering |
+| Custom Video Source | `advanced/PushExternalVideoYUV.java` | `setExternalVideoSource()`, `pushExternalVideoFrame()`, `setExternalRemoteEglContext()` | Demonstrates pushing external YUV video frames as a custom video source |
+| Custom Video Renderer | `advanced/CustomRemoteVideoRender.java` | `registerVideoFrameObserver()`, `setExternalRemoteEglContext()` | Demonstrates custom rendering of remote video streams via video frame observer |
+| Raw Audio Data | `advanced/ProcessAudioRawData.java` | `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Demonstrates processing raw audio data through the audio frame observer |
+| Multi Video Source Tracks | `advanced/MultiVideoSourceTracks.java` | `createCustomVideoTrack()`, `pushExternalVideoFrameById()`, `joinChannelEx()`, `destroyCustomVideoTrack()`, `createCustomEncodedVideoTrack()`, `pushExternalEncodedVideoFrameById()` | Demonstrates publishing multiple custom video tracks simultaneously |
+| Raw Video Data | `advanced/ProcessRawData.java` | `registerVideoFrameObserver()`, `startPreview()` | Demonstrates processing raw video data through the video frame observer |
+| Simple Extension | `advanced/SimpleExtension.java` | `enableExtension()`, `setExtensionProperty()`, `enableAudioVolumeIndication()` | Demonstrates loading and configuring a custom audio/video extension |
+| Picture In Picture | `advanced/PictureInPicture.java` | `joinChannel()`, `setupLocalVideo()`, `enableVideo()` | Demonstrates Android Picture-in-Picture mode during a video call |
+| Face Capture | `advanced/FaceCapture.java` | `enableExtension()`, `setExtensionProperty()`, `registerVideoFrameObserver()`, `registerFaceInfoObserver()` | Demonstrates face capture and lip-sync driven video using extensions |
+| Quick Switch Channel | `advanced/VideoQuickSwitch.java` | `joinChannel()`, `leaveChannel()`, `startPreview()`, `setClientRole()` | Demonstrates fast channel switching for audience members |
+| Join Multiple Channel | `advanced/JoinMultipleChannel.java` | `joinChannel()`, `joinChannelEx()`, `leaveChannelEx()`, `startPreview()`, `takeSnapshotEx()` | Demonstrates joining two channels simultaneously using RtcEngineEx |
+| Media Stream Encryption | `advanced/ChannelEncryption.java` | `enableEncryption()` | Demonstrates built-in media stream encryption |
+| Play Audio Files | `advanced/PlayAudioFiles.java` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()`, `getAudioEffectManager()`, `adjustAudioMixingVolume()` | Demonstrates audio mixing and sound effect playback |
+| Pre-call Tests | `advanced/PreCallTest.java` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()` | Demonstrates network quality probing and echo testing before joining a channel |
+| MediaPlayer | `advanced/MediaPlayer.java` | `createMediaPlayer()`, `mediaPlayer.open()`, `mediaPlayer.play()`, `mediaPlayer.stop()`, `mediaPlayer.pause()`, `mediaPlayer.seek()`, `updateChannelMediaOptions()` | Demonstrates playing media files with the built-in media player |
+| Local/Remote MediaRecorder | `advanced/MediaRecorder.java` | `createMediaRecorder()`, `destroyMediaRecorder()`, `startRecordingDeviceTest()` | Demonstrates recording local and remote media streams |
+| Scree Sharing | `advanced/ScreenSharing.java` | `startScreenCapture()`, `stopScreenCapture()`, `updateScreenCaptureParameters()`, `setScreenCaptureScenario()` | Demonstrates screen capture and sharing during a video call |
+| Video Enhancement | `advanced/VideoProcessExtension.java` | `setBeautyEffectOptions()`, `setFilterEffectOptions()`, `setLowlightEnhanceOptions()`, `setVideoDenoiserOptions()`, `setColorEnhanceOptions()`, `enableVirtualBackground()`, `setFaceShapeBeautyOptions()`, `setFaceShapeAreaOptions()` | Demonstrates built-in video enhancement including beauty, filter, denoising, and virtual background |
+| LocalVideoTranscoding | `advanced/LocalVideoTranscoding.java` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()`, `stopScreenCapture()`, `enableVirtualBackground()` | Demonstrates compositing multiple local video sources into a single stream |
+| Rhythm Player | `advanced/RhythmPlayer.java` | `startRhythmPlayer()`, `stopRhythmPlayer()`, `enableAudioVolumeIndication()` | Demonstrates metronome/rhythm playback synchronized with audio streaming |
+| Send Data Stream | `advanced/SendDataStream.java` | `createDataStream()`, `sendStreamMessage()` | Demonstrates sending and receiving data channel messages |
+| Relay Streams across Channels | `advanced/HostAcrossChannel.java` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Demonstrates relaying media streams from one channel to another |
+| Spatial Audio | `advanced/SpatialSound.java` | `enableAudio()`, `setRemoteUserSpatialAudioParams()`, `createMediaPlayer()` | Demonstrates 3D spatial audio positioning for remote users |
+| Content Inspect | `advanced/ContentInspect.java` | `enableContentInspect()` | Demonstrates real-time content moderation on video streams |
+| Third-party beauty | `advanced/ThirdPartyBeauty.java` | `registerVideoFrameObserver()` | Demonstrates integration with third-party beauty SDKs (e.g. FaceUnity) |
+| KTV Copyright Music | `advanced/KtvCopyrightMusic.java` | N/A (browser-based documentation link) | Demonstrates the KTV copyright music feature via documentation reference |
+| TransparentRendering | `advanced/TransparentRendering.java` | `setExternalVideoSource()`, `pushExternalVideoFrame()`, `createMediaPlayer()`, `startPreview()` | Demonstrates alpha-channel transparent video rendering |
+| Ultra Live Streaming with Url | `advanced/UrlLiveStream.java` | `Rte()`, `Player()`, `Canvas()`, `player.openWithUrl()`, `player.stop()` | Demonstrates ultra-low-latency live streaming playback via URL using the RTE SDK |
+| Agora beauty 2.0 | `advanced/AgoraBeauty.java` | `enableVirtualBackground()`, `setFaceShapeAreaOptions()` | Demonstrates built-in Agora beauty effects with face shaping and virtual background |
+| Simulcast | `advanced/Simulcast.java` | `setSimulcastConfig()`, `setRemoteVideoStreamType()` | Demonstrates publishing multiple quality streams with simulcast |
+| Multipath | `advanced/Multipath.java` | `joinChannel()`, `updateChannelMediaOptions()` | Demonstrates multi-path transmission for improved network reliability |
+| Audio Waveform | `audio/AudioWaveform.java` | `enableAudio()`, `enableAudioVolumeIndication()` | Demonstrates real-time audio waveform visualization |
+| AudioRouter(Third Party Player) | `audio/AudioRouterPlayer.java` | `setEnableSpeakerphone()`, `joinChannel()` | Demonstrates audio routing with third-party media players (ExoPlayer, IjkPlayer, Native) |
+
+## Case Registration Mechanism
+
+Registration is **automatic via reflection**. No manual list to maintain.
+
+**Startup flow:**
+1. `MainApplication.onCreate()` calls `ClassUtils.getFileNameByPackageName(context, "io.agora.api.example.examples")`.
+2. `ClassUtils` scans all DEX entries whose class name starts with that prefix.
+3. For each class, it checks for `@Example` annotation and calls `Examples.addItem(annotation)`.
+4. `Examples.sortItem()` sorts each group by `index`.
+5. `MainFragment` reads `Examples.ITEM_MAP` and renders the list.
+
+**`@Example` annotation — all four fields are required:**
+```java
+@Example(
+ index = 2, // sort order within the group; BASIC: 0–9, ADVANCED: 10+
+ group = BASIC, // "BASIC" or "ADVANCED"
+ name = R.string.item_my_case, // display name string resource
+ actionId = R.id.action_mainFragment_to_myCase, // nav action ID in nav_graph.xml
+ tipsId = R.string.my_case_tips // description string resource
+)
+public class MyCase extends BaseFragment { … }
+```
+
+A missing or malformed annotation causes the case to silently not appear — no crash.
+
+## Navigation
+
+Single `nav_graph.xml` with Jetpack Navigation Component.
+
+Every case needs:
+- A `` destination entry under the root `` in `nav_graph.xml`
+- An `` inside `` — **not** `mainFragment`; `mainFragment` has only one action pointing to `Ready`, all case actions live in `Ready`
+- The action `id` must exactly match `actionId` in `@Example`
+
+`MainActivity` calls `Navigation.findNavController(...).navigate(example.actionId())` on list item tap.
+
+## RtcEngine Lifecycle
+
+```
+onActivityCreated → RtcEngine.create()
+ → engine.setParameters / setVideoEncoderConfiguration
+ → joinChannel() (after permission granted)
+ ↓
+ [IRtcEngineEventHandler callbacks — background thread]
+ ↓
+onDestroy → engine.leaveChannel()
+ → RtcEngine.destroy()
+ → engine = null
+```
+
+## Token Flow
+
+```java
+TokenUtils.gen(requireContext(), channelId, uid, token -> {
+ engine.joinChannel(token, channelId, uid, options);
+});
+```
+
+`TokenUtils` reads `AGORA_APP_ID` and `AGORA_APP_CERT` from `local.properties` via `BuildConfig`. If `AGORA_APP_CERT` is empty, token generation is skipped — valid for projects without certificate.
diff --git a/Android/APIExample/CLAUDE.md b/Android/APIExample/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/Android/APIExample/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/Android/APIExample/README.md b/Android/APIExample/README.md
index ee36e5f0c..fb949216c 100644
--- a/Android/APIExample/README.md
+++ b/Android/APIExample/README.md
@@ -23,16 +23,16 @@ To build and run the sample application, get an App Id:
3. Save the **App Id** from the Dashboard for later use.
4. Save the **App Certificate** from the Dashboard for later use.
-5. Open `Android/APIExample` and edit the `app/src/main/res/values/string-configs.xml` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard. Note you can leave the certificate variable `null` if your project has not turned on security token.
+5. Open `Android/APIExample` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well.
```
- // Agora APP ID.
- YOUR APP ID
- // Agora APP Certificate. If the project does not have certificates enabled, leave this field blank.
- // PS:It is unsafe to place the App Certificate on the client side, it is recommended to place it on the server side to ensure that the App Certificate is not leaked.
- YOUR APP CERTIFICATE
+ sdk.dir=/path/to/Android/sdk
+ AGORA_APP_ID=YOUR APP ID
+ AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the `ByToken` examples to paste the token at runtime.
+
You are all set. Now connect your Android device and run the project.
### Beauty Configuration
@@ -68,22 +68,6 @@ follows:
| sticker resource(e.g. fashi.bundle) | app/src/main/assets/beauty_faceunity/sticker |
| authpack.java | app/src/main/java/io/agora/api/example/examples/advanced/beauty/authpack.java |
-#### ByteDance
-
-1. Contact ByteDance customer service to obtain the download link and certificate of the beauty sdk
-2. Unzip the ByteDance beauty resource and copy the following files/directories to the corresponding path
-
-| ByteDance Beauty Resources | Location |
-|---------------------------------|--------------------------------------|
-| resource/LicenseBag.bundle | app/src/main/assets/beauty_bytedance |
-| resource/ModelResource.bundle | app/src/main/assets/beauty_bytedance |
-| resource/ComposeMakeup.bundle | app/src/main/assets/beauty_bytedance |
-| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance |
-| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance |
-
-3. Modify the LICENSE_NAME in the app/src/main/java/io/agora/api/example/examples/advanced/beauty/ByteDanceBeauty.java file to the name of the applied certificate file.
-
-
### For Agora Extension Developers
Since version 4.0.0, Agora SDK provides an Extension Interface Framework. Developers could publish their own video/audio extension to Agora Extension Market. In this project includes a sample SimpleFilter example, by default it is disabled.
diff --git a/Android/APIExample/README.zh.md b/Android/APIExample/README.zh.md
index 17d1953b2..1f804ea25 100644
--- a/Android/APIExample/README.zh.md
+++ b/Android/APIExample/README.zh.md
@@ -23,16 +23,16 @@
3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它
4. 复制后台的 **App 证书** 并备注,稍后启动应用时会用到它
-5. 打开 `Android/APIExample` 并编辑 `app/src/main/res/values/string-configs.xml`,将你的 AppID 、App主证书 分别替换到 `Your App Id` 和 `YOUR APP CERTIFICATE`
+5. 打开 `Android/APIExample` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE`
```
- // 声网APP ID。
- YOUR APP ID
- // 声网APP证书。如果项目没有开启证书鉴权,这个字段留空。
- // 注意:App证书放在客户端不安全,推荐放在服务端以确保 App 证书不会泄露。
- YOUR APP CERTIFICATE
+ sdk.dir=/path/to/Android/sdk
+ AGORA_APP_ID=YOUR APP ID
+ AGORA_APP_CERT=YOUR APP CERTIFICATE
```
+`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 `ByToken` 系列示例在运行时粘贴 token。
+
然后你就可以编译并运行项目了。
### 美颜配置
@@ -65,22 +65,6 @@
| 贴纸资源(如fashi.bundle) | app/src/main/assets/beauty_faceunity/sticker |
| 证书authpack.java | app/src/main/java/io/agora/api/example/examples/advanced/beauty/authpack.java |
-#### 字节美颜
-
-1. 联系字节客服获取美颜sdk下载链接以及证书
-2. 解压字节/火山美颜资源并复制以下文件/目录到对应路径下
-
-| 字节SDK文件/目录 | 项目路径 |
-|--------------------------------------------------|-------------------------------------------------------|
-| resource/LicenseBag.bundle | app/src/main/assets/beauty_bytedance |
-| resource/ModelResource.bundle | app/src/main/assets/beauty_bytedance |
-| resource/ComposeMakeup.bundle | app/src/main/assets/beauty_bytedance |
-| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance |
-| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance |
-
-3.
-修改app/src/main/java/io/agora/api/example/examples/advanced/beauty/ByteDanceBeauty.java文件里LICENSE_NAME为申请到的证书文件名
-
### 对于Agora Extension开发者
从4.0.0SDK开始,Agora SDK支持插件系统和开放的云市场帮助开发者发布自己的音视频插件,本项目包含了一个SimpleFilter示例,默认是禁用的状态,如果需要开启编译和使用需要完成以下步骤:
diff --git a/Android/APIExample/app/build.gradle b/Android/APIExample/app/build.gradle
index f12013ca8..adffb056b 100644
--- a/Android/APIExample/app/build.gradle
+++ b/Android/APIExample/app/build.gradle
@@ -12,6 +12,18 @@ sdkVersionFile.withInputStream { stream ->
def agoraSdkVersion = properties.getProperty("rtc_sdk_version")
println("${rootProject.project.name} agoraSdkVersion: ${agoraSdkVersion}")
def localSdkPath= "${rootProject.projectDir.absolutePath}/../../sdk"
+def localPropertiesFile = rootProject.file("local.properties")
+def localProperties = new Properties()
+if (localPropertiesFile.exists()) {
+ localPropertiesFile.withInputStream { stream ->
+ localProperties.load(stream)
+ }
+}
+def agoraAppId = localProperties.getProperty("AGORA_APP_ID", "")
+if (agoraAppId.isEmpty()) {
+ throw new GradleException("Please configure correctly in the local.properties file in the project root directory: AGORA_APP_ID=")
+}
+def agoraAppCert = localProperties.getProperty("AGORA_APP_CERT", "")
android {
@@ -26,6 +38,8 @@ android {
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk.abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86-64'
+ buildConfigField "String", "AGORA_APP_ID", "\"${agoraAppId}\""
+ buildConfigField "String", "AGORA_APP_CERT", "\"${agoraAppCert}\""
}
signingConfigs {
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseFragment.java b/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseFragment.java
index 6d0c899ed..029ef5e60 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseFragment.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseFragment.java
@@ -20,6 +20,7 @@
import java.util.Map;
+import io.agora.api.example.utils.AgoraConfig;
import io.agora.api.example.utils.PermissonUtils;
/**
@@ -153,6 +154,14 @@ protected final void showShortToast(final String msg) {
});
}
+ protected final String getAgoraAppId() {
+ return AgoraConfig.getAppId();
+ }
+
+ protected final String getAgoraAppCertificate() {
+ return AgoraConfig.getAppCertificate();
+ }
+
/**
* Run on ui thread.
*
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseVbFragment.java b/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseVbFragment.java
index efba51360..9fed308de 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseVbFragment.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/common/BaseVbFragment.java
@@ -11,7 +11,6 @@
import androidx.viewbinding.ViewBinding;
import io.agora.api.example.MainApplication;
-import io.agora.api.example.R;
import io.agora.rtc2.Constants;
import io.agora.rtc2.IRtcEngineEventHandler;
import io.agora.rtc2.RtcEngine;
@@ -62,7 +61,7 @@ protected RtcEngine initRtcEngine(IRtcEngineEventHandler engineEventHandler) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/AgoraBeauty.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/AgoraBeauty.java
index 99c40b66e..df2305953 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/AgoraBeauty.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/AgoraBeauty.java
@@ -288,7 +288,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -394,7 +394,7 @@ private void joinChannel(String channelId) {
VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/AudienceFragment.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/AudienceFragment.java
deleted file mode 100644
index 96bbd8d8b..000000000
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/AudienceFragment.java
+++ /dev/null
@@ -1,593 +0,0 @@
-package io.agora.api.example.examples.advanced.CDNStreaming;
-
-import static io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER;
-import static io.agora.rtc2.Constants.RENDER_MODE_HIDDEN;
-import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.SurfaceView;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.CompoundButton;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.Spinner;
-import android.widget.Switch;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-import io.agora.api.example.MainApplication;
-import io.agora.api.example.R;
-import io.agora.api.example.common.BaseFragment;
-import io.agora.mediaplayer.IMediaPlayer;
-import io.agora.mediaplayer.IMediaPlayerObserver;
-import io.agora.mediaplayer.data.CacheStatistics;
-import io.agora.mediaplayer.data.PlayerPlaybackStats;
-import io.agora.mediaplayer.data.PlayerUpdatedInfo;
-import io.agora.mediaplayer.data.SrcInfo;
-import io.agora.rtc2.ChannelMediaOptions;
-import io.agora.rtc2.Constants;
-import io.agora.rtc2.IRtcEngineEventHandler;
-import io.agora.rtc2.RtcEngine;
-import io.agora.rtc2.RtcEngineConfig;
-import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
-import io.agora.rtc2.video.VideoCanvas;
-import io.agora.rtc2.video.VideoEncoderConfiguration;
-
-/**
- * The type Audience fragment.
- */
-public class AudienceFragment extends BaseFragment implements IMediaPlayerObserver {
- private static final String TAG = AudienceFragment.class.getSimpleName();
- private static final String AGORA_CHANNEL_PREFIX = "rtmp://pull.webdemo.agoraio.cn/lbhd/";
- private boolean isAgoraChannel = true;
- private boolean rtcStreaming = false;
- private String channel;
- private FrameLayout fl_local, fl_remote, fl_remote_2, fl_remote_3;
- private Map remoteViews = new ConcurrentHashMap();
- private LinearLayout rtc_control, video_row2, channel_control, vol_control;
- private RtcEngine engine;
- private IMediaPlayer mediaPlayer;
- private SeekBar volSeekBar;
- private Switch rtcSwitcher;
- private Spinner channelSpinner;
- private AlertDialog mPlayerFailDialog;
- private AlertDialog mPlayerCompletedDialog;
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.fragment_cdn_audience, container, false);
- Bundle bundle = this.getArguments();
- isAgoraChannel = bundle.getBoolean(getString(R.string.key_is_agora_channel));
- channel = bundle.getString(getString(R.string.key_channel_name));
- return view;
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- fl_local = view.findViewById(R.id.fl_local);
- fl_remote = view.findViewById(R.id.fl_remote);
- fl_remote_2 = view.findViewById(R.id.fl_remote2);
- fl_remote_3 = view.findViewById(R.id.fl_remote3);
- channel_control = view.findViewById(R.id.channel_ctrl);
- channel_control.setVisibility(isAgoraChannel ? View.VISIBLE : View.INVISIBLE);
- rtc_control = view.findViewById(R.id.rtc_ctrl);
- rtc_control.setVisibility(isAgoraChannel ? View.VISIBLE : View.INVISIBLE);
- vol_control = view.findViewById(R.id.vol_bar);
- vol_control.setVisibility(View.INVISIBLE);
- video_row2 = view.findViewById(R.id.video_container_row2);
- rtcSwitcher = view.findViewById(R.id.rtc_switch);
- rtcSwitcher.setOnCheckedChangeListener(checkedChangeListener);
- volSeekBar = view.findViewById(R.id.record_vol);
- volSeekBar.setOnSeekBarChangeListener(seekBarChangeListener);
- channelSpinner = view.findViewById(R.id.channels_spinner);
- channelSpinner.setOnItemSelectedListener(itemSelectedListener);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
- try {
- RtcEngineConfig config = new RtcEngineConfig();
- /*
- * The context of Android Activity
- */
- config.mContext = context.getApplicationContext();
- /*
- * The App ID issued to you by Agora. See How to get the App ID
- */
- config.mAppId = getString(R.string.agora_app_id);
- /* Sets the channel profile of the Agora RtcEngine.
- CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
- Use this profile in one-on-one calls or group calls, where all users can talk freely.
- CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast
- channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams;
- an audience can only receive streams.*/
- config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
- /*
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- config.mEventHandler = iRtcEngineEventHandler;
- config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode();
- engine = RtcEngine.create(config);
- /*
- * This parameter is for reporting the usages of APIExample to agora background.
- * Generally, it is not necessary for you to set this parameter.
- */
- engine.setParameters("{"
- + "\"rtc.report_app_scenario\":"
- + "{"
- + "\"appScenario\":" + 100 + ","
- + "\"serviceType\":" + 11 + ","
- + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
- + "}"
- + "}");
- // Setup video encoding configs
- engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration(
- ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject(),
- VideoEncoderConfiguration.FRAME_RATE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingFrameRate()),
- STANDARD_BITRATE,
- VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
- ));
- /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/
- LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig();
- if (localAccessPointConfiguration != null) {
- // This api can only be used in the private media server scenario, otherwise some problems may occur.
- engine.setLocalAccessPoint(localAccessPointConfiguration);
- }
- engine.enableVideo();
- //prepare media player
- mediaPlayer = engine.createMediaPlayer();
- mediaPlayer.registerPlayerObserver(this);
- SurfaceView surfaceView = new SurfaceView(this.getActivity());
- surfaceView.setZOrderMediaOverlay(false);
- if (fl_local.getChildCount() > 0) {
- fl_local.removeAllViews();
- }
- fl_local.addView(surfaceView);
- // Setup local video to render your local media player view
- VideoCanvas videoCanvas = new VideoCanvas(surfaceView, Constants.RENDER_MODE_HIDDEN, 0);
- videoCanvas.sourceType = Constants.VIDEO_SOURCE_MEDIA_PLAYER;
- videoCanvas.mediaPlayerId = mediaPlayer.getMediaPlayerId();
- engine.setupLocalVideo(videoCanvas);
- // Your have to call startPreview to see player video
- engine.startPreview();
- // Set audio route to microPhone
- engine.setDefaultAudioRoutetoSpeakerphone(true);
- openPlayerWithUrl();
- } catch (Exception e) {
- e.printStackTrace();
- getActivity().onBackPressed();
- }
- }
-
- private void openPlayerWithUrl() {
- if (isAgoraChannel) {
- mediaPlayer.openWithAgoraCDNSrc(getUrl(), 0);
- } else {
- mediaPlayer.open(getUrl(), 0);
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- if (rtcStreaming) {
- engine.leaveChannel();
- }
- mediaPlayer.stop();
- /*leaveChannel and Destroy the RtcEngine instance*/
- engine.stopPreview();
- handler.post(RtcEngine::destroy);
- engine = null;
- }
-
-
- /**
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
-
- /**
- * Error code description can be found at:
- * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- */
- @Override
- public void onError(int err) {
- Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err)));
- }
-
- /**Occurs when a user leaves the channel.
- * @param stats With this callback, the application retrieves the channel information,
- * such as the call duration and statistics.*/
- @Override
- public void onLeaveChannel(RtcStats stats) {
- super.onLeaveChannel(stats);
- }
-
- /**Occurs when the local user joins a specified channel.
- * The channel name assignment is based on channelName specified in the joinChannel method.
- * If the uid is not specified when joinChannel is called, the server automatically assigns a uid.
- * @param channel Channel name
- * @param uid User ID
- * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/
- @Override
- public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
- Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- handler.post(new Runnable() {
- @Override
- public void run() {
- vol_control.setVisibility(View.VISIBLE);
- volSeekBar.setProgress(100);
- }
- });
- }
-
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel.
- * @param uid ID of the user whose audio state changes.
- * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole
- * until this callback is triggered.*/
- @Override
- public void onUserJoined(int uid, int elapsed) {
- super.onUserJoined(uid, elapsed);
- Log.i(TAG, "onUserJoined->" + uid);
- showLongToast(String.format("user %d joined!", uid));
- /*Check if the context is correct*/
- Context context = getContext();
- if (context == null) {
- return;
- }
- if (remoteViews.containsKey(uid)) {
- return;
- } else {
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Display remote video stream*/
- SurfaceView surfaceView = null;
- // Create render view by RtcEngine
- surfaceView = new SurfaceView(context);
- surfaceView.setZOrderMediaOverlay(true);
- surfaceView.setZOrderOnTop(true);
- ViewGroup view = getAvailableView();
- remoteViews.put(uid, view);
- // Add to the remote container
- view.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup remote video to render
- engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid));
- }
- });
- }
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel.
- * @param uid ID of the user whose audio state changes.
- * @param reason Reason why the user goes offline:
- * USER_OFFLINE_QUIT(0): The user left the current channel.
- * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data
- * packet was received within a certain period of time. If a user quits the
- * call and the message is not passed to the SDK (due to an unreliable channel),
- * the SDK assumes the user dropped offline.
- * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from
- * the host to the audience.*/
- @Override
- public void onUserOffline(int uid, int reason) {
- Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason));
- showLongToast(String.format("user %d offline! reason:%d", uid, reason));
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Clear render view
- Note: The video will stay at its last frame, to completely remove it you will need to
- remove the SurfaceView from its parent*/
- engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid));
- remoteViews.get(uid).removeAllViews();
- remoteViews.remove(uid);
- }
- });
- }
-
- @Override
- public void onRtmpStreamingStateChanged(String url, int state, int errCode) {
- super.onRtmpStreamingStateChanged(url, state, errCode);
- showLongToast(String.format("onRtmpStreamingStateChanged state %s errCode %s", state, errCode));
-
- }
- };
-
- private final SeekBar.OnSeekBarChangeListener seekBarChangeListener = new SeekBar.OnSeekBarChangeListener() {
-
- @Override
- public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
- engine.adjustRecordingSignalVolume(i);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
-
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
-
- }
- };
-
- private final CompoundButton.OnCheckedChangeListener checkedChangeListener = new CompoundButton.OnCheckedChangeListener() {
- @Override
- public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
- rtcStreaming = b;
- if (rtcStreaming) {
- ChannelMediaOptions channelMediaOptions = new ChannelMediaOptions();
- channelMediaOptions.publishMicrophoneTrack = true;
- channelMediaOptions.publishCameraTrack = true;
- channelMediaOptions.clientRoleType = CLIENT_ROLE_BROADCASTER;
- int ret = engine.joinChannel(null, channel, 0, channelMediaOptions);
- if (ret != 0) {
- showLongToast(String.format("Join Channel call failed! reason:%d", ret));
- }
- } else {
- remoteViews.clear();
- engine.leaveChannel();
- vol_control.setVisibility(View.INVISIBLE);
- }
- handler.post(new Runnable() {
- @Override
- public void run() {
- toggleVideoLayout(rtcStreaming);
- }
- });
- }
- };
-
- private void toggleVideoLayout(boolean isMultiple) {
- if (isMultiple) {
- fl_remote.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- fl_remote_2.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- fl_remote_3.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- video_row2.setLayoutParams(new LinearLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 0, 1));
- // Create render view by RtcEngine
- SurfaceView surfaceView = new SurfaceView(getContext());
- if (fl_local.getChildCount() > 0) {
- fl_local.removeAllViews();
- }
- // Add to the local container
- fl_local.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup local video to render your local camera preview
- engine.setupLocalVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, 0));
- } else {
- fl_remote.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- fl_remote_2.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- fl_remote_3.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- video_row2.setLayoutParams(new LinearLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 0, 0));
- fl_remote.removeAllViews();
- fl_remote_2.removeAllViews();
- fl_remote_3.removeAllViews();
- SurfaceView surfaceView = new SurfaceView(getContext());
- surfaceView.setZOrderMediaOverlay(false);
- if (fl_local.getChildCount() > 0) {
- fl_local.removeAllViews();
- }
- fl_local.addView(surfaceView);
- // Setup local video to render your local media player view
- VideoCanvas videoCanvas = new VideoCanvas(surfaceView, Constants.RENDER_MODE_HIDDEN, 0);
- videoCanvas.sourceType = Constants.VIDEO_SOURCE_MEDIA_PLAYER;
- videoCanvas.mediaPlayerId = mediaPlayer.getMediaPlayerId();
- engine.setupLocalVideo(videoCanvas);
- }
- engine.startPreview();
- }
-
- private ViewGroup getAvailableView() {
- if (fl_remote.getChildCount() == 0) {
- return fl_remote;
- } else if (fl_remote_2.getChildCount() == 0) {
- return fl_remote_2;
- } else if (fl_remote_3.getChildCount() == 0) {
- return fl_remote_3;
- } else {
- return fl_remote;
- }
- }
-
- private String getUrl() {
- if (isAgoraChannel) {
- return AGORA_CHANNEL_PREFIX + channel;
- } else {
- return channel;
- }
- }
-
- @Override
- public void onPlayerStateChanged(io.agora.mediaplayer.Constants.MediaPlayerState state, io.agora.mediaplayer.Constants.MediaPlayerReason reason) {
- showShortToast("player state change to " + state.name());
- handler.post(new Runnable() {
- @Override
- public void run() {
- switch (state) {
- case PLAYER_STATE_FAILED:
- mediaPlayer.stop();
- //showLongToast(String.format("media player error: %s", mediaPlayerError.name()));
- if (mPlayerFailDialog == null) {
- mPlayerFailDialog = new AlertDialog.Builder(requireContext())
- .setTitle(R.string.tip)
- .setCancelable(false)
- .setNegativeButton(R.string.cancel, (dialog, which) -> {
- dialog.dismiss();
- onBackPressed();
- })
- .setPositiveButton(R.string.confirm, (dialog, which) -> openPlayerWithUrl())
- .create();
- }
- mPlayerFailDialog.setMessage(getString(R.string.media_player_error, reason.name()) + "\n\n" + getString(R.string.reopen_url_again));
- mPlayerFailDialog.show();
- break;
- case PLAYER_STATE_OPEN_COMPLETED:
- mediaPlayer.play();
- if (isAgoraChannel) {
- loadAgoraChannels();
- }
- rtcSwitcher.setEnabled(true);
- if (mPlayerFailDialog != null) {
- mPlayerFailDialog.dismiss();
- }
- break;
- case PLAYER_STATE_PLAYBACK_COMPLETED:
- if (mPlayerCompletedDialog == null) {
- mPlayerCompletedDialog = new AlertDialog.Builder(requireContext())
- .setTitle(R.string.tip)
- .setMessage(getString(R.string.media_player_complete) + "\n\n" + getString(R.string.reopen_url_again))
- .setNegativeButton(R.string.cancel, (dialog, which) -> {
- dialog.dismiss();
- onBackPressed();
- })
- .setCancelable(false)
- .setPositiveButton(R.string.confirm, (dialog, which) -> {
- mediaPlayer.stop();
- openPlayerWithUrl();
- })
- .create();
- }
- mPlayerCompletedDialog.show();
- break;
- case PLAYER_STATE_STOPPED:
- default:
- break;
- }
- }
- });
- }
-
- private void loadAgoraChannels() {
- int count = mediaPlayer.getAgoraCDNLineCount();
- ArrayAdapter arrayAdapter = new ArrayAdapter(getContext(), android.R.layout.simple_spinner_dropdown_item, getChannelArray(count));
- channelSpinner.setAdapter(arrayAdapter);
- }
-
- private List getChannelArray(int count) {
- List list = new ArrayList<>();
- for (int i = 0; i < count; i++) {
- list.add("Channel" + (i + 1));
- }
- return list;
- }
-
- @Override
- public void onPositionChanged(long positionMs, long timestampMs) {
-
- }
-
- @Override
- public void onPlayerEvent(io.agora.mediaplayer.Constants.MediaPlayerEvent mediaPlayerEvent, long l, String s) {
- Log.i(TAG, "onPlayerEvent " + mediaPlayerEvent.name());
- handler.post(new Runnable() {
- @Override
- public void run() {
- switch (mediaPlayerEvent) {
- case PLAYER_EVENT_SWITCH_COMPLETE:
- showLongToast(String.format("player switch channel completed"));
- break;
- case PLAYER_EVENT_SWITCH_ERROR:
- showLongToast(String.format("player switch channel failed: %s", s));
- break;
- default:
- break;
- }
- }
- });
- }
-
- @Override
- public void onMetaData(io.agora.mediaplayer.Constants.MediaPlayerMetadataType mediaPlayerMetadataType, byte[] bytes) {
-
- }
-
- @Override
- public void onPlayBufferUpdated(long l) {
-
- }
-
- @Override
- public void onPreloadEvent(String s, io.agora.mediaplayer.Constants.MediaPlayerPreloadEvent mediaPlayerPreloadEvent) {
-
- }
-
-
- @Override
- public void onAgoraCDNTokenWillExpire() {
-
- }
-
- @Override
- public void onPlayerSrcInfoChanged(SrcInfo srcInfo, SrcInfo srcInfo1) {
-
- }
-
- @Override
- public void onPlayerInfoUpdated(PlayerUpdatedInfo playerUpdatedInfo) {
-
- }
-
- @Override
- public void onPlayerCacheStats(CacheStatistics stats) {
-
- }
-
- @Override
- public void onPlayerPlaybackStats(PlayerPlaybackStats stats) {
-
- }
-
- @Override
- public void onAudioVolumeIndication(int i) {
-
- }
-
- private final AdapterView.OnItemSelectedListener itemSelectedListener = new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> adapterView, View view, int i, long l) {
- Log.i(TAG, "Start to switch cdn, current index is " + mediaPlayer.getAgoraCDNLineCount() + ". target index is " + i);
- mediaPlayer.switchAgoraCDNLineByIndex(i);
- }
-
- @Override
- public void onNothingSelected(AdapterView> adapterView) {
-
- }
- };
-
- @Override
- protected void onBackPressed() {
-
- if (rtcSwitcher.isChecked()) {
- rtcSwitcher.setChecked(false);
- } else {
- super.onBackPressed();
- }
- }
-}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/EntryFragment.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/EntryFragment.java
deleted file mode 100644
index 6d0488094..000000000
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/EntryFragment.java
+++ /dev/null
@@ -1,116 +0,0 @@
-//package io.agora.api.example.examples.advanced.CDNStreaming;
-//
-//import static io.agora.api.example.common.model.Examples.ADVANCED;
-//
-//import android.os.Bundle;
-//import android.view.LayoutInflater;
-//import android.view.View;
-//import android.view.ViewGroup;
-//import android.widget.AdapterView;
-//import android.widget.EditText;
-//import android.widget.Spinner;
-//
-//import androidx.annotation.NonNull;
-//import androidx.annotation.Nullable;
-//import androidx.navigation.Navigation;
-//
-//import io.agora.api.example.R;
-//import io.agora.api.example.annotation.Example;
-//import io.agora.api.example.common.BaseFragment;
-//import io.agora.api.example.utils.PermissonUtils;
-//
-///**
-// * The type Entry fragment.
-// */
-//@Example(
-// index = 2,
-// group = ADVANCED,
-// name = R.string.item_rtmpstreaming,
-// actionId = R.id.action_mainFragment_to_CDNStreaming,
-// tipsId = R.string.rtmpstreaming
-//)
-//public class EntryFragment extends BaseFragment implements View.OnClickListener {
-// private static final String TAG = EntryFragment.class.getSimpleName();
-// private Spinner streamMode;
-// private EditText et_channel;
-//
-// private boolean isAgoraChannel() {
-// return "AGORA_CHANNEL".equals(streamMode.getSelectedItem().toString());
-// }
-//
-// private String getChannelName() {
-// return et_channel.getText().toString();
-// }
-//
-// @Nullable
-// @Override
-// public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
-// View view = inflater.inflate(R.layout.fragment_cdn_entry, container, false);
-// return view;
-// }
-//
-// @Override
-// public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
-// super.onViewCreated(view, savedInstanceState);
-// view.findViewById(R.id.btn_host_join).setOnClickListener(this);
-// view.findViewById(R.id.btn_audience_join).setOnClickListener(this);
-// et_channel = view.findViewById(R.id.et_channel);
-// streamMode = view.findViewById(R.id.streamModeSpinner);
-// streamMode.setOnItemSelectedListener(new StreamModeOnItemSelectedListener());
-// }
-//
-// private final class StreamModeOnItemSelectedListener implements AdapterView.OnItemSelectedListener {
-// @Override
-// public void onItemSelected(AdapterView> adapter, View view, int position, long id) {
-// et_channel.setHint(position == 0 ? R.string.agora_channel_hint : R.string.cdn_url_hint);
-// }
-//
-// @Override
-// public void onNothingSelected(AdapterView> arg0) {
-// }
-// }
-//
-// @Override
-// public void onActivityCreated(@Nullable Bundle savedInstanceState) {
-// super.onActivityCreated(savedInstanceState);
-// }
-//
-// @Override
-// public void onDestroy() {
-// super.onDestroy();
-// }
-//
-// @Override
-// public void onClick(View v) {
-// // Check permission
-// checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() {
-// @Override
-// public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) {
-// // Permissions Granted
-// if (allPermissionsGranted) {
-// join(v);
-// }
-// }
-// });
-// }
-//
-// private void join(View v) {
-// if (v.getId() == R.id.btn_host_join) {
-// Bundle bundle = new Bundle();
-// bundle.putString(getString(R.string.key_channel_name), getChannelName());
-// bundle.putBoolean(getString(R.string.key_is_agora_channel), isAgoraChannel());
-// Navigation.findNavController(requireView()).navigate(
-// R.id.action_cdn_streaming_to_host,
-// bundle
-// );
-// } else if (v.getId() == R.id.btn_audience_join) {
-// Bundle bundle = new Bundle();
-// bundle.putString(getString(R.string.key_channel_name), getChannelName());
-// bundle.putBoolean(getString(R.string.key_is_agora_channel), isAgoraChannel());
-// Navigation.findNavController(requireView()).navigate(
-// R.id.action_cdn_streaming_to_audience,
-// bundle
-// );
-// }
-// }
-//}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/HostFragment.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/HostFragment.java
deleted file mode 100644
index fc7cca2cf..000000000
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/HostFragment.java
+++ /dev/null
@@ -1,569 +0,0 @@
-package io.agora.api.example.examples.advanced.CDNStreaming;
-
-import static io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER;
-import static io.agora.rtc2.Constants.RENDER_MODE_HIDDEN;
-import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.SurfaceView;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.CompoundButton;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.Switch;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.util.Locale;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
-
-import io.agora.api.example.MainApplication;
-import io.agora.api.example.R;
-import io.agora.api.example.common.BaseFragment;
-import io.agora.rtc2.ChannelMediaOptions;
-import io.agora.rtc2.Constants;
-import io.agora.rtc2.DirectCdnStreamingMediaOptions;
-import io.agora.rtc2.DirectCdnStreamingReason;
-import io.agora.rtc2.DirectCdnStreamingState;
-import io.agora.rtc2.DirectCdnStreamingStats;
-import io.agora.rtc2.IDirectCdnStreamingEventHandler;
-import io.agora.rtc2.IRtcEngineEventHandler;
-import io.agora.rtc2.LeaveChannelOptions;
-import io.agora.rtc2.RtcEngine;
-import io.agora.rtc2.RtcEngineConfig;
-import io.agora.rtc2.live.LiveTranscoding;
-import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
-import io.agora.rtc2.video.CameraCapturerConfiguration;
-import io.agora.rtc2.video.VideoCanvas;
-import io.agora.rtc2.video.VideoEncoderConfiguration;
-
-/**
- * The type Host fragment.
- */
-public class HostFragment extends BaseFragment {
- private static final String TAG = HostFragment.class.getSimpleName();
- private static final String AGORA_CHANNEL_PREFIX = "rtmp://push.webdemo.agoraio.cn/lbhd/";
-
- private volatile boolean isAgoraChannel = true;
- private volatile boolean cdnStreaming = false;
- private volatile boolean rtcStreaming = false;
- private String channel;
- private FrameLayout fl_local, fl_remote, fl_remote_2, fl_remote_3;
- private Map remoteViews = new ConcurrentHashMap();
- private LinearLayout rtc_control, video_row2;
- private RtcEngine engine;
- private LiveTranscoding liveTranscoding = new LiveTranscoding();
- private Button streamingButton;
- private Switch rtcSwitcher;
- private SeekBar volSeekBar;
- private VideoEncoderConfiguration videoEncoderConfiguration;
- private int canvas_width = 480;
- private int canvas_height = 640;
- private int localUid = (int) (Math.random() * Integer.MAX_VALUE / 2);
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.fragment_cdn_host, container, false);
- Bundle bundle = this.getArguments();
- isAgoraChannel = bundle.getBoolean(getString(R.string.key_is_agora_channel));
- channel = bundle.getString(getString(R.string.key_channel_name));
- return view;
- }
-
- @Override
- public void onResume() {
- super.onResume();
- getView().setFocusableInTouchMode(true);
- getView().requestFocus();
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- fl_local = view.findViewById(R.id.fl_local);
- fl_remote = view.findViewById(R.id.fl_remote);
- fl_remote_2 = view.findViewById(R.id.fl_remote2);
- fl_remote_3 = view.findViewById(R.id.fl_remote3);
- rtc_control = view.findViewById(R.id.rtc_ctrl);
- rtc_control.setVisibility(isAgoraChannel ? View.VISIBLE : View.INVISIBLE);
- video_row2 = view.findViewById(R.id.video_container_row2);
- streamingButton = view.findViewById(R.id.streaming_btn);
- streamingButton.setOnClickListener(streamingOnCLickListener);
- rtcSwitcher = view.findViewById(R.id.rtc_switch);
- rtcSwitcher.setOnCheckedChangeListener(checkedChangeListener);
- volSeekBar = view.findViewById(R.id.record_vol);
- volSeekBar.setOnSeekBarChangeListener(seekBarChangeListener);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
- try {
-
- RtcEngineConfig config = new RtcEngineConfig();
- /*
- * The context of Android Activity
- */
- config.mContext = context.getApplicationContext();
- /*
- * The App ID issued to you by Agora. See How to get the App ID
- */
- config.mAppId = getString(R.string.agora_app_id);
- /* Sets the channel profile of the Agora RtcEngine.
- CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
- Use this profile in one-on-one calls or group calls, where all users can talk freely.
- CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast
- channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams;
- an audience can only receive streams.*/
- config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
- /*
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- config.mEventHandler = iRtcEngineEventHandler;
- config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode();
- engine = RtcEngine.create(config);
- /*
- * This parameter is for reporting the usages of APIExample to agora background.
- * Generally, it is not necessary for you to set this parameter.
- */
- engine.setParameters("{"
- + "\"rtc.report_app_scenario\":"
- + "{"
- + "\"appScenario\":" + 100 + ","
- + "\"serviceType\":" + 11 + ","
- + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
- + "}"
- + "}");
- /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/
- LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig();
- if (localAccessPointConfiguration != null) {
- // This api can only be used in the private media server scenario, otherwise some problems may occur.
- engine.setLocalAccessPoint(localAccessPointConfiguration);
- }
-
- CameraCapturerConfiguration.CaptureFormat captureFormat = new CameraCapturerConfiguration.CaptureFormat();
- captureFormat.fps = 30;
- engine.setCameraCapturerConfiguration(new CameraCapturerConfiguration(CameraCapturerConfiguration.CAMERA_DIRECTION.CAMERA_FRONT, captureFormat));
-
- engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration());
- setupEngineConfig(context);
- } catch (Exception e) {
- e.printStackTrace();
- getActivity().onBackPressed();
- }
- }
-
- private void setupEngineConfig(Context context) {
- // setup local video to render local camera preview
- SurfaceView surfaceView = new SurfaceView(context);
- if (fl_local.getChildCount() > 0) {
- fl_local.removeAllViews();
- }
- // Add to the local container
- fl_local.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup local video to render your local camera preview
- engine.setupLocalVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, 0));
- // You have to call startPreview to see local video
- engine.startPreview();
- // Set audio route to microPhone
- engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*In the demo, the default is to enter as the anchor.*/
- engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
- // Enable video module
- engine.enableVideo();
- // Setup video encoding configs
- VideoEncoderConfiguration.VideoDimensions videoDimensions = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject();
- canvas_height = Math.max(videoDimensions.height, videoDimensions.width);
- canvas_width = Math.min(videoDimensions.height, videoDimensions.width);
- videoEncoderConfiguration = new VideoEncoderConfiguration(
- videoDimensions, VideoEncoderConfiguration.FRAME_RATE.FRAME_RATE_FPS_15, STANDARD_BITRATE, VideoEncoderConfiguration.ORIENTATION_MODE.ORIENTATION_MODE_FIXED_PORTRAIT
- );
- liveTranscoding.width = canvas_width;
- liveTranscoding.height = canvas_height;
- liveTranscoding.videoFramerate = 15;
- engine.setVideoEncoderConfiguration(videoEncoderConfiguration);
- engine.setDirectCdnStreamingVideoConfiguration(videoEncoderConfiguration);
- }
-
- private void stopStreaming() {
- rtcStreaming = false;
- cdnStreaming = false;
- rtcSwitcher.setChecked(false);
- rtcSwitcher.setEnabled(false);
- streamingButton.setText(getString(R.string.start_live_streaming));
- }
-
- private final View.OnClickListener streamingOnCLickListener = new View.OnClickListener() {
- @Override
- public void onClick(View view) {
- if (rtcStreaming) {
- engine.stopRtmpStream(getUrl());
- engine.leaveChannel();
- stopStreaming();
- } else if (cdnStreaming) {
- engine.stopDirectCdnStreaming();
- engine.startPreview();
- rtcSwitcher.setChecked(false);
- rtcSwitcher.setEnabled(false);
- } else {
- engine.setDirectCdnStreamingVideoConfiguration(videoEncoderConfiguration);
- int ret = startCdnStreaming();
- if (ret == 0) {
- streamingButton.setText(R.string.text_streaming);
- } else {
- showLongToast(String.format("startCdnStreaming failed! error code: %d", ret));
- }
- }
- }
- };
-
- private int startCdnStreaming() {
- DirectCdnStreamingMediaOptions directCdnStreamingMediaOptions = new DirectCdnStreamingMediaOptions();
- directCdnStreamingMediaOptions.publishCameraTrack = true;
- directCdnStreamingMediaOptions.publishMicrophoneTrack = true;
- return engine.startDirectCdnStreaming(iDirectCdnStreamingEventHandler, getUrl(), directCdnStreamingMediaOptions);
- }
-
- private String getUrl() {
- if (isAgoraChannel) {
- return AGORA_CHANNEL_PREFIX + channel;
- } else {
- return channel;
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- if (engine != null) {
- if (rtcStreaming) {
- engine.leaveChannel();
- } else if (cdnStreaming) {
- engine.stopDirectCdnStreaming();
- }
- /*leaveChannel and Destroy the RtcEngine instance*/
- engine.stopPreview();
- handler.post(RtcEngine::destroy);
- engine = null;
- }
- }
-
- /**
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
-
- /**
- * Error code description can be found at:
- * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- */
- @Override
- public void onError(int err) {
- Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err)));
- }
-
- /**Occurs when a user leaves the channel.
- * @param stats With this callback, the application retrieves the channel information,
- * such as the call duration and statistics.*/
- @Override
- public void onLeaveChannel(RtcStats stats) {
- super.onLeaveChannel(stats);
- }
-
- /**Occurs when the local user joins a specified channel.
- * The channel name assignment is based on channelName specified in the joinChannel method.
- * If the uid is not specified when joinChannel is called, the server automatically assigns a uid.
- * @param channel Channel name
- * @param uid User ID
- * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/
- @Override
- public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
- Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- localUid = uid;
- LiveTranscoding.TranscodingUser user = new LiveTranscoding.TranscodingUser();
- user.x = 0;
- user.y = 0;
- user.width = canvas_width;
- user.height = canvas_height;
- user.uid = localUid;
- liveTranscoding.addUser(user);
- // engine.updateRtmpTranscoding(liveTranscoding);
- int ret = engine.startRtmpStreamWithTranscoding(getUrl(), liveTranscoding);
- if (ret != 0) {
- showLongToast(String.format(Locale.US, "startRtmpStreamWithTranscoding failed! reason:%d", ret));
- }
- }
-
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel.
- * @param uid ID of the user whose audio state changes.
- * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole
- * until this callback is triggered.*/
- @Override
- public void onUserJoined(int uid, int elapsed) {
- super.onUserJoined(uid, elapsed);
- Log.i(TAG, "onUserJoined->" + uid);
- showLongToast(String.format("user %d joined!", uid));
- /*Check if the context is correct*/
- Context context = getContext();
- if (context == null) {
- return;
- }
-
- if (remoteViews.containsKey(uid)) {
- return;
- } else {
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Display remote video stream*/
- SurfaceView surfaceView = null;
- // Create render view by RtcEngine
- surfaceView = new SurfaceView(context);
- surfaceView.setZOrderMediaOverlay(true);
- ViewGroup view = getAvailableView();
- remoteViews.put(uid, view);
- // Add to the remote container
- view.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup remote video to render
- engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid));
- updateTranscodeLayout();
- }
- });
- }
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel.
- * @param uid ID of the user whose audio state changes.
- * @param reason Reason why the user goes offline:
- * USER_OFFLINE_QUIT(0): The user left the current channel.
- * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data
- * packet was received within a certain period of time. If a user quits the
- * call and the message is not passed to the SDK (due to an unreliable channel),
- * the SDK assumes the user dropped offline.
- * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from
- * the host to the audience.*/
- @Override
- public void onUserOffline(int uid, int reason) {
- Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason));
- showLongToast(String.format("user %d offline! reason:%d", uid, reason));
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Clear render view
- Note: The video will stay at its last frame, to completely remove it you will need to
- remove the SurfaceView from its parent*/
- engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid));
- remoteViews.get(uid).removeAllViews();
- remoteViews.remove(uid);
- updateTranscodeLayout();
- }
- });
- }
-
- @Override
- public void onRtmpStreamingStateChanged(String url, int state, int errCode) {
- super.onRtmpStreamingStateChanged(url, state, errCode);
- showShortToast(String.format("onRtmpStreamingStateChanged state %s errCode %s", state, errCode));
- if (state == Constants.RTMP_STREAM_PUBLISH_STATE_IDLE) {
- if (cdnStreaming) {
- runOnUIThread(() -> {
- LeaveChannelOptions leaveChannelOptions = new LeaveChannelOptions();
- leaveChannelOptions.stopMicrophoneRecording = false;
- engine.leaveChannel(leaveChannelOptions);
- fl_remote.removeAllViews();
- fl_remote_2.removeAllViews();
- fl_remote_3.removeAllViews();
- remoteViews.clear();
- engine.startPreview();
- engine.setDirectCdnStreamingVideoConfiguration(videoEncoderConfiguration);
- int ret = startCdnStreaming();
- if (ret != 0) {
- showLongToast(String.format("startCdnStreaming failed! error code: %d", ret));
- stopStreaming();
- }
- });
- }
- }
- }
-
- @Override
- public void onTranscodingUpdated() {
- showLongToast("RTMP transcoding updated successfully!");
- }
- };
-
- private void updateTranscodeLayout() {
- boolean hasRemote = remoteViews.size() > 0;
- LiveTranscoding.TranscodingUser user = new LiveTranscoding.TranscodingUser();
- user.x = 0;
- user.y = 0;
- user.width = hasRemote ? canvas_width / 2 : canvas_width;
- user.height = hasRemote ? canvas_height / 2 : canvas_height;
- user.uid = localUid;
- liveTranscoding.addUser(user);
- if (hasRemote) {
- int index = 0;
- for (int uid : remoteViews.keySet()) {
- index++;
- switch (index) {
- case 1:
- LiveTranscoding.TranscodingUser user1 = new LiveTranscoding.TranscodingUser();
- user1.x = canvas_width / 2;
- user1.y = 0;
- user1.width = canvas_width / 2;
- user1.height = canvas_height / 2;
- user1.uid = uid;
- liveTranscoding.addUser(user1);
- break;
- case 2:
- LiveTranscoding.TranscodingUser user2 = new LiveTranscoding.TranscodingUser();
- user2.x = 0;
- user2.y = canvas_height / 2;
- user2.width = canvas_width / 2;
- user2.height = canvas_height / 2;
- user2.uid = uid;
- liveTranscoding.addUser(user2);
- break;
- case 3:
- LiveTranscoding.TranscodingUser user3 = new LiveTranscoding.TranscodingUser();
- user3.x = canvas_width / 2;
- user3.y = canvas_height / 2;
- user3.width = canvas_width / 2;
- user3.height = canvas_height / 2;
- user3.uid = uid;
- liveTranscoding.addUser(user3);
- break;
- default:
- Log.i(TAG, "ignored user as only 2x2 video layout supported in this demo. uid:" + uid);
- }
- }
- }
- engine.updateRtmpTranscoding(liveTranscoding);
- }
-
- private final IDirectCdnStreamingEventHandler iDirectCdnStreamingEventHandler = new IDirectCdnStreamingEventHandler() {
-
-
- @Override
- public void onDirectCdnStreamingStateChanged(DirectCdnStreamingState state, DirectCdnStreamingReason reason, String message) {
- showShortToast(String.format("onDirectCdnStreamingStateChanged state:%s, error:%s", state, reason));
- runOnUIThread(new Runnable() {
- @Override
- public void run() {
- switch (state) {
- case RUNNING:
- streamingButton.setText(R.string.stop_streaming);
- cdnStreaming = true;
- break;
- case STOPPED:
- if (rtcStreaming) {
- // Switch to RTC streaming when direct CDN streaming completely stopped.
- ChannelMediaOptions channelMediaOptions = new ChannelMediaOptions();
- channelMediaOptions.publishMicrophoneTrack = true;
- channelMediaOptions.publishCameraTrack = true;
- channelMediaOptions.clientRoleType = CLIENT_ROLE_BROADCASTER;
- int ret = engine.joinChannel(null, channel, localUid, channelMediaOptions);
- if (ret != 0) {
- showLongToast(String.format("Join Channel call failed! reason:%d", ret));
- }
- } else {
- streamingButton.setText(getString(R.string.start_live_streaming));
- cdnStreaming = false;
- }
- break;
- case FAILED:
- showLongToast(String.format("Start Streaming failed, please go back to previous page and check the settings."));
- default:
- Log.i(TAG, String.format("onDirectCdnStreamingStateChanged, state: %s error: %s message: %s", state.name(), reason.name(), message));
- }
- rtcSwitcher.setEnabled(true);
- }
- });
- }
-
- @Override
- public void onDirectCdnStreamingStats(DirectCdnStreamingStats directCdnStreamingStats) {
-
- }
- };
-
- private final SeekBar.OnSeekBarChangeListener seekBarChangeListener = new SeekBar.OnSeekBarChangeListener() {
-
- @Override
- public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
- engine.adjustRecordingSignalVolume(i);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
-
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
-
- }
- };
-
- private final CompoundButton.OnCheckedChangeListener checkedChangeListener = new CompoundButton.OnCheckedChangeListener() {
- @Override
- public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
- rtcStreaming = b;
- if (rtcStreaming) {
- engine.stopDirectCdnStreaming();
- } else if (cdnStreaming) {
- engine.stopRtmpStream(getUrl());
- }
- handler.post(new Runnable() {
- @Override
- public void run() {
- toggleVideoLayout(rtcStreaming);
- }
- });
- }
- };
-
- private void toggleVideoLayout(boolean isMultiple) {
- if (isMultiple) {
- fl_remote.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- fl_remote_2.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- fl_remote_3.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0.5f));
- video_row2.setLayoutParams(new LinearLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 0, 1));
- } else {
- fl_remote.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- fl_remote_2.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- fl_remote_3.setLayoutParams(new LinearLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT, 0));
- video_row2.setLayoutParams(new LinearLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, 0, 0));
- }
- }
-
- private ViewGroup getAvailableView() {
- if (fl_remote.getChildCount() == 0) {
- return fl_remote;
- } else if (fl_remote_2.getChildCount() == 0) {
- return fl_remote_2;
- } else if (fl_remote_3.getChildCount() == 0) {
- return fl_remote_3;
- } else {
- return fl_remote;
- }
- }
-}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ChannelEncryption.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ChannelEncryption.java
index 61480657b..85603c837 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ChannelEncryption.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ChannelEncryption.java
@@ -101,7 +101,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -287,7 +287,7 @@ private void joinChannel(String channelId) {
ORIENTATION_MODE_ADAPTIVE
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ContentInspect.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ContentInspect.java
index ea09bc805..10c0894e6 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ContentInspect.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ContentInspect.java
@@ -96,7 +96,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -231,7 +231,7 @@ private void joinChannel(String channelId) {
engine.enableContentInspect(true, contentInspectConfig);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CustomRemoteVideoRender.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CustomRemoteVideoRender.java
index 753964de3..94444a61d 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CustomRemoteVideoRender.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CustomRemoteVideoRender.java
@@ -132,7 +132,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -273,7 +273,7 @@ private void joinChannel(String channelId) {
engine.startPreview();
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/FaceCapture.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/FaceCapture.java
index b70faab1b..fbcfdbb6f 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/FaceCapture.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/FaceCapture.java
@@ -80,7 +80,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -239,7 +239,7 @@ private void joinChannel(String channelId) {
engine.startPreview();
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/HostAcrossChannel.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/HostAcrossChannel.java
index a78260ef7..a54072f85 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/HostAcrossChannel.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/HostAcrossChannel.java
@@ -101,7 +101,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -261,7 +261,7 @@ private void joinChannel(String channelId) {
VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java
deleted file mode 100644
index 36ae325be..000000000
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java
+++ /dev/null
@@ -1,485 +0,0 @@
-package io.agora.api.example.examples.advanced;
-
-import static io.agora.rtc2.video.VideoCanvas.RENDER_MODE_HIDDEN;
-import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE;
-
-import android.content.Context;
-import android.os.Bundle;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.SurfaceView;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.widget.AppCompatTextView;
-
-import io.agora.api.example.MainApplication;
-import io.agora.api.example.R;
-import io.agora.api.example.common.BaseFragment;
-import io.agora.api.example.common.model.StatisticsInfo;
-import io.agora.api.example.utils.CommonUtil;
-import io.agora.api.example.utils.PermissonUtils;
-import io.agora.api.example.utils.TokenUtils;
-import io.agora.rtc2.Constants;
-import io.agora.rtc2.IRtcEngineEventHandler;
-import io.agora.rtc2.RtcEngine;
-import io.agora.rtc2.RtcEngineConfig;
-import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
-import io.agora.rtc2.video.VideoCanvas;
-import io.agora.rtc2.video.VideoEncoderConfiguration;
-
-//@Example(
-// index = 17,
-// group = ADVANCED,
-// name = R.string.item_incallreport,
-// actionId = R.id.action_mainFragment_to_InCallReport,
-// tipsId = R.string.incallstats
-//)
-
-/**
- * The type In call report.
- *
- * @deprecated The report has been moved to
- * {@link io.agora.api.example.common.widget.VideoReportLayout}.
- * You can refer to {@link LiveStreaming} or {@link io.agora.api.example.examples.basic.JoinChannelVideo} example.
- */
-@Deprecated
-public class InCallReport extends BaseFragment implements View.OnClickListener {
- private static final String TAG = InCallReport.class.getSimpleName();
-
- private FrameLayout fl_local, fl_remote;
- private Button join;
- private EditText et_channel;
- private AppCompatTextView localStats, remoteStats;
- private RtcEngine engine;
- private StatisticsInfo statisticsInfo;
- private int myUid;
- private boolean joined = false;
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.fragment_in_call_report, container, false);
- return view;
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- join = view.findViewById(R.id.btn_join);
- statisticsInfo = new StatisticsInfo();
- et_channel = view.findViewById(R.id.et_channel);
- localStats = view.findViewById(R.id.local_stats);
- localStats.bringToFront();
- remoteStats = view.findViewById(R.id.remote_stats);
- remoteStats.bringToFront();
- view.findViewById(R.id.btn_join).setOnClickListener(this);
- fl_local = view.findViewById(R.id.fl_local);
- fl_remote = view.findViewById(R.id.fl_remote);
- }
-
- private void updateLocalStats() {
- handler.post(new Runnable() {
- @Override
- public void run() {
- localStats.setText(statisticsInfo.getLocalVideoStats());
- }
- });
- }
-
- private void updateRemoteStats() {
- handler.post(new Runnable() {
- @Override
- public void run() {
- remoteStats.setText(statisticsInfo.getRemoteVideoStats());
- }
- });
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
- try {
- RtcEngineConfig config = new RtcEngineConfig();
- /*
- * The context of Android Activity
- */
- config.mContext = context.getApplicationContext();
- /*
- * The App ID issued to you by Agora. See How to get the App ID
- */
- config.mAppId = getString(R.string.agora_app_id);
- /* Sets the channel profile of the Agora RtcEngine.
- CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
- Use this profile in one-on-one calls or group calls, where all users can talk freely.
- CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast
- channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams;
- an audience can only receive streams.*/
- config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
- /*
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- config.mEventHandler = iRtcEngineEventHandler;
- config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT);
- config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode();
- engine = RtcEngine.create(config);
- /*
- * This parameter is for reporting the usages of APIExample to agora background.
- * Generally, it is not necessary for you to set this parameter.
- */
- engine.setParameters("{"
- + "\"rtc.report_app_scenario\":"
- + "{"
- + "\"appScenario\":" + 100 + ","
- + "\"serviceType\":" + 11 + ","
- + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
- + "}"
- + "}");
- /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/
- LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig();
- if (localAccessPointConfiguration != null) {
- // This api can only be used in the private media server scenario, otherwise some problems may occur.
- engine.setLocalAccessPoint(localAccessPointConfiguration);
- }
- }
- catch (Exception e) {
- e.printStackTrace();
- getActivity().onBackPressed();
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- /*leaveChannel and Destroy the RtcEngine instance*/
- if (engine != null) {
- engine.leaveChannel();
- engine.stopPreview();
- }
- handler.post(RtcEngine::destroy);
- engine = null;
- }
-
- @Override
- public void onClick(View v) {
- if (v.getId() == R.id.btn_join) {
- if (!joined) {
- CommonUtil.hideInputBoard(getActivity(), et_channel);
- // call when join button hit
- String channelId = et_channel.getText().toString();
- // Check permission
- checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() {
- @Override
- public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) {
- // Permissions Granted
- if (allPermissionsGranted) {
- joinChannel(channelId);
- }
- }
- });
- } else {
- joined = false;
- /*After joining a channel, the user must call the leaveChannel method to end the
- * call before joining another channel. This method returns 0 if the user leaves the
- * channel and releases all resources related to the call. This method call is
- * asynchronous, and the user has not exited the channel when the method call returns.
- * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback.
- * A successful leaveChannel method call triggers the following callbacks:
- * 1:The local client: onLeaveChannel.
- * 2:The remote client: onUserOffline, if the user leaving the channel is in the
- * Communication channel, or is a BROADCASTER in the Live Broadcast profile.
- * @returns 0: Success.
- * < 0: Failure.
- * PS:
- * 1:If you call the destroy method immediately after calling the leaveChannel
- * method, the leaveChannel process interrupts, and the SDK does not trigger
- * the onLeaveChannel callback.
- * 2:If you call the leaveChannel method during CDN live streaming, the SDK
- * triggers the removeInjectStreamUrl method.*/
- engine.leaveChannel();
- join.setText(getString(R.string.join));
- }
- }
- }
-
- private void joinChannel(String channelId) {
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
-
- // Create render view by RtcEngine
- SurfaceView surfaceView = new SurfaceView(context);
- if (fl_local.getChildCount() > 0) {
- fl_local.removeAllViews();
- }
- // Add to the local container
- fl_local.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup local video to render your local camera preview
- engine.setupLocalVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, 0));
- // Set audio route to microPhone
- engine.setDefaultAudioRoutetoSpeakerphone(true);
-
- /*In the demo, the default is to enter as the anchor.*/
- engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
- // Enable video module
- engine.enableVideo();
- // start preview
- engine.startPreview();
- // Setup video encoding configs
- engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration(
- ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject(),
- VideoEncoderConfiguration.FRAME_RATE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingFrameRate()),
- STANDARD_BITRATE,
- VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
- ));
-
- /*Please configure accessToken in the string_config file.
- * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
- * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
- * A token generated at the server. This applies to scenarios with high-security requirements. For details, see
- * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/
- TokenUtils.gen(requireContext(), channelId, 0, ret -> {
- /* Allows a user to join a channel.
- if you do not specify the uid, we will generate the uid for you*/
- int res = engine.joinChannel(ret, channelId, "Extra Optional Data", 0);
- if (res != 0) {
- // Usually happens with invalid parameters
- // Error code description can be found at:
- // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html
- // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html
- showAlert(RtcEngine.getErrorDescription(Math.abs(res)));
- return;
- }
- // Prevent repeated entry
- join.setEnabled(false);
- });
- }
-
- /**
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
- /**
- * Error code description can be found at:
- * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- */
- @Override
- public void onError(int err) {
- Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err)));
- }
-
-
- /**Occurs when a user leaves the channel.
- * @param stats With this callback, the application retrieves the channel information,
- * such as the call duration and statistics.*/
- @Override
- public void onLeaveChannel(RtcStats stats) {
- super.onLeaveChannel(stats);
- Log.i(TAG, String.format("local user %d leaveChannel!", myUid));
- showLongToast(String.format("local user %d leaveChannel!", myUid));
- }
-
- /**Occurs when the local user joins a specified channel.
- * The channel name assignment is based on channelName specified in the joinChannel method.
- * If the uid is not specified when joinChannel is called, the server automatically assigns a uid.
- * @param channel Channel name
- * @param uid User ID
- * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/
- @Override
- public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
- Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- myUid = uid;
- joined = true;
- handler.post(new Runnable() {
- @Override
- public void run() {
- join.setEnabled(true);
- join.setText(getString(R.string.leave));
- }
- });
- }
-
- /**Since v2.9.0.
- * This callback indicates the state change of the remote audio stream.
- * PS: This callback does not work properly when the number of users (in the Communication profile) or
- * broadcasters (in the Live-broadcast profile) in the channel exceeds 17.
- * @param uid ID of the user whose audio state changes.
- * @param state State of the remote audio
- * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due
- * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5),
- * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7).
- * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received.
- * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally,
- * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2),
- * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6).
- * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to
- * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1).
- * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to
- * REMOTE_AUDIO_REASON_INTERNAL(0).
- * @param reason The reason of the remote audio state change.
- * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons.
- * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion.
- * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery.
- * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio
- * stream or disables the audio module.
- * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio
- * stream or enables the audio module.
- * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or
- * disables the audio module.
- * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream
- * or enables the audio module.
- * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel.
- * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method
- * until the SDK triggers this callback.*/
- @Override
- public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) {
- super.onRemoteAudioStateChanged(uid, state, reason, elapsed);
- Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason);
- }
-
- /**Since v2.9.0.
- * Occurs when the remote video state changes.
- * PS: This callback does not work properly when the number of users (in the Communication
- * profile) or broadcasters (in the Live-broadcast profile) in the channel exceeds 17.
- * @param uid ID of the remote user whose video state changes.
- * @param state State of the remote video:
- * REMOTE_VIDEO_STATE_STOPPED(0): The remote video is in the default state, probably due
- * to REMOTE_VIDEO_STATE_REASON_LOCAL_MUTED(3), REMOTE_VIDEO_STATE_REASON_REMOTE_MUTED(5),
- * or REMOTE_VIDEO_STATE_REASON_REMOTE_OFFLINE(7).
- * REMOTE_VIDEO_STATE_STARTING(1): The first remote video packet is received.
- * REMOTE_VIDEO_STATE_DECODING(2): The remote video stream is decoded and plays normally,
- * probably due to REMOTE_VIDEO_STATE_REASON_NETWORK_RECOVERY (2),
- * REMOTE_VIDEO_STATE_REASON_LOCAL_UNMUTED(4), REMOTE_VIDEO_STATE_REASON_REMOTE_UNMUTED(6),
- * or REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK_RECOVERY(9).
- * REMOTE_VIDEO_STATE_FROZEN(3): The remote video is frozen, probably due to
- * REMOTE_VIDEO_STATE_REASON_NETWORK_CONGESTION(1) or REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK(8).
- * REMOTE_VIDEO_STATE_FAILED(4): The remote video fails to start, probably due to
- * REMOTE_VIDEO_STATE_REASON_INTERNAL(0).
- * @param reason The reason of the remote video state change:
- * REMOTE_VIDEO_STATE_REASON_INTERNAL(0): Internal reasons.
- * REMOTE_VIDEO_STATE_REASON_NETWORK_CONGESTION(1): Network congestion.
- * REMOTE_VIDEO_STATE_REASON_NETWORK_RECOVERY(2): Network recovery.
- * REMOTE_VIDEO_STATE_REASON_LOCAL_MUTED(3): The local user stops receiving the remote
- * video stream or disables the video module.
- * REMOTE_VIDEO_STATE_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote
- * video stream or enables the video module.
- * REMOTE_VIDEO_STATE_REASON_REMOTE_MUTED(5): The remote user stops sending the video
- * stream or disables the video module.
- * REMOTE_VIDEO_STATE_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the video
- * stream or enables the video module.
- * REMOTE_VIDEO_STATE_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel.
- * REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK(8): The remote media stream falls back to the
- * audio-only stream due to poor network conditions.
- * REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK_RECOVERY(9): The remote media stream switches
- * back to the video stream after the network conditions improve.
- * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method until
- * the SDK triggers this callback.*/
- @Override
- public void onRemoteVideoStateChanged(int uid, int state, int reason, int elapsed) {
- super.onRemoteVideoStateChanged(uid, state, reason, elapsed);
- Log.i(TAG, "onRemoteVideoStateChanged->" + uid + ", state->" + state + ", reason->" + reason);
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel.
- * @param uid ID of the user whose audio state changes.
- * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole
- * until this callback is triggered.*/
- @Override
- public void onUserJoined(int uid, int elapsed) {
- super.onUserJoined(uid, elapsed);
- Log.i(TAG, "onUserJoined->" + uid);
- showLongToast(String.format("user %d joined!", uid));
- /*Check if the context is correct*/
- Context context = getContext();
- if (context == null) {
- return;
- }
- handler.post(() -> {
- /*Display remote video stream*/
- SurfaceView surfaceView = null;
- if (fl_remote.getChildCount() > 0) {
- fl_remote.removeAllViews();
- }
- // Create render view by RtcEngine
- surfaceView = new SurfaceView(context);
- surfaceView.setZOrderMediaOverlay(true);
- // Add to the remote container
- fl_remote.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
-
- // Setup remote video to render
- engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid));
- });
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel.
- * @param uid ID of the user whose audio state changes.
- * @param reason Reason why the user goes offline:
- * USER_OFFLINE_QUIT(0): The user left the current channel.
- * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data
- * packet was received within a certain period of time. If a user quits the
- * call and the message is not passed to the SDK (due to an unreliable channel),
- * the SDK assumes the user dropped offline.
- * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from
- * the host to the audience.*/
- @Override
- public void onUserOffline(int uid, int reason) {
- Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason));
- showLongToast(String.format("user %d offline! reason:%d", uid, reason));
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Clear render view
- Note: The video will stay at its last frame, to completely remove it you will need to
- remove the SurfaceView from its parent*/
- engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid));
- }
- });
- }
-
- @Override
- public void onRemoteAudioStats(RemoteAudioStats remoteAudioStats) {
- statisticsInfo.setRemoteAudioStats(remoteAudioStats);
- updateRemoteStats();
- }
-
- @Override
- public void onLocalAudioStats(LocalAudioStats localAudioStats) {
- statisticsInfo.setLocalAudioStats(localAudioStats);
- updateLocalStats();
- }
-
- @Override
- public void onRemoteVideoStats(RemoteVideoStats remoteVideoStats) {
- statisticsInfo.setRemoteVideoStats(remoteVideoStats);
- updateRemoteStats();
- }
-
- @Override
- public void onLocalVideoStats(Constants.VideoSourceType source, LocalVideoStats stats) {
- super.onLocalVideoStats(source, stats);
- statisticsInfo.setLocalVideoStats(stats);
- updateLocalStats();
- }
-
- @Override
- public void onRtcStats(RtcStats rtcStats) {
- statisticsInfo.setRtcStats(rtcStats);
- }
- };
-}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/JoinMultipleChannel.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/JoinMultipleChannel.java
index 0af1dbf5f..5297e2db0 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/JoinMultipleChannel.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/JoinMultipleChannel.java
@@ -105,7 +105,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -269,7 +269,7 @@ private void joinChannel(String channelId) {
VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LiveStreaming.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LiveStreaming.java
index 2dde01164..e0f0de695 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LiveStreaming.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LiveStreaming.java
@@ -305,7 +305,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
* @param handler IRtcEngineEventHandler is an abstract class providing default implementation.
* The SDK uses this class to report to the app on SDK runtime events.*/
RtcEngineConfig rtcEngineConfig = new RtcEngineConfig();
- rtcEngineConfig.mAppId = getString(R.string.agora_app_id);
+ rtcEngineConfig.mAppId = getAgoraAppId();
rtcEngineConfig.mContext = context.getApplicationContext();
rtcEngineConfig.mEventHandler = iRtcEngineEventHandler;
/* Sets the channel profile of the Agora RtcEngine. */
@@ -549,7 +549,6 @@ private void joinChannel(String channelId) {
/*
- * Please configure accessToken in the string_config file.
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LocalVideoTranscoding.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LocalVideoTranscoding.java
index 8e64a1256..156d54c4d 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LocalVideoTranscoding.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/LocalVideoTranscoding.java
@@ -101,7 +101,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -280,7 +280,7 @@ private void joinChannel(String channelId) {
option.publishMicrophoneTrack = true;
option.publishTranscodedVideoTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaMetadata.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaMetadata.java
index 9ecd38b78..3765e8cd6 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaMetadata.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaMetadata.java
@@ -115,7 +115,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -260,7 +260,7 @@ private void joinChannel(String channelId) {
int code = engine.registerMediaMetadataObserver(iMetadataObserver, IMetadataObserver.VIDEO_METADATA);
Log.e(TAG, code + "");
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaPlayer.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaPlayer.java
index dc7ebb47e..c15cfe19d 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaPlayer.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaPlayer.java
@@ -118,7 +118,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -338,7 +338,7 @@ private void joinChannel(String channelId) {
options.publishMicrophoneTrack = false;
options.enableAudioRecordingOrPlayout = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaRecorder.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaRecorder.java
index 98fdb4136..9222a0faa 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaRecorder.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MediaRecorder.java
@@ -112,7 +112,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -265,7 +265,7 @@ private void joinChannel(String channelId) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MultiVideoSourceTracks.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MultiVideoSourceTracks.java
index 72d0c1d9b..6dc6e3972 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MultiVideoSourceTracks.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/MultiVideoSourceTracks.java
@@ -135,7 +135,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -241,7 +241,7 @@ private void joinChannel(String channelId) {
VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Multipath.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Multipath.java
index 6f460ee81..d74421d59 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Multipath.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Multipath.java
@@ -117,7 +117,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -288,7 +288,7 @@ private void joinChannel(String channelId, boolean broadcast) {
Log.d(TAG, mediaOptions.toString());
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PictureInPicture.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PictureInPicture.java
index 3997576b3..d356e4626 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PictureInPicture.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PictureInPicture.java
@@ -111,7 +111,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -265,7 +265,7 @@ private void joinChannel(String channelId) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
index 964dd692a..388f159f6 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PlayAudioFiles.java
@@ -163,7 +163,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -333,7 +333,7 @@ private void joinChannel(String channelId) {
Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))
);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
index 23d3754ed..7e0cb52f9 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PreCallTest.java
@@ -82,7 +82,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
index c71801453..4551a21d2 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessAudioRawData.java
@@ -149,7 +149,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -268,7 +268,7 @@ private void joinChannel(String channelId) {
engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessRawData.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessRawData.java
index c973a0168..97179ddb0 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessRawData.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ProcessRawData.java
@@ -90,7 +90,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -244,7 +244,7 @@ private void joinChannel(String channelId) {
engine.startPreview();
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java
deleted file mode 100644
index dcc49c6fc..000000000
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java
+++ /dev/null
@@ -1,555 +0,0 @@
-package io.agora.api.example.examples.advanced;
-
-import static io.agora.rtc2.video.VideoCanvas.RENDER_MODE_HIDDEN;
-import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE;
-
-import android.content.Context;
-import android.graphics.SurfaceTexture;
-import android.hardware.Camera;
-import android.opengl.EGLSurface;
-import android.opengl.GLES11Ext;
-import android.opengl.GLES20;
-import android.opengl.Matrix;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.SurfaceView;
-import android.view.TextureView;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-import java.util.concurrent.Callable;
-
-import io.agora.api.example.MainApplication;
-import io.agora.api.example.R;
-import io.agora.api.example.common.BaseFragment;
-import io.agora.api.example.common.gles.ProgramTextureOES;
-import io.agora.api.example.common.gles.core.EglCore;
-import io.agora.api.example.common.gles.core.GlUtil;
-import io.agora.api.example.utils.CommonUtil;
-import io.agora.api.example.utils.PermissonUtils;
-import io.agora.api.example.utils.TokenUtils;
-import io.agora.base.TextureBufferHelper;
-import io.agora.base.VideoFrame;
-import io.agora.base.internal.video.EglBase;
-import io.agora.base.internal.video.EglBase14;
-import io.agora.base.internal.video.RendererCommon;
-import io.agora.base.internal.video.YuvConverter;
-import io.agora.rtc2.ChannelMediaOptions;
-import io.agora.rtc2.Constants;
-import io.agora.rtc2.IRtcEngineEventHandler;
-import io.agora.rtc2.RtcEngine;
-import io.agora.rtc2.RtcEngineConfig;
-import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
-import io.agora.rtc2.video.VideoCanvas;
-import io.agora.rtc2.video.VideoEncoderConfiguration;
-
-//@Example(
-// index = 7,
-// group = ADVANCED,
-// name = R.string.item_pushexternal,
-// actionId = R.id.action_mainFragment_to_PushExternalVideo,
-// tipsId = R.string.pushexternalvideo
-//)
-
-/**
- * The type Push external video.
- *
- * @deprecated The impletation of custom has been moved to {@link PushExternalVideoYUV}. You can refer to {@link PushExternalVideoYUV} example.
- */
-@Deprecated
-public class PushExternalVideo extends BaseFragment implements View.OnClickListener, TextureView.SurfaceTextureListener,
- SurfaceTexture.OnFrameAvailableListener {
- private static final String TAG = PushExternalVideo.class.getSimpleName();
- private final int DEFAULT_CAPTURE_WIDTH = 640;
- private final int DEFAULT_CAPTURE_HEIGHT = 480;
-
- private FrameLayout fl_local, fl_remote;
- private Button join;
- private EditText et_channel;
- private RtcEngine engine;
- private int myUid;
- private volatile boolean joined = false;
-
- private YuvConverter mYuvConverter = new YuvConverter();
- private Handler mHandler;
- private int mPreviewTexture;
- private SurfaceTexture mPreviewSurfaceTexture;
- private EglCore mEglCore;
- private EGLSurface mDummySurface;
- private EGLSurface mDrawSurface;
- private ProgramTextureOES mProgram;
- private float[] mTransform = new float[16];
- private float[] mMVPMatrix = new float[16];
- private boolean mMVPMatrixInit = false;
- private Camera mCamera;
- private int mFacing = Camera.CameraInfo.CAMERA_FACING_FRONT;
- private boolean mPreviewing = false;
- private int mSurfaceWidth;
- private int mSurfaceHeight;
- private boolean mTextureDestroyed;
- private volatile boolean glPrepared;
- private volatile TextureBufferHelper textureBufferHelper;
-
- private boolean prepareGl(EglBase.Context eglContext, final int width, final int height) {
- Log.d(TAG, "prepareGl");
- textureBufferHelper = TextureBufferHelper.create("STProcess", eglContext);
- if (textureBufferHelper == null) {
- return false;
- }
- Log.d(TAG, "prepareGl completed");
- return true;
- }
-
- @Nullable
- @Override
- public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.fragment_push_externalvideo, container, false);
- return view;
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- join = view.findViewById(R.id.btn_join);
- et_channel = view.findViewById(R.id.et_channel);
- view.findViewById(R.id.btn_join).setOnClickListener(this);
- fl_local = view.findViewById(R.id.fl_local);
- fl_remote = view.findViewById(R.id.fl_remote);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
- try {
- RtcEngineConfig config = new RtcEngineConfig();
- /*
- * The context of Android Activity
- */
- config.mContext = context.getApplicationContext();
- /*
- * The App ID issued to you by Agora. See How to get the App ID
- */
- config.mAppId = getString(R.string.agora_app_id);
- /* Sets the channel profile of the Agora RtcEngine.
- CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
- Use this profile in one-on-one calls or group calls, where all users can talk freely.
- CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast
- channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams;
- an audience can only receive streams.*/
- config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
- /*
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- config.mEventHandler = iRtcEngineEventHandler;
- config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT);
- config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode();
- engine = RtcEngine.create(config);
- /*
- * This parameter is for reporting the usages of APIExample to agora background.
- * Generally, it is not necessary for you to set this parameter.
- */
- engine.setParameters("{"
- + "\"rtc.report_app_scenario\":"
- + "{"
- + "\"appScenario\":" + 100 + ","
- + "\"serviceType\":" + 11 + ","
- + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
- + "}"
- + "}");
- /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/
- LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig();
- if (localAccessPointConfiguration != null) {
- // This api can only be used in the private media server scenario, otherwise some problems may occur.
- engine.setLocalAccessPoint(localAccessPointConfiguration);
- }
- }
- catch (Exception e) {
- e.printStackTrace();
- getActivity().onBackPressed();
- }
- }
-
-
- @Override
- public void onDestroy() {
- /*leaveChannel and Destroy the RtcEngine instance*/
- if (engine != null) {
- /*After joining a channel, the user must call the leaveChannel method to end the
- * call before joining another channel. This method returns 0 if the user leaves the
- * channel and releases all resources related to the call. This method call is
- * asynchronous, and the user has not exited the channel when the method call returns.
- * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback.
- * A successful leaveChannel method call triggers the following callbacks:
- * 1:The local client: onLeaveChannel.
- * 2:The remote client: onUserOffline, if the user leaving the channel is in the
- * Communication channel, or is a BROADCASTER in the Live Broadcast profile.
- * @returns 0: Success.
- * < 0: Failure.
- * PS:
- * 1:If you call the destroy method immediately after calling the leaveChannel
- * method, the leaveChannel process interrupts, and the SDK does not trigger
- * the onLeaveChannel callback.
- * 2:If you call the leaveChannel method during CDN live streaming, the SDK
- * triggers the removeInjectStreamUrl method.*/
- engine.leaveChannel();
- engine.stopPreview();
- if (textureBufferHelper != null) {
- textureBufferHelper.dispose();
- textureBufferHelper = null;
- }
- }
- engine = null;
- super.onDestroy();
- handler.post(RtcEngine::destroy);
- }
-
- @Override
- public void onClick(View v) {
- if (v.getId() == R.id.btn_join) {
- if (!joined) {
- CommonUtil.hideInputBoard(getActivity(), et_channel);
- // call when join button hit
- String channelId = et_channel.getText().toString();
- // Check permission
- checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() {
- @Override
- public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) {
- // Permissions Granted
- if (allPermissionsGranted) {
- joinChannel(channelId);
- }
- }
- });
- } else {
- fl_local.setVisibility(View.GONE);
- getActivity().onBackPressed();
- }
- }
- }
-
- private void joinChannel(String channelId) {
-// engine.setParameters("{\"rtc.log_filter\":65535}");
- // Check if the context is valid
- Context context = getContext();
- if (context == null) {
- return;
- }
-
- // Create render view by RtcEngine
- TextureView textureView = new TextureView(getContext());
- //add SurfaceTextureListener
- textureView.setSurfaceTextureListener(this);
- // Add to the local container
- fl_local.addView(textureView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- /*Set up to play remote sound with receiver*/
- engine.setDefaultAudioRoutetoSpeakerphone(true);
-
- /*In the demo, the default is to enter as the anchor.*/
- engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
- // Enables the video module.
- engine.enableVideo();
- // Setup video encoding configs
- engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration(
- ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject(),
- VideoEncoderConfiguration.FRAME_RATE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingFrameRate()),
- STANDARD_BITRATE,
- VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
- ));
- /*Configures the external video source.
- * @param enable Sets whether or not to use the external video source:
- * true: Use the external video source.
- * false: Do not use the external video source.
- * @param useTexture Sets whether or not to use texture as an input:
- * true: Use texture as an input.
- * false: (Default) Do not use texture as an input.
- * @param pushMode
- * VIDEO_FRAME: Use the ENCODED_VIDEO_FRAME.
- * ENCODED_VIDEO_FRAME: Use the ENCODED_VIDEO_FRAME*/
- engine.setExternalVideoSource(true, true, Constants.ExternalVideoSourceType.VIDEO_FRAME);
-
- /*Please configure accessToken in the string_config file.
- * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
- * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
- * A token generated at the server. This applies to scenarios with high-security requirements. For details, see
- * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/
- TokenUtils.gen(requireContext(), channelId, 0, token -> {
- /* Allows a user to join a channel.
- if you do not specify the uid, we will generate the uid for you*/
-
- ChannelMediaOptions option = new ChannelMediaOptions();
- option.autoSubscribeAudio = true;
- option.autoSubscribeVideo = true;
- int res = engine.joinChannel(token, channelId, 0, option);
- if (res != 0) {
- // Usually happens with invalid parameters
- // Error code description can be found at:
- // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html
- // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html
- showAlert(RtcEngine.getErrorDescription(Math.abs(res)));
- return;
- }
- // Prevent repeated entry
- join.setEnabled(false);
- });
-
- }
-
- @Override
- public void onFrameAvailable(SurfaceTexture surfaceTexture) {
- if (mTextureDestroyed) {
- return;
- }
-
- if (!mEglCore.isCurrent(mDrawSurface)) {
- mEglCore.makeCurrent(mDrawSurface);
- }
- try {
- surfaceTexture.updateTexImage();
- surfaceTexture.getTransformMatrix(mTransform);
- } catch (Exception e) {
- e.printStackTrace();
- }
-
- /*The rectangle ratio of frames and the screen surface may be different, so cropping may
- * happen when display frames to the screen.
- * The display transformation matrix does not change for the same camera when the screen
- * orientation remains the same.*/
- if (!mMVPMatrixInit) {
- /*For simplicity, we only consider the activity as portrait mode. In this case, the captured
- * images should be rotated 90 degrees (left or right).Thus the frame width and height
- * should be swapped.*/
- float frameRatio = DEFAULT_CAPTURE_HEIGHT / (float) DEFAULT_CAPTURE_WIDTH;
- float surfaceRatio = mSurfaceWidth / (float) mSurfaceHeight;
- Matrix.setIdentityM(mMVPMatrix, 0);
-
- if (frameRatio >= surfaceRatio) {
- float w = DEFAULT_CAPTURE_WIDTH * surfaceRatio;
- float scaleW = DEFAULT_CAPTURE_HEIGHT / w;
- Matrix.scaleM(mMVPMatrix, 0, scaleW, 1, 1);
- } else {
- float h = DEFAULT_CAPTURE_HEIGHT / surfaceRatio;
- float scaleH = DEFAULT_CAPTURE_WIDTH / h;
- Matrix.scaleM(mMVPMatrix, 0, 1, scaleH, 1);
- }
- mMVPMatrixInit = true;
- }
- GLES20.glViewport(0, 0, mSurfaceWidth, mSurfaceHeight);
- mProgram.drawFrame(mPreviewTexture, mTransform, mMVPMatrix);
- mEglCore.swapBuffers(mDrawSurface);
-
- if (joined) {
- VideoFrame.Buffer buffer = textureBufferHelper.invoke(new Callable() {
- @Override
- public VideoFrame.Buffer call() throws Exception {
- return textureBufferHelper.wrapTextureBuffer(DEFAULT_CAPTURE_HEIGHT,
- DEFAULT_CAPTURE_WIDTH, VideoFrame.TextureBuffer.Type.OES, mPreviewTexture,
- RendererCommon.convertMatrixToAndroidGraphicsMatrix(mTransform));
- }
- });
- VideoFrame frame = new VideoFrame(buffer, 0, 0);
- /*Pushes the video frame using the AgoraVideoFrame class and passes the video frame to the Agora SDK.
- * Call the setExternalVideoSource method and set pushMode as true before calling this
- * method. Otherwise, a failure returns after calling this method.
- * @param frame AgoraVideoFrame
- * @return
- * true: The frame is pushed successfully.
- * false: Failed to push the frame.
- * PS:
- * In the Communication profile, the SDK does not support textured video frames.*/
- boolean a = engine.pushExternalVideoFrame(frame);
- Log.d(TAG, "pushExternalVideoFrame:" + a);
- }
- }
-
- @Override
- public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
- Log.i(TAG, "onSurfaceTextureAvailable");
- mTextureDestroyed = false;
- mSurfaceWidth = width;
- mSurfaceHeight = height;
- /* handler associate to the GL thread which creates the texture.
- * in some condition SDK need to convert from texture format to YUV format, in this case,
- * SDK will use this handler to switch into the GL thread to complete the conversion.
- * */
- mHandler = new Handler(Looper.myLooper());
- mEglCore = new EglCore();
- if (!glPrepared) {
- // setup egl context
- EglBase.Context eglContext = new EglBase14.Context(mEglCore.getEGLContext());
- glPrepared = prepareGl(eglContext, width, height);
- }
- mDummySurface = mEglCore.createOffscreenSurface(1, 1);
- mEglCore.makeCurrent(mDummySurface);
- mPreviewTexture = GlUtil.createTextureObject(GLES11Ext.GL_TEXTURE_EXTERNAL_OES);
- mPreviewSurfaceTexture = new SurfaceTexture(mPreviewTexture);
- mPreviewSurfaceTexture.setOnFrameAvailableListener(this);
- mDrawSurface = mEglCore.createWindowSurface(surface);
- mProgram = new ProgramTextureOES();
- if (mCamera != null || mPreviewing) {
- Log.e(TAG, "Camera preview has been started");
- return;
- }
- try {
- mCamera = Camera.open(mFacing);
- /*It is assumed to capture images of resolution 640x480. During development, it should
- * be the most suitable supported resolution that best fits the scenario.*/
- Camera.Parameters parameters = mCamera.getParameters();
- parameters.setPreviewSize(DEFAULT_CAPTURE_WIDTH, DEFAULT_CAPTURE_HEIGHT);
- mCamera.setParameters(parameters);
- mCamera.setPreviewTexture(mPreviewSurfaceTexture);
- /*The display orientation is 90 for both front and back facing cameras using a surface
- * texture for the preview when the screen is in portrait mode.*/
- mCamera.setDisplayOrientation(90);
- mCamera.startPreview();
- mPreviewing = true;
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
-
- @Override
- public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {
-
- }
-
- @Override
- public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
- Log.i(TAG, "onSurfaceTextureDestroyed");
- mTextureDestroyed = true;
- if (mCamera != null && mPreviewing) {
- mCamera.stopPreview();
- mPreviewing = false;
- mCamera.release();
- mCamera = null;
- }
- mProgram.release();
- mEglCore.releaseSurface(mDummySurface);
- mEglCore.releaseSurface(mDrawSurface);
- mEglCore.release();
- return true;
- }
-
- @Override
- public void onSurfaceTextureUpdated(SurfaceTexture surface) {
-
- }
-
- /**
- * IRtcEngineEventHandler is an abstract class providing default implementation.
- * The SDK uses this class to report to the app on SDK runtime events.
- */
- private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
- /**
- * Error code description can be found at:
- * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror
- */
- @Override
- public void onError(int err) {
- Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err)));
- }
-
- /**Occurs when a user leaves the channel.
- * @param stats With this callback, the application retrieves the channel information,
- * such as the call duration and statistics.*/
- @Override
- public void onLeaveChannel(RtcStats stats) {
- super.onLeaveChannel(stats);
- Log.i(TAG, String.format("local user %d leaveChannel!", myUid));
- showLongToast(String.format("local user %d leaveChannel!", myUid));
- }
-
- /**Occurs when the local user joins a specified channel.
- * The channel name assignment is based on channelName specified in the joinChannel method.
- * If the uid is not specified when joinChannel is called, the server automatically assigns a uid.
- * @param channel Channel name
- * @param uid User ID
- * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/
- @Override
- public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
- Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
- myUid = uid;
- joined = true;
- handler.post(new Runnable() {
- @Override
- public void run() {
- join.setEnabled(true);
- join.setText(getString(R.string.leave));
- }
- });
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel.
- * @param uid ID of the user whose audio state changes.
- * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole
- * until this callback is triggered.*/
- @Override
- public void onUserJoined(int uid, int elapsed) {
- super.onUserJoined(uid, elapsed);
- Log.i(TAG, "onUserJoined->" + uid);
- showLongToast(String.format("user %d joined!", uid));
- /*Check if the context is correct*/
- Context context = getContext();
- if (context == null) {
- return;
- }
- handler.post(() -> {
- /*Display remote video stream*/
- // Create render view by RtcEngine
- SurfaceView surfaceView = new SurfaceView(context);
- surfaceView.setZOrderMediaOverlay(true);
- if (fl_remote.getChildCount() > 0) {
- fl_remote.removeAllViews();
- }
- // Add to the remote container
- fl_remote.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- // Setup remote video to render
- engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid));
- });
- }
-
- /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel.
- * @param uid ID of the user whose audio state changes.
- * @param reason Reason why the user goes offline:
- * USER_OFFLINE_QUIT(0): The user left the current channel.
- * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data
- * packet was received within a certain period of time. If a user quits the
- * call and the message is not passed to the SDK (due to an unreliable channel),
- * the SDK assumes the user dropped offline.
- * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from
- * the host to the audience.*/
- @Override
- public void onUserOffline(int uid, int reason) {
- Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason));
- showLongToast(String.format("user %d offline! reason:%d", uid, reason));
- handler.post(new Runnable() {
- @Override
- public void run() {
- /*Clear render view
- Note: The video will stay at its last frame, to completely remove it you will need to
- remove the SurfaceView from its parent*/
- engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid));
- }
- });
- }
- };
-}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideoYUV.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideoYUV.java
index 1bcb3a580..183aca4fa 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideoYUV.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideoYUV.java
@@ -136,7 +136,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -301,7 +301,7 @@ private void joinChannel(String channelId) {
fl_local.addView(textureView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
engine.startPreview(Constants.VideoSourceType.VIDEO_SOURCE_CUSTOM);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RTMPStreaming.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RTMPStreaming.java
index 46a20a4b7..5385417e3 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RTMPStreaming.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RTMPStreaming.java
@@ -115,7 +115,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -240,7 +240,7 @@ private void joinChannel(String channelId) {
/*Set up to play remote sound with receiver*/
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
index 3a31b7cb3..9aa4f0ee4 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/RhythmPlayer.java
@@ -102,7 +102,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -229,7 +229,7 @@ private void joinChannel(String channelId) {
engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
engine.enableAudioVolumeIndication(1000, 3, true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ScreenSharing.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ScreenSharing.java
index f750807d5..a4f4cbd87 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ScreenSharing.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ScreenSharing.java
@@ -156,7 +156,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -412,7 +412,7 @@ private void joinChannel() {
startScreenSharePreview();
}
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SendDataStream.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SendDataStream.java
index 70e44a44c..0311ad237 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SendDataStream.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SendDataStream.java
@@ -102,7 +102,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -240,7 +240,7 @@ private void joinChannel(String channelId) {
/*Set up to play remote sound with receiver*/
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SimpleExtension.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SimpleExtension.java
index e026253da..0a19f3db6 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SimpleExtension.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SimpleExtension.java
@@ -171,7 +171,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -333,7 +333,7 @@ private void joinChannel(String channelId) {
engine.setClientRole(CLIENT_ROLE_BROADCASTER);
engine.enableAudioVolumeIndication(1000, 3, false);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Simulcast.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Simulcast.java
index 4fb3144ef..025cd6e19 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Simulcast.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/Simulcast.java
@@ -122,7 +122,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -336,7 +336,7 @@ private void joinChannel(String channelId, boolean broadcast) {
engine.setSimulcastConfig(simulcastConfig);
}
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
index 36e5fd7c3..616988e25 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SpatialSound.java
@@ -104,7 +104,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
* How to get the App ID
* @param handler IRtcEngineEventHandler is an abstract class providing default implementation.
* The SDK uses this class to report to the app on SDK runtime events.*/
- String appId = getString(R.string.agora_app_id);
+ String appId = getAgoraAppId();
RtcEngineConfig config = new RtcEngineConfig();
config.mContext = getContext().getApplicationContext();
config.mAppId = appId;
@@ -219,7 +219,7 @@ private void joinChannel() {
engine.setClientRole(io.agora.rtc2.Constants.CLIENT_ROLE_BROADCASTER);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SwitchCameraScreenShare.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SwitchCameraScreenShare.java
index 7f3a6e08a..4f9cceefb 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SwitchCameraScreenShare.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/SwitchCameraScreenShare.java
@@ -110,7 +110,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -350,7 +350,7 @@ private void joinChannel(String channelId) {
/*Set up to play remote sound with receiver*/
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/TransparentRendering.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/TransparentRendering.java
index 556dd440f..b694eae98 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/TransparentRendering.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/TransparentRendering.java
@@ -98,7 +98,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -248,7 +248,7 @@ private void joinChannel(String channelId) {
option.publishCameraTrack = false;
option.publishCustomVideoTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/UrlLiveStream.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/UrlLiveStream.java
index a9a006e7c..11ae7f4f6 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/UrlLiveStream.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/UrlLiveStream.java
@@ -67,11 +67,11 @@ protected void initView() {
@Override
protected void initData() {
- binding.etRteUrl.setText("rte://" + getString(R.string.agora_app_id));
+ binding.etRteUrl.setText("rte://" + getAgoraAppId());
try {
mRte = new Rte(null);
Config config = new Config();
- config.setAppId(getContext().getString(R.string.agora_app_id));
+ config.setAppId(getAgoraAppId());
mRte.setConfigs(config);
mRte.initMediaEngine((Error error) -> {
io.agora.rte.Constants.ErrorCode errCode = error.code();
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoProcessExtension.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoProcessExtension.java
index f1a38a2b7..892f5c2c9 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoProcessExtension.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoProcessExtension.java
@@ -194,7 +194,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -293,7 +293,7 @@ private void joinChannel(String channelId) {
VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation())
));
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoQuickSwitch.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoQuickSwitch.java
index 44830dd19..7bac6cf96 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoQuickSwitch.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VideoQuickSwitch.java
@@ -103,7 +103,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -288,7 +288,7 @@ private void joinChannel(String channelId) {
/*Set up to play remote sound with receiver*/
engine.setDefaultAudioRoutetoSpeakerphone(true);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
index 270ec862c..d8b7ef145 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/VoiceEffects.java
@@ -249,7 +249,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -397,7 +397,7 @@ private void joinChannel(String channelId) {
Constants.AudioScenario.getValue(Constants.AudioScenario.valueOf(audioScenario.getSelectedItem().toString()))
);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/FaceUnityBeauty.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/FaceUnityBeauty.java
index 64ffd435a..05d82f6fa 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/FaceUnityBeauty.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/FaceUnityBeauty.java
@@ -201,7 +201,7 @@ public void onRemoteVideoStats(RemoteVideoStats stats) {
}
}
};
- rtcEngine = RtcEngine.create(getContext(), getString(R.string.agora_app_id), mRtcEngineEventHandler);
+ rtcEngine = RtcEngine.create(getContext(), getAgoraAppId(), mRtcEngineEventHandler);
if (rtcEngine == null) {
return;
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/SenseTimeBeauty.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/SenseTimeBeauty.java
index 03b69da34..dd3763b09 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/SenseTimeBeauty.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/beauty/SenseTimeBeauty.java
@@ -228,7 +228,7 @@ public void onRemoteVideoStats(RemoteVideoStats stats) {
}
}
};
- rtcEngine = RtcEngine.create(getContext(), getString(R.string.agora_app_id), mRtcEngineEventHandler);
+ rtcEngine = RtcEngine.create(getContext(), getAgoraAppId(), mRtcEngineEventHandler);
if (rtcEngine == null) {
return;
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
index 1124f07a6..09eef328a 100755
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioRender.java
@@ -106,7 +106,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -233,7 +233,7 @@ private void joinChannel(String channelId) {
engine.setExternalAudioSink(true, SAMPLE_RATE, SAMPLE_NUM_OF_CHANNEL);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
index ae9ab30e1..6443918d0 100755
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/customaudio/CustomAudioSource.java
@@ -121,7 +121,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -275,7 +275,7 @@ private void joinChannel(String channelId) {
config.enableLocalPlayback = false;
customAudioTrack = engine.createCustomAudioTrack(Constants.AudioTrackType.AUDIO_TRACK_MIXABLE, config);
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerExo.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerExo.java
index 367b01c4e..aa64576e3 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerExo.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerExo.java
@@ -68,7 +68,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -123,7 +123,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerIjk.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerIjk.java
index 808ec3cf9..c5a15375e 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerIjk.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerIjk.java
@@ -66,7 +66,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -120,7 +120,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerNative.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerNative.java
index fa3e86c44..e6563ea47 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerNative.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayerNative.java
@@ -67,7 +67,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -121,7 +121,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
index 109b634d0..848b1b76d 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioWaveform.java
@@ -62,7 +62,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -171,7 +171,7 @@ private void joinChannel(String channelId) {
option.autoSubscribeVideo = true;
option.publishMicrophoneTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
index 88cb69e8b..f83c52537 100755
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelAudio.java
@@ -299,7 +299,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -552,7 +552,7 @@ private void joinChannel(String channelId) {
option.autoSubscribeAudio = true;
option.autoSubscribeVideo = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelVideo.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelVideo.java
index 89c2d1149..0e1ed1432 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelVideo.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/basic/JoinChannelVideo.java
@@ -100,7 +100,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
/*
* The App ID issued to you by Agora. See How to get the App ID
*/
- config.mAppId = getString(R.string.agora_app_id);
+ config.mAppId = getAgoraAppId();
/* Sets the channel profile of the Agora RtcEngine.
CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile.
Use this profile in one-on-one calls or group calls, where all users can talk freely.
@@ -239,7 +239,7 @@ private void joinChannel(String channelId) {
option.publishMicrophoneTrack = true;
option.publishCameraTrack = true;
- /*Please configure accessToken in the string_config file.
+ /*
* A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see
* https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token
* A token generated at the server. This applies to scenarios with high-security requirements. For details, see
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java b/Android/APIExample/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java
new file mode 100644
index 000000000..4f1d3e4e5
--- /dev/null
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/utils/AgoraConfig.java
@@ -0,0 +1,22 @@
+package io.agora.api.example.utils;
+
+import android.text.TextUtils;
+
+import io.agora.api.example.BuildConfig;
+
+public final class AgoraConfig {
+ private AgoraConfig() {
+ }
+
+ public static String getAppId() {
+ return BuildConfig.AGORA_APP_ID;
+ }
+
+ public static String getAppCertificate() {
+ return BuildConfig.AGORA_APP_CERT;
+ }
+
+ public static boolean hasAppCertificate() {
+ return !TextUtils.isEmpty(getAppCertificate());
+ }
+}
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/utils/TokenUtils.java b/Android/APIExample/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
index e72d8185e..52ea546d8 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/utils/TokenUtils.java
@@ -14,7 +14,6 @@
import java.io.IOException;
import java.util.Objects;
-import io.agora.api.example.R;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
@@ -44,11 +43,11 @@ private TokenUtils() {
}
public static void genToken(Context context, String channelName, int uid, OnTokenGenCallback onGetToken) {
- String cert = context.getString(R.string.agora_app_certificate);
+ String cert = AgoraConfig.getAppCertificate();
if (cert.isEmpty()) {
onGetToken.onTokenGen("");
} else {
- gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), cert, channelName, uid, ret -> {
if (onGetToken != null) {
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
@@ -74,7 +73,7 @@ public static void genToken(Context context, String channelName, int uid, OnToke
* @param onGetToken the on get token
*/
public static void gen(Context context, String channelName, int uid, OnTokenGenCallback onGetToken) {
- gen(context.getString(R.string.agora_app_id), context.getString(R.string.agora_app_certificate), channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), AgoraConfig.getAppCertificate(), channelName, uid, ret -> {
if (onGetToken != null) {
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
diff --git a/Android/APIExample/app/src/main/res/layout/fragment_cdn_audience.xml b/Android/APIExample/app/src/main/res/layout/fragment_cdn_audience.xml
deleted file mode 100644
index d4736e9ff..000000000
--- a/Android/APIExample/app/src/main/res/layout/fragment_cdn_audience.xml
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Android/APIExample/app/src/main/res/layout/fragment_cdn_entry.xml b/Android/APIExample/app/src/main/res/layout/fragment_cdn_entry.xml
deleted file mode 100644
index 9f5115198..000000000
--- a/Android/APIExample/app/src/main/res/layout/fragment_cdn_entry.xml
+++ /dev/null
@@ -1,68 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Android/APIExample/app/src/main/res/layout/fragment_cdn_host.xml b/Android/APIExample/app/src/main/res/layout/fragment_cdn_host.xml
deleted file mode 100644
index 589d26031..000000000
--- a/Android/APIExample/app/src/main/res/layout/fragment_cdn_host.xml
+++ /dev/null
@@ -1,126 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Android/APIExample/app/src/main/res/layout/fragment_in_call_report.xml b/Android/APIExample/app/src/main/res/layout/fragment_in_call_report.xml
deleted file mode 100644
index f2ef0b03f..000000000
--- a/Android/APIExample/app/src/main/res/layout/fragment_in_call_report.xml
+++ /dev/null
@@ -1,93 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Android/APIExample/app/src/main/res/navigation/nav_graph.xml b/Android/APIExample/app/src/main/res/navigation/nav_graph.xml
index 08f4fdc45..579f8c9bb 100755
--- a/Android/APIExample/app/src/main/res/navigation/nav_graph.xml
+++ b/Android/APIExample/app/src/main/res/navigation/nav_graph.xml
@@ -35,9 +35,6 @@
-
-
-
@@ -95,9 +92,6 @@
-
@@ -175,20 +169,6 @@
android:name="io.agora.api.example.examples.basic.JoinChannelVideoByToken"
android:label="@string/item_joinvideo_by_token"
tools:layout="@layout/fragment_joinchannel_video_by_token" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 媒体流加密(自定义)媒体流加密设置视频编码属性
- 通话中质量监测RTC实时直播双进程屏幕共享现实增强集成
@@ -160,7 +159,6 @@
此示例演示在使用RTC通话中音频路由对第三方播放器的影响。此示例演示了在音视频通话过程中如何实现波形图此示例演示了在音视频通话过程中如何使用Agora SDK的媒体录制器录制本地或远端的视频。
- 此示例演示了在音视频通话过程中如何通过回调获取当前通话质量。>
此示例演示了在音视频通话过程中如何将A频道的主播流转发到B频道,实现主播PK。此示例演示了在音视频通话过程中如何进行音视频帧的加解密的方法。此示例演示了在音视频通话过程中如何通过VideoEncoderConfiguration来适配最佳视频性能。
diff --git a/Android/APIExample/app/src/main/res/values/string_configs.xml b/Android/APIExample/app/src/main/res/values/string_configs.xml
deleted file mode 100644
index 49b631ccc..000000000
--- a/Android/APIExample/app/src/main/res/values/string_configs.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
- YOUR APP ID
-
-
-
- YOUR APP CERTIFICATE
-
-
diff --git a/Android/APIExample/app/src/main/res/values/strings.xml b/Android/APIExample/app/src/main/res/values/strings.xml
index 848a2bdce..15153ceab 100644
--- a/Android/APIExample/app/src/main/res/values/strings.xml
+++ b/Android/APIExample/app/src/main/res/values/strings.xml
@@ -117,7 +117,6 @@
Media Stream Encryption(Custom Encoder)Media Stream EncryptionSet the Video Profile
- Report In-call StatisticsSuper ResolutionRTC Live StreamingMulti Process - Screen Sharing
@@ -166,7 +165,6 @@
This example shows the behavior of audio router while communicating with rtc.This example demonstrates how to display waveform by rtc engine.This example demonstrates how to use MediaRecorder to recorde local or remote video.
- This example demonstrates how to display in call statistics.This example demonstrates how to transfer media streaming to another rtc channel.This example demonstrates how to encrypt and decrypt audio and video frames during audio and video calls.This example demonstrates how to use VideoEncoderConfiguration to adjust video configurations.
diff --git a/Android/APIExample/ci.env.py b/Android/APIExample/ci.env.py
index 5e9491cd9..872df41ae 100644
--- a/Android/APIExample/ci.env.py
+++ b/Android/APIExample/ci.env.py
@@ -1,25 +1,30 @@
-#!/usr/bin/python
-# -*- coding: UTF-8 -*-
-import re
import os
+from pathlib import Path
-def main():
- appId = ""
- if "AGORA_APP_ID" in os.environ:
- appId = os.environ["AGORA_APP_ID"]
- token = ""
- f = open("./app/src/main/res/values/string_configs.xml", 'r+')
- content = f.read()
- contentNew = re.sub(r'YOUR APP ID', appId, content)
- contentNew = re.sub(r'YOUR ACCESS TOKEN', token, contentNew)
- f.seek(0)
- f.write(contentNew)
- f.truncate()
+def upsert_property(lines, key, value):
+ target = f"{key}="
+ replaced = False
+ new_lines = []
+ for line in lines:
+ if line.startswith(target):
+ new_lines.append(f"{target}{value}\n")
+ replaced = True
+ else:
+ new_lines.append(line)
+ if not replaced and value:
+ new_lines.append(f"{target}{value}\n")
+ return new_lines
+
+def main():
+ app_id = os.environ.get("AGORA_APP_ID", "")
+ app_cert = os.environ.get("AGORA_APP_CERT", "") or os.environ.get("AGORA_APP_CERTIFICATE", "")
+ local_properties = Path("./local.properties")
+ lines = local_properties.read_text().splitlines(keepends=True) if local_properties.exists() else []
+ lines = upsert_property(lines, "AGORA_APP_ID", app_id)
+ lines = upsert_property(lines, "AGORA_APP_CERT", app_cert)
+ local_properties.write_text("".join(lines))
if __name__ == "__main__":
main()
-
-
-
diff --git a/Android/APIExample/cloud_build.sh b/Android/APIExample/cloud_build.sh
index 6f4cf9b60..7bb5103c5 100755
--- a/Android/APIExample/cloud_build.sh
+++ b/Android/APIExample/cloud_build.sh
@@ -34,11 +34,23 @@ fi
#sed -ie "s#google()#maven { url \"https\://maven.aliyun.com/repository/public\" }\n google()#g" settings.gradle
#sed -ie "s#https://services.gradle.org/distributions#https://mirrors.cloud.tencent.com/gradle#g" gradle/wrapper/gradle-wrapper.properties
+set_local_property() {
+ key="$1"
+ value="$2"
+ file="local.properties"
+ touch "$file"
+ if grep -q "^${key}=" "$file"; then
+ sed -i.bak "s#^${key}=.*#${key}=${value}#g" "$file"
+ rm -f "${file}.bak"
+ elif [ -n "$value" ]; then
+ echo "${key}=${value}" >> "$file"
+ fi
+}
+
## config appId
-sed -i -e "s#YOUR APP ID#${APP_ID}#g" app/src/main/res/values/string_configs.xml
-sed -i -e "s#YOUR APP CERTIFICATE##g" app/src/main/res/values/string_configs.xml
-sed -i -e "s#YOUR ACCESS TOKEN##g" app/src/main/res/values/string_configs.xml
-rm -f app/src/main/res/values/string_configs.xml-e
+set_local_property "AGORA_APP_ID" "${APP_ID}"
+APP_CERT_VALUE="${APP_CERT:-${AGORA_APP_CERT:-${AGORA_APP_CERTIFICATE:-}}}"
+set_local_property "AGORA_APP_CERT" "${APP_CERT_VALUE}"
echo "First argument: $1"
echo "Second argument: $2"
if [ "$1" = "false" ]; then
@@ -110,4 +122,4 @@ if [ "$WORKSPACE" != "" ]; then
APK_NAME="${PROJECT_NAME}_${BUILD_NUMBER}_${SDK_VERSION}_$(date "+%Y%m%d%H%M%S").apk"
echo "Copying APK to: $WORKSPACE/$APK_NAME"
cp app/build/outputs/apk/release/*.apk "$WORKSPACE/$APK_NAME"
-fi
\ No newline at end of file
+fi
diff --git a/Android/ARCHITECTURE.md b/Android/ARCHITECTURE.md
new file mode 100644
index 000000000..c709c9421
--- /dev/null
+++ b/Android/ARCHITECTURE.md
@@ -0,0 +1,38 @@
+# ARCHITECTURE.md
+
+Three independent Android projects, each with its own Gradle root and APK output.
+For internal details of each project, see the project-level `ARCHITECTURE.md`.
+
+---
+
+## APIExample — Full Demo
+
+- Package: `io.agora.api.example`
+- SDK: `cn.shengwang.rtc:full-sdk` + `full-screen-sharing`
+- Language: Java + Kotlin mixed
+- UI: XML layouts + ViewBinding, Jetpack Navigation
+- Case registration: reflection-based via `@Example` annotation + `ClassUtils` DEX scan
+- Optional modules: `agora-simple-filter` (C++ extension), `agora-stream-encrypt`
+- Details: `APIExample/ARCHITECTURE.md`
+
+---
+
+## APIExample-Audio — Audio-Only Demo
+
+- Package: `io.agora.api.example.audio`
+- SDK: `cn.shengwang.rtc:voice-sdk` (no video module)
+- Language: Java + Kotlin mixed
+- UI: XML layouts + ViewBinding, Jetpack Navigation
+- Case registration: identical to APIExample — `@Example` annotation + `ClassUtils` DEX scan
+- Details: `APIExample-Audio/ARCHITECTURE.md`
+
+---
+
+## APIExample-Compose — Jetpack Compose Demo
+
+- Package: `io.agora.api.example.compose`
+- SDK: `cn.shengwang.rtc:full-sdk` + `full-screen-sharing`
+- Language: Kotlin only
+- UI: Jetpack Compose + Compose Navigation, no XML layouts
+- Case registration: manual — add entry to `model/Examples.kt` + create `samples/MyCase.kt`
+- Details: `APIExample-Compose/ARCHITECTURE.md`
diff --git a/Android/CLAUDE.md b/Android/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/Android/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..50fc67993
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` as the primary reference for AI agents.
+
+Please see @AGENTS.md in this same directory and treat its content as the authoritative guide for navigating and working within this repository.
diff --git a/iOS/AGENTS.md b/iOS/AGENTS.md
new file mode 100644
index 000000000..cca86ab28
--- /dev/null
+++ b/iOS/AGENTS.md
@@ -0,0 +1,38 @@
+# AGENTS.md
+
+Entry point for AI agents working on iOS examples. Read this first, then go to the relevant project's own `AGENTS.md`.
+
+## Projects
+
+| Project | SDK | Purpose |
+|---------|-----|---------|
+| `APIExample/` | `AgoraRtcEngine_iOS` | Full demo — all APIs, UIKit + Swift, default choice |
+| `APIExample-SwiftUI/` | `AgoraRtcEngine_iOS` | SwiftUI variant, mirrors APIExample cases |
+| `APIExample-OC/` | `AgoraRtcEngine_iOS` | Objective-C variant, mirrors APIExample cases |
+| `APIExample-Audio/` | `AgoraAudio_iOS` | Audio-only — no video APIs available |
+
+SDK version: each project's `Podfile` specifies the version.
+
+## Which Project to Use
+
+- Need video call, screen sharing, beauty filters, or extensions → `APIExample/`
+- Audio-only features (voice call, audio effects, spatial audio) → `APIExample-Audio/`
+- Building with SwiftUI, or porting an existing case to SwiftUI → `APIExample-SwiftUI/`
+- Need Objective-C implementation → `APIExample-OC/`
+- Not sure → default to `APIExample/`
+
+## Architecture Red Lines
+
+- Do NOT share source files, storyboards, or SDK dependencies between projects
+- Do NOT add video rendering APIs (`enableVideo`, `setupLocalVideo`) to `APIExample-Audio/`
+- Do NOT call SDK APIs on a background thread without dispatching UI updates to the main thread
+- Do NOT commit `KeyCenter.swift` / `KeyCenter.m` with real App IDs or certificates
+- Always call `leaveChannel()` and `AgoraRtcEngineKit.destroy()` when an example screen is closed
+
+## Further Reading
+
+- `ARCHITECTURE.md` — four-project structure overview
+- `APIExample/AGENTS.md` — build commands, config, Skills for the full demo
+- `APIExample-SwiftUI/AGENTS.md` — same for the SwiftUI demo
+- `APIExample-OC/AGENTS.md` — same for the Objective-C demo
+- `APIExample-Audio/AGENTS.md` — same for the audio demo
diff --git a/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..6b6676b67
--- /dev/null
+++ b/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,47 @@
+---
+name: query-cases
+description: >
+ Find existing audio API demo cases in the APIExample-Audio project by feature name, API name, or keyword.
+ Use this before creating a new case to avoid duplication.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# query-cases — APIExample-Audio
+
+## When to Use
+
+- User asks "where is the voice changer example?"
+- User wants to find code for a specific Agora audio API
+- Before creating a new case, to confirm it does not already exist
+
+## Quick Search (try this first)
+
+Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. This project has 11 cases total, so the Case Index is the fastest lookup.
+
+## Deep Search (for complex queries)
+
+1. Check `APIExample-Audio/ViewController.swift` — the `menus` array lists all registered cases
+2. Source files are at:
+ - `APIExample-Audio/Examples/Basic//.swift`
+ - `APIExample-Audio/Examples/Advanced//.swift`
+
+## Common Query Patterns
+
+| Query | Where to look |
+|-------|--------------|
+| Feature by name (e.g. "spatial audio") | Case Index — search Description column |
+| API by method name (e.g. `setAudioProfile`) | Case Index — search Key APIs column |
+| All basic cases | Case Index — filter by Path prefix `Basic/` |
+| Cases using custom audio | Case Index — search for `CustomAudio` or `pushExternal` |
+
+## Output Format
+
+Report results as:
+- Case name and file path
+- Key APIs demonstrated
+- One-line description
diff --git a/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md b/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..c1cb428f8
--- /dev/null
+++ b/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,131 @@
+---
+name: review-case
+description: >
+ Structured code review for a case in the APIExample-Audio project.
+ Checks engine lifecycle, audio-only constraints, thread safety, permissions, and API correctness.
+ This project uses AgoraAudio_iOS — video APIs must not appear.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# review-case — APIExample-Audio
+
+## Review Dimensions (in priority order)
+
+### 1. Audio-Only Constraint (highest priority for this project)
+
+**Check:**
+- No calls to `enableVideo()`, `disableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `startPreview()`, `stopPreview()`
+- No `AgoraRtcVideoCanvas` instantiation
+- No `VideoView` or video rendering views in storyboard or code
+- No camera permission requests
+
+Any video API call in this project is a critical error — the SDK will crash or silently fail.
+
+---
+
+### 2. Engine Lifecycle
+
+**Check:**
+- `AgoraRtcEngineKit.sharedEngine(with:delegate:)` called in `viewDidLoad` (not in Entry VC)
+- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil`
+- No engine instance stored beyond the Main VC's lifetime
+
+**Correct:**
+```swift
+override func willMove(toParent parent: UIViewController?) {
+ super.willMove(toParent: parent)
+ if parent == nil {
+ agoraKit?.leaveChannel()
+ AgoraRtcEngineKit.destroy()
+ }
+}
+```
+
+---
+
+### 3. Thread Safety
+
+All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread.
+
+**Check:**
+- Every UI update inside a delegate callback is wrapped in `DispatchQueue.main.async { }`
+- No UIKit objects mutated directly in callbacks
+
+---
+
+### 4. Permissions
+
+**Check:**
+- Microphone permission requested before `joinChannel()`
+- `joinChannel()` called only inside the permission grant callback
+- No camera permission requests (audio-only project)
+
+---
+
+### 5. Error Handling
+
+**Check:**
+- Return value of `joinChannel()` checked
+- `rtcEngine(_:didOccurError:)` implemented and logged
+- Token expiry handled if token is used
+
+---
+
+### 6. Code Conventions
+
+**Check:**
+- Entry class inherits `UIViewController`, Main class inherits `BaseViewController`
+- Class names follow `Entry` / `Main` pattern
+- `configs` dictionary used to pass data from Entry to Main
+- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section
+- Storyboard contains only audio controls (labels, sliders, buttons) — no video views
+
+---
+
+### 7. Audio API Usage
+
+**Check:**
+- `setAudioProfile(_:)` called before `joinChannel()` if non-default profile needed
+- `setAudioScenario(_:)` called before `joinChannel()` if non-default scenario needed
+- `enableAudioVolumeIndication(_:smooth:reportVad:)` called if volume callbacks are needed
+- Custom audio tracks stopped and released on exit
+- External audio sinks disabled on exit if `enableExternalAudioSink` was called
+
+---
+
+### 8. Resource Cleanup
+
+**Check:**
+- Audio mixing stopped (`stopAudioMixing()`) if started
+- Rhythm player stopped (`stopRhythmPlayer()`) if started
+- Echo test stopped (`stopEchoTest()`) if started
+- Last-mile probe stopped (`stopLastmileProbeTest()`) if started
+- Custom audio tracks destroyed on exit
+
+---
+
+## Review Output Format
+
+```
+[SEVERITY] file/line — issue description
+Suggestion: how to fix
+```
+
+Severity levels:
+- `[CRITICAL]` — crash, leak, video API in audio-only project, or incorrect behavior
+- `[WARNING]` — convention violation or subtle bug risk
+- `[INFO]` — style or minor improvement
+
+---
+
+## Audio-Specific iOS Checks
+
+- `AVAudioSession` category should be `.playAndRecord` with `.defaultToSpeaker` option for most audio cases
+- Background audio: verify `UIBackgroundModes` includes `audio` in `Info.plist` if background playback is needed
+- In-ear monitoring (`enable(inEarMonitoring:)`) only works with wired headphones — document this limitation in the case if relevant
+- `[weak self]` required in all closures capturing `self` to avoid retain cycles
diff --git a/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..3e59cbd14
--- /dev/null
+++ b/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,158 @@
+---
+name: upsert-case
+description: >
+ Add a new audio API demo case or modify an existing one in the APIExample-Audio project.
+ Uses AgoraAudio_iOS SDK — no video APIs available. Covers folder creation, Entry/Main Swift file,
+ storyboard, MenuItem registration, and Case Index update.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# upsert-case — APIExample-Audio
+
+## When to Use
+
+- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/`
+- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+
+
+Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist.
+
+> **Audio-only constraint**: this project uses `AgoraAudio_iOS` SDK. The video module is not available.
+> Do NOT add any video API calls. See the NEVER list below.
+
+## Files to Touch
+
+| Scenario | Files |
+|----------|-------|
+| Add new case | New folder + `.swift` file + `.storyboard`, `ViewController.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) |
+| Modify existing case | Existing `.swift` file(s), optionally `.storyboard`, `ARCHITECTURE.md` (Case Index) |
+
+---
+
+## Step 1 — Create the Example Folder
+
+```
+APIExample-Audio/Examples/[Basic|Advanced]//
+```
+
+## Step 2 — Create the Swift File
+
+Create `.swift` with Entry and Main classes:
+
+```swift
+import UIKit
+import AgoraRtcKit
+
+class Entry: UIViewController {
+ @IBOutlet weak var channelTextField: UITextField!
+
+ @IBAction func onJoinPressed(_ sender: UIButton) {
+ guard let channelName = channelTextField.text, !channelName.isEmpty else { return }
+ let storyboard = UIStoryboard(name: "", bundle: nil)
+ guard let mainVC = storyboard.instantiateViewController(
+ withIdentifier: "") as? Main else { return }
+ mainVC.configs = ["channelName": channelName]
+ navigationController?.pushViewController(mainVC, animated: true)
+ }
+}
+
+class Main: BaseViewController {
+ var agoraKit: AgoraRtcEngineKit?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ guard let channelName = configs["channelName"] as? String else { return }
+ let config = AgoraRtcEngineConfig()
+ config.appId = KeyCenter.AppId
+ agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)
+ agoraKit?.setAudioProfile(.default)
+ // request microphone permission, then join
+ NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in
+ let option = AgoraRtcChannelMediaOptions()
+ option.publishMicrophoneTrack = true
+ self?.agoraKit?.joinChannel(byToken: token, channelId: channelName,
+ uid: 0, mediaOptions: option)
+ }
+ }
+
+ override func willMove(toParent parent: UIViewController?) {
+ super.willMove(toParent: parent)
+ if parent == nil {
+ agoraKit?.leaveChannel()
+ AgoraRtcEngineKit.destroy()
+ }
+ }
+}
+
+extension Main: AgoraRtcEngineDelegate {
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String,
+ withUid uid: UInt, elapsed: Int) {
+ LogUtils.log(message: "Joined: \(channel) uid: \(uid)", level: .info)
+ }
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
+ LogUtils.log(message: "Error: \(errorCode.rawValue)", level: .error)
+ }
+}
+```
+
+## Step 3 — Create the Storyboard
+
+Create `APIExample-Audio/Base.lproj/.storyboard` with two scenes:
+
+| Scene | Storyboard ID | Class |
+|-------|--------------|-------|
+| Entry | `EntryViewController` | `Entry` |
+| Main | `` | `Main` |
+
+UI should contain only audio controls — no video rendering views.
+
+## Step 4 — Register the MenuItem
+
+Add to the `menus` array in `APIExample-Audio/ViewController.swift`:
+
+```swift
+MenuItem(name: "".localized,
+ storyboard: "",
+ controller: "")
+```
+
+## Step 5 — Update the Case Index
+
+Add a row to the `## Case Index` table in `ARCHITECTURE.md`:
+
+```markdown
+| | `Examples/[Basic|Advanced]//.swift` | `keyApi1()`, `keyApi2()` | One-line description |
+```
+
+---
+
+## Verification Checklist
+
+- [ ] Folder created under correct category (Basic / Advanced)
+- [ ] Both Entry and Main classes exist in the Swift file
+- [ ] Main inherits `BaseViewController`
+- [ ] Storyboard has correct scene IDs
+- [ ] No video rendering views in the storyboard
+- [ ] MenuItem added to `ViewController.swift`
+- [ ] `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil`
+- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main`
+- [ ] Microphone permission requested before `joinChannel()`
+- [ ] Case Index row added/updated in `ARCHITECTURE.md`
+- [ ] Project builds without errors
+
+---
+
+## NEVER
+
+- NEVER call `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, or `startPreview()` — the SDK has no video module
+- NEVER add `AgoraRtcVideoCanvas` or `VideoView` to any storyboard or code in this project
+- NEVER create `AgoraRtcEngineKit` in the Entry VC
+- NEVER call `leaveChannel` or `destroy` in `viewDidDisappear` — use `willMove(toParent:)` with `parent == nil`
+- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }`
+- NEVER share an `AgoraRtcEngineKit` instance between cases
+- NEVER skip updating the Case Index in `ARCHITECTURE.md`
diff --git a/iOS/APIExample-Audio/AGENTS.md b/iOS/APIExample-Audio/AGENTS.md
new file mode 100644
index 000000000..f42c6f8a8
--- /dev/null
+++ b/iOS/APIExample-Audio/AGENTS.md
@@ -0,0 +1,32 @@
+# AGENTS.md — APIExample-Audio
+
+Audio-only demo project. Uses `AgoraAudio_iOS` SDK — the video module is not included.
+
+## Build Commands
+
+```bash
+pod install
+# Then open APIExample-Audio.xcworkspace in Xcode and build (Cmd+B)
+```
+
+## App ID Configuration
+
+Edit `APIExample-Audio/Common/KeyCenter.swift`:
+```swift
+static let AppId: String = "YOUR_APP_ID"
+static let Certificate: String? = nil // leave nil if App Certificate is not enabled
+```
+
+To obtain an App ID, see [README.md](README.md#obtain-an-app-id).
+
+## Skills
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new audio API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and audio-only convention compliance |
+| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle
diff --git a/iOS/APIExample-Audio/ARCHITECTURE.md b/iOS/APIExample-Audio/ARCHITECTURE.md
new file mode 100644
index 000000000..823be6545
--- /dev/null
+++ b/iOS/APIExample-Audio/ARCHITECTURE.md
@@ -0,0 +1,131 @@
+# ARCHITECTURE.md — APIExample-Audio
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.swift` | `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()`, `enable(inEarMonitoring:)` | Basic audio call with profile, scenario, volume, and in-ear monitoring controls |
+| JoinChannelAudio(Token) | `Examples/Basic/JoinChannelAudio(Token)/JoinChannelAudioToken.swift` | `joinChannel(byToken:)`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()` | Audio call with token authentication |
+| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.swift` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setLocalVoiceEqualizationOf()` | Voice beautifier, effects, conversion presets, and equalizer |
+| CustomAudioSource | `Examples/Advanced/CustomAudioSource/CustomAudioSource.swift` | `setExternalAudioSource()` | Push custom audio via external audio source API |
+| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.swift` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as mixable audio track |
+| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.swift` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering |
+| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.swift` | `setAudioFrameDelegate()` | Capture raw audio PCM data via delegate |
+| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.swift` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input |
+| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.swift` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio |
+| PrecallTest | `Examples/Advanced/PrecallTest/PrecallTest.swift` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()` | Pre-call echo test and last-mile network probe |
+| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.swift` | `createMediaPlayer()`, `updateChannel()`, `setEnableSpeakerphone()` | 3D spatial audio with media player integration |
+
+## Directory Layout
+
+```
+APIExample-Audio/
+├── Podfile # CocoaPods dependencies (AgoraAudio_iOS, Floaty, AGEVideoLayout)
+└── APIExample-Audio/
+ ├── AppDelegate.swift
+ ├── ViewController.swift # Root menu controller — MenuItem registration lives here
+ ├── Info.plist
+ ├── APIExample.entitlements
+ ├── APIExample-Bridging-Header.h
+ │
+ ├── Common/
+ │ ├── KeyCenter.swift # App ID and Certificate
+ │ ├── GlobalSettings.swift # Shared runtime config
+ │ ├── BaseViewController.swift # Base class all Main VCs must extend
+ │ ├── EntryViewController.swift # Generic Entry VC for storyboard == "Main" cases
+ │ ├── LogViewController.swift # Log viewer
+ │ ├── AlertManager.swift
+ │ ├── AgoraExtension.swift
+ │ ├── StatisticsInfo.swift
+ │ ├── UITypeAlias.swift
+ │ ├── VideoView.swift / .xib # Audio seat view (no video rendering)
+ │ ├── Settings/ # Settings UI components
+ │ ├── Utils/ # LogUtils, Util (privatization config)
+ │ ├── NetworkManager/ # Token request helper
+ │ ├── ExternalAudio/ # External audio source helpers
+ │ └── ExternalVideo/ # (unused in audio project)
+ │
+ ├── Examples/
+ │ ├── Basic/
+ │ │ ├── JoinChannelAudio/ # "Join a channel (Audio)"
+ │ │ └── JoinChannelAudio(Token)/ # "Join a channel (Token)"
+ │ └── Advanced/
+ │ ├── VoiceChanger/ # "Voice Changer" — voice beautifier/effects
+ │ ├── CustomAudioSource/ # "Custom Audio Source"
+ │ ├── CustomPcmAudioSource/ # "Custom Audio Source (PCM)"
+ │ ├── CustomAudioRender/ # "Custom Audio Render"
+ │ ├── RawAudioData/ # "Raw Audio Data"
+ │ ├── AudioMixing/ # "Audio Mixing"
+ │ ├── RhythmPlayer/ # "Rhythm Player"
+ │ ├── PrecallTest/ # "Precall Test"
+ │ └── SpatialAudio/ # "Spatial Audio"
+ │
+ ├── Resources/ # Audio sample files
+ ├── Assets.xcassets/
+ ├── Base.lproj/ # Main.storyboard, LaunchScreen.storyboard
+ └── zh-Hans.lproj/ # Chinese localization
+```
+
+## Case Registration Mechanism
+
+Registration is **manual** via the `menus` array in `ViewController.swift`. Identical to `APIExample`.
+
+**`MenuItem` struct:**
+```swift
+struct MenuItem {
+ var name: String // display name in the list
+ var entry: String // storyboard ID of the entry VC (default: "EntryViewController")
+ var storyboard: String // storyboard file name (default: "Main")
+ var controller: String // storyboard ID of the main VC
+ var note: String // optional description
+}
+```
+
+**To add a case, edit exactly two things:**
+1. Add a `MenuItem` to the `menus` array in `ViewController.swift`
+2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift file(s) and storyboard
+
+## Entry/Main ViewController Pattern
+
+Identical to `APIExample`:
+
+**Entry** (`Entry : UIViewController`)
+- Collects user configuration before entering the example
+- Passes configuration to Main via a `configs` dictionary
+
+**Main** (`Main : BaseViewController`)
+- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example
+- Implements `AgoraRtcEngineDelegate`
+- Receives configuration exclusively through `configs`
+- UI contains only audio controls — no video rendering views
+
+## Audio-Only Constraint
+
+This project uses `AgoraAudio_iOS` SDK which has no video module. Main view controllers must NOT include:
+- Video rendering views or video canvas setup
+- Calls to `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`
+- Camera-related APIs
+
+All UI is limited to audio controls, status indicators, and effect parameter inputs.
+
+## AgoraRtcEngineKit Lifecycle
+
+```
+viewDidLoad → AgoraRtcEngineKit.sharedEngine(withAppId:delegate:)
+ → engine.setAudioProfile / setAudioScenario
+ → engine.joinChannel() (after RECORD_AUDIO permission granted)
+ ↓
+ [AgoraRtcEngineDelegate callbacks — may be on background thread]
+ ↓
+viewDidDisappear / willMove(toParent:)
+ → engine.leaveChannel()
+ → AgoraRtcEngineKit.destroy()
+```
+
+## Token Flow
+
+```swift
+NetworkManager.shared.generateToken(channelName: channelId, uid: uid) { token in
+ self.agoraKit?.joinChannel(byToken: token, channelId: channelId, uid: uid, mediaOptions: options)
+}
+```
diff --git a/iOS/APIExample-Audio/CLAUDE.md b/iOS/APIExample-Audio/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/iOS/APIExample-Audio/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..8817e67f1
--- /dev/null
+++ b/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: query-cases
+description: >
+ Find existing API demo cases in the APIExample-OC project by feature name, API name, or keyword.
+ Use this before creating a new case to avoid duplication.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# query-cases — APIExample-OC
+
+## When to Use
+
+- User asks "where is the screen sharing example?"
+- User wants to find code for a specific Agora SDK API
+- Before creating a new case, to confirm it does not already exist
+
+## Quick Search (try this first)
+
+Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file.
+
+## Deep Search (for complex queries)
+
+1. Check `APIExample-OC/ViewController.m` — the `+[MenuSection menus]` method lists all registered cases
+2. Source files are at:
+ - `APIExample-OC/Examples/Basic//.m`
+ - `APIExample-OC/Examples/Advanced//.m`
+3. Each case folder contains:
+ - `.h` — class declarations
+ - `.m` — Entry and Main implementations
+
+## Common Query Patterns
+
+| Query | Where to look |
+|-------|--------------|
+| Feature by name | Case Index — search Description column |
+| API by method name (OC syntax) | Case Index — search Key APIs column; or grep `.m` files |
+| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` |
+| Cases using a specific pattern | Grep `.m` files under `Examples/` |
+
+## Output Format
+
+Report results as:
+- Case name and file path
+- Key APIs demonstrated (OC method syntax)
+- One-line description
diff --git a/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md b/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..2c9d57145
--- /dev/null
+++ b/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,158 @@
+---
+name: review-case
+description: >
+ Structured code review for a case in the APIExample-OC (Objective-C + UIKit) project.
+ Checks engine lifecycle, thread safety, memory management, permissions, and OC conventions.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# review-case — APIExample-OC
+
+## Review Dimensions (in priority order)
+
+### 1. Engine Lifecycle
+
+**Check:**
+- `[AgoraRtcEngineKit sharedEngineWithConfig:delegate:]` called in `viewDidLoad` (not in Entry VC)
+- `[self.agoraKit leaveChannel:]` + `[AgoraRtcEngineKit destroy]` called when leaving
+- Cleanup triggered by `isMovingFromParentViewController` in `viewDidDisappear:`, or in `dealloc`
+
+**Correct:**
+```objc
+- (void)viewDidDisappear:(BOOL)animated {
+ [super viewDidDisappear:animated];
+ if (self.isMovingFromParentViewController) {
+ [self.agoraKit leaveChannel:nil];
+ [AgoraRtcEngineKit destroy];
+ }
+}
+```
+
+**Wrong:**
+```objc
+// Missing destroy — engine leaks
+- (void)viewDidDisappear:(BOOL)animated {
+ [self.agoraKit leaveChannel:nil];
+}
+```
+
+---
+
+### 2. Thread Safety
+
+All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread.
+
+**Check:**
+- Every UI update inside a delegate callback is wrapped in `dispatch_async(dispatch_get_main_queue(), ^{ })`
+- No `UIView` or other UIKit objects mutated directly in callbacks
+
+**Correct:**
+```objc
+- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinedOfUid:(NSUInteger)uid elapsed:(NSInteger)elapsed {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ [self setupRemoteVideoWithUid:uid];
+ });
+}
+```
+
+**Wrong:**
+```objc
+- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinedOfUid:(NSUInteger)uid elapsed:(NSInteger)elapsed {
+ [self setupRemoteVideoWithUid:uid]; // UI update on background thread
+}
+```
+
+---
+
+### 3. Memory Management
+
+**Check:**
+- `__weak typeof(self) weakSelf = self` used in all blocks that capture `self`
+- Delegate property on `AgoraRtcEngineKit` is `weak` (it is by SDK design, but verify no strong cycle)
+- No `__unsafe_unretained` used for delegate or view references
+
+**Correct:**
+```objc
+__weak typeof(self) weakSelf = self;
+[[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) {
+ [weakSelf.agoraKit joinChannelByToken:token ...];
+}];
+```
+
+---
+
+### 4. Permissions
+
+**Check:**
+- Camera permission requested before `joinChannelByToken:` for video cases
+- Microphone permission requested before `joinChannelByToken:` for all cases
+- `joinChannelByToken:` called only inside the permission grant callback
+
+---
+
+### 5. Error Handling
+
+**Check:**
+- Return value of `joinChannelByToken:` checked (non-zero = error)
+- `rtcEngine:didOccurError:` delegate method implemented and logged
+- Token expiry handled via `rtcEngine:tokenPrivilegeWillExpire:` if token is used
+
+---
+
+### 6. Code Conventions
+
+**Check:**
+- Entry class inherits `UIViewController`, Main class inherits `BaseViewController`
+- Class names follow `Entry` / `Main` pattern
+- `configs` dictionary (`NSDictionary`) used to pass data from Entry to Main
+- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section
+- Both `.h` and `.m` files present; public interface minimal in `.h`
+
+---
+
+### 7. API Usage Correctness
+
+**Check:**
+- `setVideoEncoderConfiguration:` called before `joinChannelByToken:`
+- `setupLocalVideo:` called before `startPreview` and `joinChannelByToken:`
+- `enableVideo` called before `setupLocalVideo:` for video cases
+- `setClientRole:` called before `joinChannelByToken:` for live streaming cases
+
+---
+
+### 8. Resource Cleanup
+
+**Check:**
+- Audio files / custom audio tracks stopped and released on exit
+- External video sources unregistered on exit
+- Media player destroyed if created (`[self.agoraKit destroyMediaPlayer:player]`)
+- Screen capture stopped if started
+- Multi-camera capture stopped if started
+
+---
+
+## Review Output Format
+
+```
+[SEVERITY] file/line — issue description
+Suggestion: how to fix
+```
+
+Severity levels:
+- `[CRITICAL]` — crash, leak, or incorrect behavior
+- `[WARNING]` — convention violation or subtle bug risk
+- `[INFO]` — style or minor improvement
+
+---
+
+## OC-Specific Checks
+
+- Verify `NS_ASSUME_NONNULL_BEGIN/END` wraps the header to reduce nullability warnings
+- Verify `IBOutlet` properties are `weak` (Xcode default, but worth confirming)
+- `isMovingFromParentViewController` is the correct guard in `viewDidDisappear:` for navigation-based cleanup — do NOT use `isBeingDismissed` (that's for modal presentation)
+- ARC is enabled — no manual `retain`/`release` calls should appear
diff --git a/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..92a9c1d7c
--- /dev/null
+++ b/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,173 @@
+---
+name: upsert-case
+description: >
+ Add a new API demo case or modify an existing one in the APIExample-OC (Objective-C + UIKit) project.
+ Covers folder creation, Entry/Main OC files, storyboard, MenuItem registration, and Case Index update.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# upsert-case — APIExample-OC
+
+## When to Use
+
+- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/`
+- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+
+
+Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist.
+
+## Files to Touch
+
+| Scenario | Files |
+|----------|-------|
+| Add new case | New folder + `.h/.m` files + `.storyboard`, `ViewController.m` (MenuItem), `ARCHITECTURE.md` (Case Index) |
+| Modify existing case | Existing `.h/.m` files, optionally `.storyboard`, `ARCHITECTURE.md` (Case Index) |
+
+---
+
+## Step 1 — Create the Example Folder
+
+```
+APIExample-OC/Examples/[Basic|Advanced]//
+```
+
+## Step 2 — Create the Header File
+
+Create `.h`:
+
+```objc
+#import "BaseViewController.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface Entry : UIViewController
+@end
+
+@interface Main : BaseViewController
+@end
+
+NS_ASSUME_NONNULL_END
+```
+
+## Step 3 — Create the Implementation File
+
+Create `.m`:
+
+```objc
+#import ".h"
+#import
+#import "KeyCenter.h"
+#import "NetworkManager.h"
+
+@interface Entry ()
+@property (weak, nonatomic) IBOutlet UITextField *channelTextField;
+@end
+
+@implementation Entry
+- (IBAction)onJoinPressed:(UIButton *)sender {
+ NSString *channelName = self.channelTextField.text;
+ if (channelName.length == 0) return;
+ UIStoryboard *sb = [UIStoryboard storyboardWithName:@"" bundle:nil];
+ Main *mainVC = [sb instantiateViewControllerWithIdentifier:@""];
+ mainVC.configs = @{@"channelName": channelName};
+ [self.navigationController pushViewController:mainVC animated:YES];
+}
+@end
+
+@interface Main ()
+@property (nonatomic, strong) AgoraRtcEngineKit *agoraKit;
+@end
+
+@implementation Main
+- (void)viewDidLoad {
+ [super viewDidLoad];
+ NSString *channelName = self.configs[@"channelName"];
+ AgoraRtcEngineConfig *config = [AgoraRtcEngineConfig new];
+ config.appId = [KeyCenter AppId];
+ self.agoraKit = [AgoraRtcEngineKit sharedEngineWithConfig:config delegate:self];
+ // configure engine, request permissions, then join
+ [[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) {
+ AgoraRtcChannelMediaOptions *option = [AgoraRtcChannelMediaOptions new];
+ [self.agoraKit joinChannelByToken:token channelId:channelName
+ uid:0 mediaOptions:option joinSuccess:nil];
+ }];
+}
+
+- (void)viewDidDisappear:(BOOL)animated {
+ [super viewDidDisappear:animated];
+ if (self.isMovingFromParentViewController) {
+ [self.agoraKit leaveChannel:nil];
+ [AgoraRtcEngineKit destroy];
+ }
+}
+@end
+
+@implementation Main (AgoraRtcEngineDelegate)
+- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinChannel:(NSString *)channel
+ withUid:(NSUInteger)uid elapsed:(NSInteger)elapsed {
+ NSLog(@"Joined: %@ uid: %lu", channel, (unsigned long)uid);
+}
+- (void)rtcEngine:(AgoraRtcEngineKit *)engine didOccurError:(AgoraErrorCode)errorCode {
+ NSLog(@"Error: %ld", (long)errorCode);
+}
+@end
+```
+
+## Step 4 — Create the Storyboard
+
+Create `APIExample-OC/.storyboard` with two scenes:
+
+| Scene | Storyboard ID | Class |
+|-------|--------------|-------|
+| Entry | `EntryViewController` | `Entry` |
+| Main | `` | `Main` |
+
+## Step 5 — Register the MenuItem
+
+Add to `+[MenuSection menus]` in `ViewController.m`:
+
+```objc
+[[MenuItem alloc] initWithName:NSLocalizedString(@"", nil)
+ storyboard:@""
+ controller:@""]
+```
+
+## Step 6 — Update the Case Index
+
+Add a row to the `## Case Index` table in `ARCHITECTURE.md`:
+
+```markdown
+| | `Examples/[Basic|Advanced]//.m` | `keyApi1:`, `keyApi2:` | One-line description |
+```
+
+---
+
+## Verification Checklist
+
+- [ ] Folder created under correct category (Basic / Advanced)
+- [ ] Both `.h` and `.m` files created with Entry and Main classes
+- [ ] Main inherits `BaseViewController` and conforms to `AgoraRtcEngineDelegate`
+- [ ] Storyboard has correct scene IDs
+- [ ] MenuItem added to `ViewController.m`
+- [ ] `leaveChannel:` + `[AgoraRtcEngineKit destroy]` called when leaving
+- [ ] UI updates inside delegate callbacks dispatched via `dispatch_async(dispatch_get_main_queue(), ^{ })`
+- [ ] `__weak typeof(self) weakSelf = self` used in blocks that capture `self`
+- [ ] Camera/microphone permissions requested before `joinChannelByToken:`
+- [ ] Case Index row added/updated in `ARCHITECTURE.md`
+- [ ] Project builds without errors
+
+---
+
+## NEVER
+
+- NEVER create `AgoraRtcEngineKit` in the Entry VC
+- NEVER use `__unsafe_unretained` for delegate references — use `__weak`
+- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `dispatch_async(dispatch_get_main_queue(), ^{ })`
+- NEVER add a new scene to `Main.storyboard` — each case must have its own `.storyboard` file
+- NEVER share an `AgoraRtcEngineKit` instance between cases
+- NEVER call `joinChannelByToken:` before requesting camera/microphone permissions
+- NEVER skip updating the Case Index in `ARCHITECTURE.md`
diff --git a/iOS/APIExample-OC/AGENTS.md b/iOS/APIExample-OC/AGENTS.md
new file mode 100644
index 000000000..5bf171d06
--- /dev/null
+++ b/iOS/APIExample-OC/AGENTS.md
@@ -0,0 +1,37 @@
+# AGENTS.md — APIExample-OC
+
+Objective-C variant of the API demo. Mirrors cases from `APIExample/` using Objective-C instead of Swift.
+
+## Build Commands
+
+```bash
+pod install
+# Then open APIExample-OC.xcworkspace in Xcode and build (Cmd+B)
+```
+
+## App ID Configuration
+
+Edit `APIExample-OC/Common/KeyCenter.m`:
+```objc
++ (NSString *)AppId {
+ return @"YOUR_APP_ID";
+}
+
++ (NSString *)Certificate {
+ return nil; // leave nil if App Certificate is not enabled
+}
+```
+
+To obtain an App ID, see [README.md](README.md#obtain-an-app-id).
+
+## Skills
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and OC convention compliance |
+| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle
diff --git a/iOS/APIExample-OC/ARCHITECTURE.md b/iOS/APIExample-OC/ARCHITECTURE.md
new file mode 100644
index 000000000..e7091a64d
--- /dev/null
+++ b/iOS/APIExample-OC/ARCHITECTURE.md
@@ -0,0 +1,164 @@
+# ARCHITECTURE.md — APIExample-OC
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/JoinChannelVideo.m` | `joinChannelByToken:`, `setupLocalVideo:`, `setupRemoteVideo:` | Basic video call — join channel and render local/remote video |
+| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/JoinChannelVideoToken.m` | `joinChannelByToken:`, `setupLocalVideo:`, `setupRemoteVideo:` | Video call with token authentication |
+| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/JoinChannelVideoRecorder.m` | `createMediaRecorder:`, `joinChannelByToken:`, `setupLocalVideo:` | Local and remote stream recording |
+| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.m` | `joinChannelByToken:`, `setAudioProfile:`, `enableAudioVolumeIndication:` | Basic audio call |
+| LiveStreaming | `Examples/Advanced/LiveStreaming/LiveStreaming.m` | `setClientRole:`, `setVideoScenario:`, `preloadChannelByToken:`, `enableInstantMediaRendering` | Interactive live streaming with role switching |
+| RTMPStreaming | `Examples/Advanced/RTMPStreaming/RTMPStreaming.m` | `startRtmpStreamWithoutTranscoding:`, `startRtmpStreamWithTranscoding:`, `updateRtmpTranscoding:`, `stopRtmpStream:` | Push stream to CDN with optional transcoding |
+| VideoMetadata | `Examples/Advanced/VideoMetadata/VideoMetadata.m` | `setMediaMetadataDataSource:withType:`, `setMediaMetadataDelegate:withType:` | Send and receive metadata attached to video stream |
+| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.m` | `setVoiceBeautifierPreset:`, `setAudioEffectPreset:`, `setVoiceConversionPreset:` | Voice beautifier, effects, and conversion presets |
+| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.m` | `createCustomAudioTrack:config:`, `enableCustomAudioLocalPlayback:enabled:`, `pushExternalAudioFrameRawData:` | Push custom PCM audio frames as external audio source |
+| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.m` | `enableExternalAudioSink:sampleRate:channels:`, `pullPlaybackAudioFrameRawData:lengthInByte:` | Pull audio frames for custom rendering |
+| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/CustomVideoSourcePush.m` | `setExternalVideoSource:useTexture:sourceType:`, `pushExternalVideoFrame:videoTrackId:` | Push external video frames as custom video source |
+| CustomVideoRender | `Examples/Advanced/CustomVideoRender/CustomVideoRender.m` | `setVideoFrameDelegate:` | Custom rendering of remote video frames via delegate |
+| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.m` | `setAudioFrameDelegate:` | Capture raw audio PCM data via delegate |
+| RawVideoData | `Examples/Advanced/RawVideoData/RawVideoData.m` | `setVideoFrameDelegate:` | Capture raw video frames via delegate |
+| SimpleFilter | `Examples/Advanced/SimpleFilter/SimpleFilter.m` | `enableExtensionWithVendor:extension:enabled:`, `setExtensionPropertyWithVendor:extension:key:value:` | Apply audio/video filter via Agora Extension API |
+| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/JoinMultiChannel.m` | `joinChannelExByToken:connection:delegate:mediaOptions:` | Join multiple channels simultaneously via ex connection |
+| StreamEncryption | `Examples/Advanced/StreamEncryption/StreamEncryption.m` | `enableEncryption:encryptionConfig:` | Built-in and custom stream encryption |
+| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.m` | `startAudioMixing:loopback:cycle:`, `adjustAudioMixingVolume:`, `setEffectsVolume:` | Mix local audio file with microphone input |
+| MediaPlayer | `Examples/Advanced/MediaPlayer/MediaPlayer.m` | `createMediaPlayerWithDelegate:`, `updateChannelExWithMediaOptions:connection:` | Play media files and publish to channel via media player |
+| ScreenShare | `Examples/Advanced/ScreenShare/ScreenShare.m` | `startScreenCapture:`, `updateScreenCapture:`, `stopScreenCapture` | Screen capture and sharing via ReplayKit extension |
+| LocalCompositeGraph | `Examples/Advanced/LocalCompositeGraph/LocalCompositeGraph.m` | `startLocalVideoTranscoder:`, `startCameraCapture:config:`, `enableVirtualBackground:backData:segData:` | Composite multiple video sources locally before publishing |
+| VideoProcess | `Examples/Advanced/VideoProcess/VideoProcess.m` | `setBeautyEffectOptions:options:`, `enableVirtualBackground:backData:segData:`, `enableExtensionWithVendor:` | Built-in beauty, virtual background, and video enhancement |
+| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.m` | `startRhythmPlayer:sound2:config:`, `stopRhythmPlayer` | Play metronome-style rhythm audio |
+| CreateDataStream | `Examples/Advanced/CreateDataStream/CreateDataStream.m` | `createDataStream:config:`, `sendStreamMessage:data:` | Create and send data stream messages between users |
+| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/MediaChannelRelay.m` | `startOrUpdateChannelMediaRelay:`, `stopChannelMediaRelay`, `pauseAllChannelMediaRelay`, `resumeAllChannelMediaRelay` | Relay media stream to multiple destination channels |
+| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.m` | `createMediaPlayerWithDelegate:`, `updateChannelWithMediaOptions:` | 3D spatial audio with media player integration |
+| ContentInspect | `Examples/Advanced/ContentInspect/ContentInspect.m` | `enableContentInspect:config:`, `switchCamera` | Moderate content in video stream |
+| MutliCamera | `Examples/Advanced/MutliCamera/MutliCamera.m` | `enableMultiCamera:config:`, `startCameraCapture:config:`, `stopCameraCapture:` | Capture from front and back cameras simultaneously (iOS 13+) |
+| PictureInPicture | `Examples/Advanced/PictureInPicture/PictureInPicture.m` | `setVideoFrameDelegate:`, `AVPictureInPictureController` | Picture-in-Picture using AVKit (iOS 15+) |
+| Simulcast | `Examples/Advanced/Simulcast/Simulcast.m` | `setSimulcastConfig:`, `setRemoteVideoStream:type:` | Publish multiple video quality layers simultaneously |
+| Multipath | `Examples/Advanced/Multipath/Multipath.m` | `updateChannelWithMediaOptions:` | Multi-path network transmission configuration |
+
+## Directory Layout
+
+```
+APIExample-OC/
+├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS)
+├── SimpleFilter/ # Optional C++ audio/video extension module
+├── Agora-ScreenShare-Extension-OC/ # ReplayKit broadcast extension for screen sharing
+├── libs/ # Local SDK frameworks (when not using CocoaPods)
+├── zh-Hans.lproj/ # Chinese localization (project level)
+└── APIExample-OC/
+ ├── main.m
+ ├── AppDelegate.h / .m
+ ├── ViewController.h / .m # Root menu controller — MenuItem registration lives here
+ ├── Info.plist
+ ├── APIExample-Bridging-Header.h
+ │
+ ├── Common/
+ │ ├── KeyCenter.h / .m # App ID and Certificate
+ │ ├── BaseViewController.h / .m # Base class all Main VCs must extend
+ │ ├── VideoView.h / .m / .xib # Reusable video rendering view
+ │ ├── Views/ # Reusable UI components
+ │ ├── Utils/ # LogUtils, GlobalSettings, Util (privatization config)
+ │ ├── NetworkManager/ # Token request helper
+ │ ├── ExternalAudio/ # External audio source helpers
+ │ ├── ExternalVideo/ # External video source helpers
+ │ └── CustomEncryption/ # Custom stream encryption helpers
+ │
+ ├── Examples/
+ │ ├── Basic/
+ │ │ ├── JoinChannelVideo/ # "Join a channel (Video)"
+ │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)"
+ │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording"
+ │ │ └── JoinChannelAudio/ # "Join a channel (Audio)"
+ │ └── Advanced/
+ │ ├── LiveStreaming/ # "Live Streaming"
+ │ ├── RTMPStreaming/ # "RTMP Streaming"
+ │ ├── VideoMetadata/ # "Video Metadata"
+ │ ├── VoiceChanger/ # "Voice Changer"
+ │ ├── CustomPcmAudioSource/ # "Custom Audio Source"
+ │ ├── CustomAudioRender/ # "Custom Audio Render"
+ │ ├── CustomVideoSourcePush/ # "Custom Video Source (Push)"
+ │ ├── CustomVideoRender/ # "Custom Video Render"
+ │ ├── RawAudioData/ # "Raw Audio Data"
+ │ ├── RawVideoData/ # "Raw Video Data"
+ │ ├── PictureInPicture/ # "Picture In Picture (iOS15+)"
+ │ ├── SimpleFilter/ # "Simple Filter Extension"
+ │ ├── JoinMultiChannel/ # "Join Multiple Channels"
+ │ ├── StreamEncryption/ # "Stream Encryption"
+ │ ├── AudioMixing/ # "Audio Mixing"
+ │ ├── MediaPlayer/ # "Media Player"
+ │ ├── ScreenShare/ # "Screen Share"
+ │ ├── VideoProcess/ # "Video Process"
+ │ ├── RhythmPlayer/ # "Rhythm Player"
+ │ ├── CreateDataStream/ # "Create Data Stream"
+ │ ├── MediaChannelRelay/ # "Media Channel Relay"
+ │ ├── SpatialAudio/ # "Spatial Audio"
+ │ ├── ContentInspect/ # "Content Inspect"
+ │ ├── MutliCamera/ # "Multi Camera (iOS13+)"
+ │ ├── Simulcast/ # "Simulcast"
+ │ ├── Multipath/ # "Multipath"
+ │ └── LocalCompositeGraph/ # "Local Composite Graph"
+ │
+ ├── Resources/ # Audio/video sample files
+ ├── Assets.xcassets/
+ ├── en.lproj/ # English localization
+ └── zh-Hans.lproj/ # Chinese localization
+```
+
+## Case Registration Mechanism
+
+Registration is **manual** via the `+[MenuSection menus]` method in `ViewController.m`. No reflection or annotation scanning.
+
+**`MenuItem` class:**
+```objc
+@interface MenuItem : NSObject
+@property(nonatomic, copy) NSString *name; // display name in the list
+@property(nonatomic, copy) NSString *entry; // storyboard ID of the entry VC (default: "EntryViewController")
+@property(nonatomic, copy) NSString *storyboard; // storyboard file name
+@property(nonatomic, copy) NSString *controller; // (unused in current implementation)
+@property(nonatomic, copy) NSString *note; // optional description
+@end
+```
+
+Each example has its own `.storyboard` file. The VC with identifier `entry` (default `"EntryViewController"`) is instantiated directly from that storyboard.
+
+**To add a case, edit exactly two things:**
+1. Add a `MenuItem` to the `+[MenuSection menus]` method in `ViewController.m`:
+ ```objc
+ [[MenuItem alloc] initWithName:@"My New Case".localized storyboard:@"MyNewCase" controller:@""]
+ ```
+2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the `.h/.m` files and storyboard
+
+## Entry/Main ViewController Pattern
+
+Every example is split into two view controller roles:
+
+**Entry** (`Entry : UIViewController`)
+- Collects user configuration before entering the example
+- Passes configuration to Main via a `configs` dictionary (`NSDictionary`)
+
+**Main** (`Main : BaseViewController`)
+- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example
+- Conforms to `AgoraRtcEngineDelegate`
+- Receives configuration exclusively through `configs`
+
+## AgoraRtcEngineKit Lifecycle
+
+```
+viewDidLoad → [AgoraRtcEngineKit sharedEngineWithAppId:delegate:]
+ → [engine setVideoEncoderConfiguration:] / [engine setChannelProfile:]
+ → [engine joinChannelByToken:...] (after permission granted)
+ ↓
+ [AgoraRtcEngineDelegate callbacks — may be on background thread]
+ ↓
+viewDidDisappear / dealloc
+ → [engine leaveChannel:]
+ → [AgoraRtcEngineKit destroy]
+```
+
+## Token Flow
+
+```objc
+[[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) {
+ [self.agoraKit joinChannelByToken:token channelId:channelName uid:0 mediaOptions:options];
+}];
+```
diff --git a/iOS/APIExample-OC/CLAUDE.md b/iOS/APIExample-OC/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/iOS/APIExample-OC/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..03f4a9dda
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,50 @@
+---
+name: query-cases
+description: >
+ Find existing API demo cases in the APIExample-SwiftUI project by feature name, API name, or keyword.
+ Use this before creating a new case to avoid duplication.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# query-cases — APIExample-SwiftUI
+
+## When to Use
+
+- User asks "where is the screen sharing example?"
+- User wants to find code for a specific Agora SDK API
+- Before creating a new case, to confirm it does not already exist
+
+## Quick Search (try this first)
+
+Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file.
+
+## Deep Search (for complex queries)
+
+1. Check `APIExample-SwiftUI/ContentView.swift` — the `menus` array lists all registered cases
+2. Source files are at:
+ - `APIExample-SwiftUI/Examples/Basic//`
+ - `APIExample-SwiftUI/Examples/Advanced//`
+3. Each case folder contains:
+ - `.swift` — Entry and Main SwiftUI views
+ - `RTC.swift` — engine lifecycle and delegate callbacks
+
+## Common Query Patterns
+
+| Query | Where to look |
+|-------|--------------|
+| Feature by name | Case Index — search Description column |
+| API by method name | Case Index — search Key APIs column; or grep `*RTC.swift` files |
+| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` |
+| Cases using a specific pattern | Grep `*RTC.swift` files under `Examples/` |
+
+## Output Format
+
+Report results as:
+- Case name and folder path
+- Key APIs demonstrated
+- One-line description
diff --git a/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..04c4437b9
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,140 @@
+---
+name: review-case
+description: >
+ Structured code review for a case in the APIExample-SwiftUI project.
+ Checks engine lifecycle, SwiftUI state ownership, thread safety, permissions, and API correctness.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# review-case — APIExample-SwiftUI
+
+## Review Dimensions (in priority order)
+
+### 1. Engine Lifecycle
+
+**Check:**
+- `AgoraRtcEngineKit` created inside `setupRTC()` of the RTC class, not in the Entry view
+- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called inside `onDestroy()`
+- `setupRTC()` called from `.onAppear`, `onDestroy()` called from `.onDisappear`
+- No engine instance retained beyond the RTC object's lifetime
+
+**Correct:**
+```swift
+.onAppear { rtc.setupRTC(configs: configs) }
+.onDisappear { rtc.onDestroy() }
+```
+
+**Wrong:**
+```swift
+// Missing onDisappear — engine never destroyed
+.onAppear { rtc.setupRTC(configs: configs) }
+```
+
+---
+
+### 2. SwiftUI State Ownership
+
+**Check:**
+- Main view declares RTC object as `@ObservedObject`, not `@StateObject`
+- Entry view does not hold a reference to the RTC object
+- `@Published` properties used for state that drives UI updates
+
+**Correct:**
+```swift
+struct MyCase: View {
+ @ObservedObject private var rtc = MyCaseRTC() // correct
+}
+```
+
+**Wrong:**
+```swift
+struct MyCase: View {
+ @StateObject private var rtc = MyCaseRTC() // wrong — SwiftUI owns lifetime, may outlive view
+}
+```
+
+---
+
+### 3. Thread Safety
+
+All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread.
+
+**Check:**
+- Every `@Published` property mutation inside a delegate callback is dispatched to `DispatchQueue.main`
+- No direct SwiftUI state mutation on background thread
+
+**Correct:**
+```swift
+func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
+ DispatchQueue.main.async {
+ self.remoteUid = uid
+ }
+}
+```
+
+---
+
+### 4. Permissions
+
+**Check:**
+- Camera/microphone permissions requested before `joinChannel()`
+- `joinChannel()` called only inside the permission grant callback
+
+---
+
+### 5. Error Handling
+
+**Check:**
+- Return value of `joinChannel()` checked
+- `rtcEngine(_:didOccurError:)` implemented and logged
+- Token expiry handled if token is used
+
+---
+
+### 6. Code Conventions
+
+**Check:**
+- RTC class inherits `NSObject`, conforms to `ObservableObject` and `AgoraRtcEngineDelegate`
+- Entry view named `Entry`, main view named ``
+- RTC class named `RTC`
+- `configs` dictionary used to pass data from Entry to Main
+- No SDK calls inside SwiftUI `body` computed property
+
+---
+
+### 7. Resource Cleanup
+
+**Check:**
+- Audio files / custom audio tracks stopped in `onDestroy()`
+- External video sources unregistered in `onDestroy()`
+- Media player destroyed if created
+- Screen capture stopped if started
+- Multi-camera capture stopped if started
+
+---
+
+## Review Output Format
+
+```
+[SEVERITY] file/line — issue description
+Suggestion: how to fix
+```
+
+Severity levels:
+- `[CRITICAL]` — crash, leak, or incorrect behavior
+- `[WARNING]` — convention violation or subtle bug risk
+- `[INFO]` — style or minor improvement
+
+---
+
+## SwiftUI-Specific Checks
+
+- `onAppear` can fire multiple times (e.g., sheet dismiss, navigation pop/push) — verify `setupRTC` is idempotent or guarded
+- `onDisappear` fires when the view is covered by another view in a `TabView` — verify this does not prematurely destroy the engine
+- Video rendering views (`VideoView` / `VideoUIView`) must be created before `setupRTC` is called so the canvas can be set up correctly
+- `[weak self]` required in all closures capturing `self` inside the RTC class to avoid retain cycles
diff --git a/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..821cff8eb
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,163 @@
+---
+name: upsert-case
+description: >
+ Add a new API demo case or modify an existing one in the APIExample-SwiftUI project.
+ Covers folder creation, Entry view, RTC class, MenuItem registration, and Case Index update.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# upsert-case — APIExample-SwiftUI
+
+## When to Use
+
+- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/`
+- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+
+
+Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist.
+
+## Files to Touch
+
+| Scenario | Files |
+|----------|-------|
+| Add new case | New folder + `RTC.swift` + `.swift`, `ContentView.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) |
+| Modify existing case | Existing `*RTC.swift` and/or `*.swift` view files, `ARCHITECTURE.md` (Case Index) |
+
+---
+
+## Step 1 — Create the Example Folder
+
+```
+APIExample-SwiftUI/Examples/[Basic|Advanced]//
+```
+
+## Step 2 — Create the RTC Class
+
+Create `RTC.swift` — owns the engine lifecycle:
+
+```swift
+import AgoraRtcKit
+import SwiftUI
+
+class RTC: NSObject, ObservableObject {
+ var agoraKit: AgoraRtcEngineKit!
+ private var isJoined = false
+
+ func setupRTC(configs: [String: Any]) {
+ let config = AgoraRtcEngineConfig()
+ config.appId = KeyCenter.AppId
+ agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)
+
+ guard let channelName = configs["channelName"] as? String else { return }
+ let option = AgoraRtcChannelMediaOptions()
+ option.clientRoleType = .broadcaster
+
+ NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in
+ self?.agoraKit.joinChannel(byToken: token, channelId: channelName,
+ uid: 0, mediaOptions: option)
+ }
+ }
+
+ func onDestroy() {
+ if isJoined { agoraKit.leaveChannel(nil) }
+ AgoraRtcEngineKit.destroy()
+ }
+}
+
+extension RTC: AgoraRtcEngineDelegate {
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String,
+ withUid uid: UInt, elapsed: Int) {
+ isJoined = true
+ LogUtils.log(message: "Joined: \(channel)", level: .info)
+ }
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
+ LogUtils.log(message: "Error: \(errorCode)", level: .error)
+ }
+}
+```
+
+## Step 3 — Create the SwiftUI Views
+
+Create `.swift` with Entry and Main views:
+
+```swift
+import SwiftUI
+
+struct Entry: View {
+ @State private var channelName = ""
+ @State private var isActive = false
+ @State private var configs: [String: Any] = [:]
+
+ var body: some View {
+ VStack {
+ TextField("Enter channel name".localized, text: $channelName)
+ .textFieldStyle(.roundedBorder).padding()
+ Button("Join".localized) {
+ configs = ["channelName": channelName]
+ isActive = true
+ }.disabled(channelName.isEmpty)
+ NavigationLink(destination: (configs: configs),
+ isActive: $isActive) { EmptyView() }
+ }
+ }
+}
+
+struct : View {
+ @State var configs: [String: Any] = [:]
+ @ObservedObject private var rtc = RTC()
+
+ var body: some View {
+ VStack { /* UI here */ }
+ .onAppear { rtc.setupRTC(configs: configs) }
+ .onDisappear { rtc.onDestroy() }
+ }
+}
+```
+
+## Step 4 — Register the MenuItem
+
+Add to the `menus` array in `APIExample-SwiftUI/ContentView.swift`:
+
+```swift
+MenuItem(name: "".localized, view: AnyView(Entry()))
+```
+
+## Step 5 — Update the Case Index
+
+Add a row to the `## Case Index` table in `ARCHITECTURE.md`:
+
+```markdown
+| | `Examples/[Basic|Advanced]//` | `keyApi1()`, `keyApi2()` | One-line description |
+```
+
+---
+
+## Verification Checklist
+
+- [ ] Folder created under correct category (Basic / Advanced)
+- [ ] RTC class inherits `NSObject`, conforms to `ObservableObject` and `AgoraRtcEngineDelegate`
+- [ ] Engine created in `setupRTC`, destroyed in `onDestroy`
+- [ ] Main view uses `@ObservedObject` (not `@StateObject`) for the RTC object
+- [ ] `setupRTC` called in `.onAppear`, `onDestroy` called in `.onDisappear`
+- [ ] `leaveChannel` + `AgoraRtcEngineKit.destroy()` called in `onDestroy`
+- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main`
+- [ ] MenuItem added to `ContentView.swift`
+- [ ] Case Index row added/updated in `ARCHITECTURE.md`
+- [ ] Project builds without errors
+
+---
+
+## NEVER
+
+- NEVER create `AgoraRtcEngineKit` in the Entry view
+- NEVER use `@StateObject` for the RTC object in the Main view — the Main view does not own its lifetime
+- NEVER call SDK APIs inside SwiftUI `body` — only in `.onAppear`, `.onDisappear`, or explicit user action handlers
+- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }`
+- NEVER share an `AgoraRtcEngineKit` instance between cases
+- NEVER call `joinChannel` before requesting camera/microphone permissions
+- NEVER skip updating the Case Index in `ARCHITECTURE.md`
diff --git a/iOS/APIExample-SwiftUI/AGENTS.md b/iOS/APIExample-SwiftUI/AGENTS.md
new file mode 100644
index 000000000..1ca46420c
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/AGENTS.md
@@ -0,0 +1,32 @@
+# AGENTS.md — APIExample-SwiftUI
+
+SwiftUI variant of the API demo. Mirrors cases from `APIExample/` using SwiftUI views + MVVM pattern instead of UIKit + Storyboards.
+
+## Build Commands
+
+```bash
+pod install
+# Then open APIExample-SwiftUI.xcworkspace in Xcode and build (Cmd+B)
+```
+
+## App ID Configuration
+
+Edit `APIExample-SwiftUI/Common/KeyCenter.swift`:
+```swift
+static let AppId: String = "YOUR_APP_ID"
+static let Certificate: String? = nil // leave nil if App Certificate is not enabled
+```
+
+To obtain an App ID, see [README.md](README.md#obtain-an-app-id).
+
+## Skills
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and SwiftUI convention compliance |
+| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, case registration, Entry/RTC pattern, engine lifecycle
diff --git a/iOS/APIExample-SwiftUI/ARCHITECTURE.md b/iOS/APIExample-SwiftUI/ARCHITECTURE.md
new file mode 100644
index 000000000..b864ed27b
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/ARCHITECTURE.md
@@ -0,0 +1,185 @@
+# ARCHITECTURE.md — APIExample-SwiftUI
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/` | `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call — join channel and render local/remote video |
+| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/` | `joinChannel(byToken:)`, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication |
+| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/` | `createMediaRecorder()`, `joinChannel()`, `setupLocalVideo()` | Local and remote stream recording |
+| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/` | `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()`, `enable(inEarMonitoring:)` | Basic audio call with profile, scenario, and volume controls |
+| LiveStreaming | `Examples/Advanced/LiveStreaming/` | `setClientRole()`, `setVideoScenario()`, `preloadChannel()`, `enableInstantMediaRendering()` | Interactive live streaming with role switching |
+| RTMPStream | `Examples/Advanced/RTMPStream/` | `startRtmpStreamWithoutTranscoding()`, `startRtmpStream(withTranscoding:)`, `updateRtmpTranscoding()`, `stopRtmpStream()` | Push stream to CDN with optional transcoding |
+| VideoMetadata | `Examples/Advanced/VideoMetadata/` | `setMediaMetadataDataSource()`, `setMediaMetadataDelegate()` | Send and receive metadata attached to video stream |
+| VoiceChanger | `Examples/Advanced/VoiceChanger/` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setLocalVoiceFormant()` | Voice beautifier, effects, and conversion presets |
+| CustomPCMAudioSource | `Examples/Advanced/CustomPCMAudioSource/` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as external audio source |
+| CustomAudioRender | `Examples/Advanced/CustomAudioRender/` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering |
+| RawAudioData | `Examples/Advanced/RawAudioData/` | `setAudioFrameDelegate()`, `sendAudioMetadata()` | Capture raw audio PCM data via delegate |
+| RawVideoData | `Examples/Advanced/RawVideoData/` | `setVideoFrameDelegate()` | Capture raw video frames via delegate |
+| PictureInPicture | `Examples/Advanced/PictureInPicture/` | `AVPictureInPictureController`, `joinChannel()`, `setVideoFrameDelegate()` | Picture-in-Picture using AVKit (iOS 15+) |
+| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/` | `joinChannel()`, `leaveChannel()` | Quickly switch between channels as audience |
+| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/` | `joinChannelEx()`, `takeSnapshotEx()` | Join multiple channels simultaneously via ex connection |
+| StreamEncryption | `Examples/Advanced/StreamEncryption/` | `enableEncryption()` | Built-in and custom stream encryption |
+| AudioMixing | `Examples/Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input |
+| PrecallTest | `Examples/Advanced/PrecallTest/` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()` | Pre-call echo test and last-mile network probe |
+| MediaPlayer | `Examples/Advanced/MediaPlayer/` | `createMediaPlayer()`, `updateChannelEx()` | Play media files and publish to channel via media player |
+| ScreenShare | `Examples/Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCapture()`, `stopScreenCapture()` | Screen capture and sharing via ReplayKit extension |
+| LocalVideoTranscoding | `Examples/Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `createMediaPlayer()` | Transcode multiple video sources locally before publishing |
+| LocalVideoComposition | `Examples/Advanced/LocalVideoComposition/` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()` | Composite camera and screen capture into one stream |
+| VideoProcess | `Examples/Advanced/VideoProcess/` | `setBeautyEffectOptions()`, `enableVirtualBackground()`, `enableExtension()` | Built-in beauty, virtual background, and video enhancement |
+| AgoraBeauty | `Examples/Advanced/AgoraBeauty/` | `enableExtension()`, `createVideoEffectObject()`, `setFilterEffectOptions()` | Agora beauty extension with makeup and virtual background |
+| RhythmPlayer | `Examples/Advanced/RhythmPlayer/` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio |
+| CreateDataStream | `Examples/Advanced/CreateDataStream/` | `createDataStream()`, `sendStreamMessage()` | Create and send data stream messages between users |
+| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()` | Relay media stream to multiple destination channels |
+| SpatialAudio | `Examples/Advanced/SpatialAudio/` | `createMediaPlayer()`, `updateChannel()` | 3D spatial audio with media player integration |
+| ContentInspect | `Examples/Advanced/ContentInspect/` | `enableContentInspect()`, `switchCamera()` | Moderate content in video stream |
+| MutliCamera | `Examples/Advanced/MutliCamera/` | `enableMultiCamera()`, `startCameraCapture()`, `stopCameraCapture()` | Capture from front and back cameras simultaneously (iOS 13+) |
+| KtvCopyrightMusic | `Examples/Advanced/KtvCopyrightMusic/` | — | Links to KTV copyright music documentation |
+| ARKit | `Examples/Advanced/ARKit/` | `setVideoFrameDelegate()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()` | Push ARKit face tracking frames as custom video source |
+| AudioWaveform | `Examples/Advanced/AudioWaveform/` | `setAudioProfile()`, `enableAudioVolumeIndication()` | Visualize audio waveform from volume callbacks |
+| FaceCapture | `Examples/Advanced/FaceCapture/` | `enableExtension()`, `setExtensionPropertyWithVendor()`, `setFaceInfoDelegate()` | Face capture and lip sync via Agora extension |
+| Simulcast | `Examples/Advanced/Simulcast/` | `setSimulcastConfig()`, `setRemoteVideoStream()` | Publish multiple video quality layers simultaneously |
+| Multipath | `Examples/Advanced/Multipath/` | `updateChannel()` | Multi-path network transmission configuration |
+
+## Directory Layout
+
+```
+APIExample-SwiftUI/
+├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS)
+├── Agora-ScreenShare-Extension/ # ReplayKit broadcast extension for screen sharing
+├── libs/ # Local SDK frameworks (when not using CocoaPods)
+└── APIExample-SwiftUI/
+ ├── APIExample_SwiftUIApp.swift # App entry point (@main)
+ ├── ContentView.swift # Root navigation — MenuItem registration lives here
+ ├── Info.plist
+ ├── APIExample-Bridging-Header.h
+ │
+ ├── Common/
+ │ ├── KeyCenter.swift # App ID and Certificate
+ │ ├── AgoraExtension.swift
+ │ ├── PickerView.swift
+ │ ├── StatisticsInfo.swift
+ │ ├── VideoView.swift # SwiftUI wrapper for video rendering
+ │ ├── VideoUIView.swift # UIKit video view
+ │ ├── ViewExtensions.swift
+ │ ├── View/ # Reusable SwiftUI components
+ │ ├── Settings/ # GlobalSettings
+ │ ├── Utils/ # LogUtils, Util (privatization config)
+ │ ├── NetworkManager/ # Token request helper
+ │ ├── ExternalAudio/ # External audio source helpers
+ │ ├── ExternalVideo/ # External video source helpers
+ │ ├── CustomEncryption/ # Custom stream encryption helpers
+ │ └── ARKit/ # ARKit integration helpers
+ │
+ ├── Examples/
+ │ ├── Basic/
+ │ │ ├── JoinChannelVideo/ # "Join a channel (Video)"
+ │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)"
+ │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording"
+ │ │ └── JoinChannelAudio/ # "Join a channel (Audio)"
+ │ └── Advanced/
+ │ ├── LiveStreaming/ # "Live Streaming"
+ │ ├── RTMPStream/ # "RTMP Streaming"
+ │ ├── VideoMetadata/ # "Video Metadata"
+ │ ├── VoiceChanger/ # "Voice Changer"
+ │ ├── CustomPCMAudioSource/ # "Custom Audio Source (PCM)"
+ │ ├── CustomAudioRender/ # "Custom Audio Render"
+ │ ├── RawAudioData/ # "Raw Audio Data"
+ │ ├── RawVideoData/ # "Raw Video Data"
+ │ ├── PictureInPicture/ # "Picture In Picture"
+ │ ├── QuickSwitchChannel/ # "Quick Switch Channel"
+ │ ├── JoinMultiChannel/ # "Join Multiple Channels"
+ │ ├── StreamEncryption/ # "Stream Encryption"
+ │ ├── AudioMixing/ # "Audio Mixing"
+ │ ├── PrecallTest/ # "Precall Test"
+ │ ├── MediaPlayer/ # "Media Player"
+ │ ├── ScreenShare/ # "Screen Share"
+ │ ├── LocalVideoTranscoding/ # "Local Video Transcoding"
+ │ ├── LocalVideoComposition/ # "Local Composite Graph"
+ │ ├── VideoProcess/ # "Video Process"
+ │ ├── AgoraBeauty/ # "Agora Beauty"
+ │ ├── RhythmPlayer/ # "Rhythm Player"
+ │ ├── CreateDataStream/ # "Create Data Stream"
+ │ ├── MediaChannelRelay/ # "Media Channel Relay"
+ │ ├── SpatialAudio/ # "Spatial Audio"
+ │ ├── ContentInspect/ # "Content Inspect"
+ │ ├── MutliCamera/ # "Multi Camera (iOS13+)"
+ │ ├── KtvCopyrightMusic/ # "KTV Copyright Music"
+ │ ├── ARKit/ # "ARKit"
+ │ ├── AudioWaveform/ # "Audio Waveform"
+ │ ├── FaceCapture/ # "Face Capture"
+ │ ├── Simulcast/ # "Simulcast"
+ │ └── Multipath/ # "Multipath"
+ │
+ ├── Resources/ # Audio/video sample files
+ ├── Assets.xcassets/
+ └── Preview Content/
+```
+
+## Case Registration Mechanism
+
+Registration is **manual** via the `menus` array in `ContentView.swift`. No reflection or annotation scanning.
+
+**`MenuItem` struct:**
+```swift
+struct MenuItem: Identifiable {
+ let id = UUID()
+ var name: String
+ var view: AnyView // the Entry view wrapped in AnyView
+}
+```
+
+Navigation uses SwiftUI `NavigationLink`. Each `MenuItem` holds an `AnyView` wrapping the Entry view.
+
+**To add a case, edit exactly two things:**
+1. Add a `MenuItem` to the `menus` array in `ContentView.swift`:
+ ```swift
+ MenuItem(name: "My New Case".localized, view: AnyView(MyNewCaseEntry()))
+ ```
+2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift files
+
+## Entry/RTC Pattern
+
+Every example is split into two parts:
+
+**Entry** (`Entry : View`)
+- A SwiftUI View that collects user configuration (channel name, etc.)
+- Uses `NavigationLink` to navigate to the main view
+- Passes configuration via a `configs` dictionary
+
+**RTC** (`RTC : NSObject, ObservableObject, AgoraRtcEngineDelegate`)
+- Owns the `AgoraRtcEngineKit` lifecycle
+- Exposes state to the View via `@Published` properties
+- Implements all delegate callbacks
+
+**Main View** (` : View`)
+- Holds the RTC object as `@ObservedObject`
+- Calls `setupRTC()` in `.onAppear`
+- Calls `onDestroy()` in `.onDisappear`
+
+## Video Rendering
+
+UIKit video views (`VideoUIView`) are bridged into SwiftUI via `UIViewRepresentable` (`VideoView`). The RTC class owns the `UIView` instances; the SwiftUI View wraps them for display.
+
+## AgoraRtcEngineKit Lifecycle
+
+```
+.onAppear → setupRTC()
+ → AgoraRtcEngineKit.sharedEngine(with:delegate:)
+ → engine.setVideoEncoderConfiguration / setClientRole
+ → engine.joinChannel() (after token generation)
+ ↓
+ [AgoraRtcEngineDelegate callbacks]
+ ↓
+.onDisappear → onDestroy()
+ → engine.leaveChannel()
+ → AgoraRtcEngineKit.destroy()
+```
+
+## Token Flow
+
+```swift
+NetworkManager.shared.generateToken(channelName: channelName) { token in
+ self.agoraKit.joinChannel(byToken: token, channelId: channelName, uid: 0, mediaOptions: option)
+}
+```
diff --git a/iOS/APIExample-SwiftUI/CLAUDE.md b/iOS/APIExample-SwiftUI/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/iOS/APIExample-SwiftUI/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/iOS/APIExample/.agent/skills/query-cases/SKILL.md b/iOS/APIExample/.agent/skills/query-cases/SKILL.md
new file mode 100644
index 000000000..f35362ee4
--- /dev/null
+++ b/iOS/APIExample/.agent/skills/query-cases/SKILL.md
@@ -0,0 +1,51 @@
+---
+name: query-cases
+description: >
+ Find existing API demo cases in the APIExample project by feature name, API name, or keyword.
+ Use this before creating a new case to avoid duplication.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# query-cases — APIExample
+
+## When to Use
+
+- User asks "where is the screen sharing example?"
+- User wants to find code for a specific Agora SDK API
+- Before creating a new case, to confirm it does not already exist
+
+## Quick Search (try this first)
+
+Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file.
+
+Example: searching `screenCapture` in the Case Index immediately returns the `ScreenShare` row.
+
+## Deep Search (for complex queries)
+
+For queries like "which cases use multi-channel" or "which cases call joinChannelEx", scan source files:
+
+1. Check `APIExample/ViewController.swift` — the `menus` array lists all registered cases
+2. Source files are at:
+ - `APIExample/Examples/Basic//.swift`
+ - `APIExample/Examples/Advanced//.swift`
+
+## Common Query Patterns
+
+| Query | Where to look |
+|-------|--------------|
+| Feature by name (e.g. "screen share") | Case Index — search Description column |
+| API by method name (e.g. `startScreenCapture`) | Case Index — search Key APIs column |
+| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` |
+| Cases using a specific pattern (e.g. `joinChannelEx`) | Grep source files under `Examples/` |
+
+## Output Format
+
+Report results as:
+- Case name and file path
+- Key APIs demonstrated
+- One-line description
diff --git a/iOS/APIExample/.agent/skills/review-case/SKILL.md b/iOS/APIExample/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..70e70d6eb
--- /dev/null
+++ b/iOS/APIExample/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,154 @@
+---
+name: review-case
+description: >
+ Structured code review for a case in the APIExample (UIKit + Swift) project.
+ Checks engine lifecycle, thread safety, permissions, error handling, API correctness, and code conventions.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# review-case — APIExample
+
+## Review Dimensions (in priority order)
+
+### 1. Engine Lifecycle
+
+The most critical dimension. Leaks here cause crashes in subsequent examples.
+
+**Check:**
+- `AgoraRtcEngineKit.sharedEngine(with:delegate:)` called in `viewDidLoad` (not in Entry VC)
+- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil`
+- No engine instance stored beyond the Main VC's lifetime
+
+**Correct:**
+```swift
+override func willMove(toParent parent: UIViewController?) {
+ super.willMove(toParent: parent)
+ if parent == nil {
+ agoraKit?.leaveChannel()
+ AgoraRtcEngineKit.destroy()
+ }
+}
+```
+
+**Wrong:**
+```swift
+// Missing destroy — engine leaks
+override func viewDidDisappear(_ animated: Bool) {
+ agoraKit?.leaveChannel()
+}
+```
+
+---
+
+### 2. Thread Safety
+
+All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread.
+
+**Check:**
+- Every UI update inside a delegate callback is wrapped in `DispatchQueue.main.async { }`
+- No `UIView`, `UILabel`, or other UIKit objects mutated directly in callbacks
+
+**Correct:**
+```swift
+func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
+ DispatchQueue.main.async {
+ self.remoteView.isHidden = false
+ self.setupRemoteVideo(uid: uid)
+ }
+}
+```
+
+**Wrong:**
+```swift
+func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
+ remoteView.isHidden = false // UI update on background thread
+}
+```
+
+---
+
+### 3. Permissions
+
+**Check:**
+- Camera permission requested before `joinChannel()` for video cases
+- Microphone permission requested before `joinChannel()` for all cases
+- `joinChannel()` called only inside the permission grant callback, not before
+
+**Correct:**
+```swift
+AgoraAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
+ guard granted else { return }
+ self?.agoraKit?.joinChannel(...)
+}
+```
+
+---
+
+### 4. Error Handling
+
+**Check:**
+- Return value of `joinChannel()` checked (non-zero = error)
+- `rtcEngine(_:didOccurError:)` delegate method implemented and logged
+- Token expiry handled via `rtcEngine(_:tokenPrivilegeWillExpire:)` if token is used
+
+---
+
+### 5. Code Conventions
+
+**Check:**
+- Entry class inherits `UIViewController`, Main class inherits `BaseViewController`
+- Entry class usually follows `Entry`; the main controller may be `Main` or an existing project-specific `*ViewController` name as long as storyboard wiring is correct
+- `configs` dictionary used to pass data from Entry to Main (no direct property injection)
+- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section
+- Storyboard ID of Main scene matches the `controller` field in `MenuItem`
+
+---
+
+### 6. API Usage Correctness
+
+**Check:**
+- `setVideoEncoderConfiguration` called before `joinChannel`, not after
+- `setupLocalVideo` called before `startPreview` and `joinChannel`
+- `enableVideo()` called before `setupLocalVideo` for video cases
+- `setClientRole` called before `joinChannel` for live streaming cases
+- No deprecated API variants used (check SDK release notes if unsure)
+
+---
+
+### 7. Resource Cleanup
+
+**Check:**
+- Audio files / custom audio tracks stopped and released on exit
+- External video sources unregistered (`setExternalVideoSource(false, ...)`)
+- Media player destroyed if created (`agoraKit.destroy(mediaPlayer)`)
+- Screen capture stopped if started (`stopScreenCapture()`)
+- Multi-camera capture stopped if started (`stopCameraCapture(.cameraSecondary)`)
+
+---
+
+## Review Output Format
+
+For each issue found, report:
+
+```
+[SEVERITY] file/line — issue description
+Suggestion: how to fix
+```
+
+Severity levels:
+- `[CRITICAL]` — will cause crash, leak, or incorrect behavior
+- `[WARNING]` — violates convention or may cause subtle bugs
+- `[INFO]` — style or minor improvement suggestion
+
+---
+
+## iOS-Specific Checks
+
+- Background audio: if the case uses audio, verify `AVAudioSession` category is set appropriately and `UIBackgroundModes` includes `audio` if background playback is needed
+- `willMove(toParent:)` is the correct hook — do NOT use `viewWillDisappear` or `deinit` for engine cleanup in navigation-based flows
+- `[weak self]` must be used in all closures that capture `self` to avoid retain cycles with the engine delegate
diff --git a/iOS/APIExample/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..9e1c531d2
--- /dev/null
+++ b/iOS/APIExample/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,185 @@
+---
+name: upsert-case
+description: >
+ Add a new API demo case or modify an existing one in the APIExample (UIKit + Swift) project.
+ Covers folder creation, Entry/Main Swift file, storyboard, MenuItem registration, and Case Index update.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: iOS
+---
+
+# upsert-case — APIExample
+
+## When to Use
+
+- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/`
+- **Modify**: the case already exists — update the existing `.swift` and storyboard first, then check registration and docs
+
+Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist.
+
+## Files to Touch
+
+| Scenario | Files |
+|----------|-------|
+| Add new case | New folder + `.swift` file + `Base.lproj/.storyboard`, `ViewController.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) |
+| Modify existing case | Existing `.swift` file(s), optionally `Base.lproj/.storyboard`, `ViewController.swift` if registration/wiring changed, `ARCHITECTURE.md` (Case Index) |
+
+---
+
+## Modify Existing Case
+
+When repairing or rebuilding an existing case, use this order instead of the new-case flow:
+
+1. Locate the existing `.swift` implementation and update the actual runtime logic first
+2. Update the existing storyboard if scene wiring, outlets, actions, or controller identifiers changed
+3. Check `APIExample/ViewController.swift` and fix the `MenuItem` only if registration or `controller` / `storyboard` wiring is wrong
+4. Update `ARCHITECTURE.md` last if the case path, APIs, or description changed
+
+Do not skip implementation edits just because the case folder already exists.
+
+## Step 1 — Create the Example Folder
+
+```
+APIExample/Examples/[Basic|Advanced]//
+```
+
+Use `Basic/` for fundamental channel join demos, `Advanced/` for everything else.
+
+## Step 2 — Create the Swift File
+
+Create `.swift` containing both Entry and Main classes:
+
+```swift
+import UIKit
+import AgoraRtcKit
+
+class Entry: UIViewController {
+ @IBOutlet weak var channelTextField: UITextField!
+
+ @IBAction func onJoinPressed(_ sender: UIButton) {
+ guard let channelName = channelTextField.text, !channelName.isEmpty else { return }
+ let storyboard = UIStoryboard(name: "", bundle: nil)
+ guard let mainVC = storyboard.instantiateViewController(
+ withIdentifier: "") as? Main else { return }
+ mainVC.configs = ["channelName": channelName]
+ navigationController?.pushViewController(mainVC, animated: true)
+ }
+}
+
+class Main: BaseViewController {
+ var agoraKit: AgoraRtcEngineKit?
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ guard let channelName = configs["channelName"] as? String else { return }
+ let config = AgoraRtcEngineConfig()
+ config.appId = KeyCenter.AppId
+ agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self)
+ // configure engine, request permissions, then join
+ NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in
+ let option = AgoraRtcChannelMediaOptions()
+ self?.agoraKit?.joinChannel(byToken: token, channelId: channelName,
+ uid: 0, mediaOptions: option)
+ }
+ }
+
+ override func willMove(toParent parent: UIViewController?) {
+ super.willMove(toParent: parent)
+ if parent == nil {
+ agoraKit?.leaveChannel()
+ AgoraRtcEngineKit.destroy()
+ }
+ }
+}
+
+extension Main: AgoraRtcEngineDelegate {
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String,
+ withUid uid: UInt, elapsed: Int) {
+ LogUtils.log(message: "Joined: \(channel) uid: \(uid)", level: .info)
+ }
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
+ LogUtils.log(message: "Error: \(errorCode.rawValue)", level: .error)
+ }
+}
+```
+
+## Step 3 — Create the Storyboard
+
+Create `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard` with two scenes:
+
+| Scene | Storyboard ID | Class |
+|-------|--------------|-------|
+| Entry | `EntryViewController` | `Entry` |
+| Main | `` | `Main` |
+
+Connect a `Show` segue or use the manual push in `onJoinPressed`.
+
+Keep the storyboard inside the example folder. Do not place new case storyboards in the shared `APIExample/Base.lproj/` directory.
+
+## Default Entry UI Convention
+
+Unless the user explicitly asks for a different flow, use this default Entry layout and interaction:
+
+- One channel input field (`UITextField`) with placeholder `"Enter channel name".localized`
+- One join button (`UIButton`) with title `"Join".localized`
+- Join action validates non-empty channel name, dismisses keyboard, and pushes Main VC
+- Pass config via `configs = ["channelName": channelName]`
+
+Rationale:
+- This matches the dominant pattern used by existing APIExample cases
+- Keeps new cases consistent with existing user interaction and navigation
+- Minimizes refactor cost when RTC join logic is added later
+
+## Step 4 — Register the MenuItem
+
+Add to the `menus` array in `APIExample/ViewController.swift`:
+
+```swift
+MenuItem(name: "".localized,
+ storyboard: "",
+ controller: "")
+```
+
+Place it in the correct section (Basic / Advanced).
+
+## Step 5 — Update the Case Index
+
+Add a row to the `## Case Index` table in `ARCHITECTURE.md`:
+
+```markdown
+| | `Examples/[Basic|Advanced]//.swift` | `keyApi1()`, `keyApi2()` | One-line description |
+```
+
+Key APIs: list 2–5 core SDK methods the case demonstrates. Do not list `joinChannel`, `leaveChannel`, `destroy`, or `sharedEngine` unless they are the primary focus.
+
+---
+
+## Verification Checklist
+
+- [ ] Folder created under correct category (Basic / Advanced)
+- [ ] Both Entry and Main classes exist in the Swift file
+- [ ] Main inherits `BaseViewController`
+- [ ] Storyboard has correct scene IDs
+- [ ] Entry scene follows default UI convention (channel input + Join button), unless the user requested otherwise
+- [ ] MenuItem added to `ViewController.swift`
+- [ ] `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil`
+- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main`
+- [ ] Camera/microphone permissions requested before `joinChannel()`
+- [ ] Case Index row added/updated in `ARCHITECTURE.md`
+- [ ] Project builds without errors
+
+---
+
+## NEVER
+
+- NEVER create `AgoraRtcEngineKit` in the Entry VC
+- NEVER call `leaveChannel` or `destroy` in `viewDidDisappear` — use `willMove(toParent:)` with `parent == nil`
+- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }`
+- NEVER add a new scene to `Main.storyboard` — each case must have its own `.storyboard` file
+- NEVER share an `AgoraRtcEngineKit` instance between cases
+- NEVER call `joinChannel` before requesting camera/microphone permissions
+- NEVER skip updating the Case Index in `ARCHITECTURE.md`
diff --git a/iOS/APIExample/AGENTS.md b/iOS/APIExample/AGENTS.md
new file mode 100644
index 000000000..2401e937b
--- /dev/null
+++ b/iOS/APIExample/AGENTS.md
@@ -0,0 +1,32 @@
+# AGENTS.md — APIExample
+
+Full demo project. Covers all Agora RTC APIs using UIKit + Swift. Default choice when no specific variant is required.
+
+## Build Commands
+
+```bash
+pod install
+# Then open APIExample.xcworkspace in Xcode and build (Cmd+B)
+```
+
+## App ID Configuration
+
+Edit `APIExample/Common/KeyCenter.swift`:
+```swift
+static let AppId: String = "YOUR_APP_ID"
+static let Certificate: String? = nil // leave nil if App Certificate is not enabled
+```
+
+To obtain an App ID, see [README.md](README.md#obtain-an-app-id).
+
+## Skills
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and convention compliance |
+| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature |
+
+## Further Reading
+
+- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle
diff --git a/iOS/APIExample/ARCHITECTURE.md b/iOS/APIExample/ARCHITECTURE.md
new file mode 100644
index 000000000..8dd1b081c
--- /dev/null
+++ b/iOS/APIExample/ARCHITECTURE.md
@@ -0,0 +1,209 @@
+# ARCHITECTURE.md — APIExample
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/JoinChannelVideo.swift` | `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call — join channel and render local/remote video |
+| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/JoinChannelVideoToken.swift` | `joinChannel(byToken:)`, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication |
+| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/JoinChannelVideoRecorder.swift` | `createMediaRecorder()`, `joinChannel()`, `setupLocalVideo()` | Local and remote stream recording |
+| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.swift` | `joinChannel()`, `setAudioProfile()`, `enableAudioVolumeIndication()`, `adjustRecordingSignalVolume()` | Basic audio call with volume and in-ear monitoring controls |
+| LiveStreaming | `Examples/Advanced/LiveStreaming/LiveStreaming.swift` | `setClientRole()`, `setVideoScenario()`, `preloadChannel()`, `enableCameraCenterStage()` | Interactive live streaming with role switching and camera features |
+| RTMPStreaming | `Examples/Advanced/RTMPStreaming/RTMPStreaming.swift` | `startRtmpStreamWithoutTranscoding()`, `startRtmpStream(withTranscoding:)`, `updateRtmpTranscoding()`, `stopRtmpStream()` | Push stream to CDN with optional transcoding |
+| VideoMetadata | `Examples/Advanced/VideoMetadata/VideoMetadata.swift` | `setMediaMetadataDataSource()`, `setMediaMetadataDelegate()` | Send and receive metadata attached to video stream |
+| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.swift` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAINSMode()` | Voice beautifier, effects, conversion presets, and AI noise suppression |
+| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.swift` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as external audio source |
+| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.swift` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering |
+| CustomAudioSource | `Examples/Advanced/CustomAudioSource/CustomAudioSource.swift` | `createCustomAudioTrack()` | Push custom audio via mixable audio track |
+| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/CustomVideoSourcePush.swift` | `setExternalVideoSource()`, `pushExternalVideoFrame()` | Push external video frames as custom video source |
+| CustomVideoSourcePushMulti | `Examples/Advanced/CustomVideoSourcePushMulti/CustomVideoSourcePushMulti.swift` | `createCustomVideoTrack()`, `createCustomEncodedVideoTrack()`, `pushExternalEncodedVideoFrame()` | Multi-track custom video source with encoded frame push |
+| CustomVideoRender | `Examples/Advanced/CustomVideoRender/CustomVideoRender.swift` | `setVideoFrameDelegate()` | Custom rendering of remote video frames via delegate |
+| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.swift` | `setAudioFrameDelegate()`, `sendAudioMetadata()` | Capture raw audio PCM data via delegate |
+| RawVideoData | `Examples/Advanced/RawVideoData/RawVideoData.swift` | `setVideoFrameDelegate()` | Capture raw video frames via delegate |
+| RawMediaData | `Examples/Advanced/RawMediaData/RawMediaData.swift` | `setVideoFrameDelegate()`, `setAudioFrameDelegate()`, `setRecordingAudioFrameParametersWithSampleRate()`, `startAudioRecording()` | Capture both raw audio and video data simultaneously |
+| PictureInPicture | `Examples/Advanced/PictureInPicture/` | `AVPictureInPictureController`, `joinChannel()`, `setVideoFrameDelegate()` | Picture-in-Picture using AVKit (iOS 15+) |
+| SimpleFilter | `Examples/Advanced/SimpleFilter/SimpleFilter.swift` | `enableExtension()`, `setExtensionPropertyWithVendor()` | Apply audio/video filter via Agora Extension API |
+| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/QuickSwitchChannel.swift` | `joinChannel()`, `leaveChannel()` | Quickly switch between channels as audience |
+| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/JoinMultiChannel.swift` | `joinChannelEx()`, `takeSnapshotEx()` | Join multiple channels simultaneously via ex connection |
+| StreamEncryption | `Examples/Advanced/StreamEncryption/StreamEncryption.swift` | `enableEncryption()` | Built-in and custom stream encryption |
+| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.swift` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input |
+| PrecallTest | `Examples/Advanced/PrecallTest/PrecallTest.swift` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()`, `stopLastmileProbeTest()` | Pre-call echo test and last-mile network probe |
+| MediaPlayer | `Examples/Advanced/MediaPlayer/MediaPlayer.swift` | `createMediaPlayer()`, `updateChannelEx()` | Play media files and publish to channel via media player |
+| ScreenShare | `Examples/Advanced/ScreenShare/ScreenShare.swift` | `startScreenCapture()`, `updateScreenCapture()`, `stopScreenCapture()`, `setScreenCaptureScenario()` | Screen capture and sharing via ReplayKit extension |
+| LocalCompositeGraph | `Examples/Advanced/LocalCompositeGraph/LocalCompositeGraph.swift` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()`, `enableVirtualBackground()` | Composite multiple video sources locally before publishing |
+| VideoProcess | `Examples/Advanced/VideoProcess/VideoProcess.swift` | `setBeautyEffectOptions()`, `enableVirtualBackground()`, `enableExtension()` | Built-in beauty, virtual background, and video enhancement |
+| AgoraBeauty | `Examples/Advanced/AgoraBeauty/AgoraBeauty.swift` | `enableExtension()`, `enableVirtualBackground()` | Agora beauty extension with virtual background |
+| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.swift` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio |
+| CreateDataStream | `Examples/Advanced/CreateDataStream/CreateDataStream.swift` | `createDataStream()`, `sendStreamMessage()` | Create and send data stream messages between users |
+| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/MediaChannelRelay.swift` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Relay media stream to multiple destination channels |
+| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.swift` | `createMediaPlayer()`, `updateChannel()` | 3D spatial audio with media player integration |
+| ContentInspect | `Examples/Advanced/ContentInspect/ContentInspect.swift` | `enableContentInspect()`, `switchCamera()` | Moderate content in video stream |
+| MutliCamera | `Examples/Advanced/MutliCamera/MutliCamera.swift` | `enableMultiCamera()`, `startCameraCapture()`, `stopCameraCapture()` | Capture from front and back cameras simultaneously (iOS 13+) |
+| KtvCopyrightMusic | `Examples/Advanced/KtvCopyrightMusic/KtvCopyrightMusic.swift` | — | Links to KTV copyright music documentation |
+| ThirdBeautify | `Examples/Advanced/ThirdBeautify/ThirdBeautify.swift` | `enableExtension()` | Third-party beauty SDK integration (ByteDance / FaceUnity / SenseTime) |
+| ARKit | `Examples/Advanced/ARKit/ARKit.swift` | `setVideoFrameDelegate()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()` | Push ARKit face tracking frames as custom video source |
+| AudioRouterPlayer | `Examples/Advanced/AudioRouterPlayer/AudioRouterPlayer.swift` | `setEnableSpeakerphone()` | Control audio output routing with third-party player |
+| AudioWaveform | `Examples/Advanced/AudioWaveform/AudioWaveform.swift` | `setAudioProfile()`, `enableAudioVolumeIndication()` | Visualize audio waveform from volume callbacks |
+| FaceCapture | `Examples/Advanced/FaceCapture/FaceCapture.swift` | `enableExtension()`, `setExtensionPropertyWithVendor()`, `setFaceInfoDelegate()` | Face capture and lip sync via Agora extension |
+| TransparentRender | `Examples/Advanced/TransparentRender/TransparentRender.swift` | `createMediaPlayer()`, `setExternalVideoSource()`, `pushExternalVideoFrame()` | Render video with transparent background |
+| RtePlayer | `Examples/Advanced/RtePlayer/RtePlayer.swift` | `AgoraRte`, `AgoraRtePlayer`, `AgoraRteCanvas` | URL-based stream playback via RTE Player API |
+| Simulcast | `Examples/Advanced/Simulcast/Simulcast.swift` | `setSimulcastConfig()`, `setRemoteVideoStream()` | Publish multiple video quality layers simultaneously |
+| Multipath | `Examples/Advanced/Multipath/Multipath.swift` | `updateChannel()` | Multi-path network transmission configuration |
+
+## Directory Layout
+
+```
+APIExample/
+├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS, Floaty, AGEVideoLayout, etc.)
+├── SimpleFilter/ # Optional C++ audio/video extension module
+├── Agora-ScreenShare-Extension/ # ReplayKit broadcast extension for screen sharing
+├── ByteEffectLib/ # Optional ByteDance beauty SDK resources
+├── FULib/ # Optional FaceUnity beauty SDK resources
+├── SenseLib/ # Optional SenseTime beauty SDK resources
+├── libs/ # Local SDK frameworks (when not using CocoaPods)
+└── APIExample/
+ ├── AppDelegate.swift
+ ├── ViewController.swift # Root menu controller — MenuItem registration lives here
+ ├── Info.plist
+ ├── APIExample.entitlements
+ ├── APIExample-Bridging-Header.h
+ │
+ ├── Common/
+ │ ├── KeyCenter.swift # App ID and Certificate
+ │ ├── GlobalSettings.swift # Shared runtime config (resolution, fps, orientation, role)
+ │ ├── BaseViewController.swift # Base class all Main VCs must extend
+ │ ├── EntryViewController.swift # Generic Entry VC for storyboard == "Main" cases
+ │ ├── LogViewController.swift # Log viewer
+ │ ├── AlertManager.swift
+ │ ├── AgoraExtension.swift
+ │ ├── PickerView.swift
+ │ ├── StatisticsInfo.swift
+ │ ├── UITypeAlias.swift
+ │ ├── VideoView.swift / .xib # Reusable video rendering view
+ │ ├── Settings/ # Settings UI components
+ │ ├── Utils/ # LogUtils, Util (privatization config)
+ │ ├── NetworkManager/ # Token request helper
+ │ ├── ExternalAudio/ # External audio source helpers
+ │ ├── ExternalVideo/ # External video source helpers
+ │ ├── CustomEncryption/ # Custom stream encryption helpers
+ │ └── ARKit/ # ARKit integration helpers
+ │
+ ├── Examples/
+ │ ├── Basic/
+ │ │ ├── JoinChannelVideo/ # "Join a channel (Video)"
+ │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)"
+ │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording"
+ │ │ └── JoinChannelAudio/ # "Join a channel (Audio)"
+ │ └── Advanced/
+ │ ├── LiveStreaming/ # "Live Streaming" — setClientRole
+ │ ├── RTMPStreaming/ # "RTMP Streaming" — push to CDN
+ │ ├── VideoMetadata/ # "Video Metadata" — send/receive metadata
+ │ ├── VoiceChanger/ # "Voice Changer" — voice beautifier/effects
+ │ ├── CustomPcmAudioSource/ # "Custom Audio Source" — push PCM audio
+ │ ├── CustomAudioRender/ # "Custom Audio Render" — pull audio rendering
+ │ ├── CustomAudioSource/ # (legacy custom audio source)
+ │ ├── CustomVideoSourcePush/ # "Custom Video Source(Push)" — push external video
+ │ ├── CustomVideoSourcePushMulti/ # "Custom Video Source(Multi)" — multi-track push
+ │ ├── CustomVideoRender/ # "Custom Video Render"
+ │ ├── RawAudioData/ # "Raw Audio Data"
+ │ ├── RawVideoData/ # "Raw Video Data"
+ │ ├── RawMediaData/ # (legacy raw media data)
+ │ ├── PictureInPicture/ # "Picture In Picture (iOS15+)"
+ │ ├── SimpleFilter/ # "Simple Filter Extension"
+ │ ├── QuickSwitchChannel/ # "Quick Switch Channel"
+ │ ├── JoinMultiChannel/ # "Join Multiple Channels"
+ │ ├── StreamEncryption/ # "Stream Encryption"
+ │ ├── AudioMixing/ # "Audio Mixing"
+ │ ├── PrecallTest/ # "Precall Test"
+ │ ├── MediaPlayer/ # "Media Player"
+ │ ├── ScreenShare/ # "Screen Share"
+ │ ├── LocalCompositeGraph/ # "Local Composite Graph"
+ │ ├── VideoProcess/ # "Video Process"
+ │ ├── AgoraBeauty/ # "Agora Beauty"
+ │ ├── RhythmPlayer/ # "Rhythm Player"
+ │ ├── CreateDataStream/ # "Create Data Stream"
+ │ ├── MediaChannelRelay/ # "Media Channel Relay"
+ │ ├── SpatialAudio/ # "Spatial Audio"
+ │ ├── ContentInspect/ # "Content Inspect"
+ │ ├── MutliCamera/ # "Multi Camera (iOS13+)"
+ │ ├── KtvCopyrightMusic/ # "KTV Copyright Music"
+ │ ├── ThirdBeautify/ # "Third Beautify" — third-party beauty SDK (includes SenseBeautify/ subfolder, domestic)
+ │ ├── ARKit/ # "ARKit"
+ │ ├── AudioRouterPlayer/ # "Audio Router (Third Party Player)"
+ │ ├── AudioWaveform/ # "Audio Waveform"
+ │ ├── FaceCapture/ # "Face Capture"
+ │ ├── TransparentRender/ # "Transparent Render"
+ │ ├── RtePlayer/ # "URL Streaming (RTE Player)"
+ │ ├── Simulcast/ # "Simulcast"
+ │ ├── Multipath/ # "Multipath"
+ │ └── VideoChat/ # (disabled) Group Video Chat
+ │
+ ├── Resources/ # Audio/video sample files; beauty_material.bundle (Agora beauty, domestic)
+ ├── Assets.xcassets/
+ ├── Base.lproj/ # Main.storyboard, LaunchScreen.storyboard
+ └── zh-Hans.lproj/ # Chinese localization
+```
+
+
+## Case Registration Mechanism
+
+Registration is **manual** via the `menus` array in `ViewController.swift`. No reflection or annotation scanning.
+
+**`MenuItem` struct:**
+```swift
+struct MenuItem {
+ var name: String // display name in the list
+ var entry: String // storyboard ID of the entry VC (default: "EntryViewController")
+ var storyboard: String // storyboard file name (default: "Main")
+ var controller: String // storyboard ID of the main VC
+ var note: String // optional description
+}
+```
+
+**Two navigation paths exist depending on `storyboard`:**
+
+1. `storyboard == "Main"` — uses the shared `Main.storyboard`. The generic `EntryViewController` is instantiated, and `nextVCIdentifier` is set to `controller` to load the Main VC.
+2. `storyboard != "Main"` — each example has its own `.storyboard` file. The VC with identifier `entry` (default `"EntryViewController"`) is instantiated directly from that storyboard.
+
+Most examples use path 2 (their own storyboard).
+
+**To add a case, edit exactly two things:**
+1. Add a `MenuItem` to the `menus` array in `ViewController.swift`
+2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift file(s) and storyboard
+
+## Entry/Main ViewController Pattern
+
+Every example is split into two view controller roles:
+
+**Entry** (`Entry : UIViewController`)
+- Collects user configuration before entering the example
+- Passes configuration to Main via a `configs` dictionary
+
+**Main** (`Main : BaseViewController`)
+- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example
+- Implements `AgoraRtcEngineDelegate`
+- Receives configuration exclusively through `configs`
+
+## AgoraRtcEngineKit Lifecycle
+
+```
+viewDidLoad → AgoraRtcEngineKit.sharedEngine(withAppId:delegate:)
+ → engine.setVideoEncoderConfiguration / setChannelProfile
+ → engine.joinChannel() (after permission granted)
+ ↓
+ [AgoraRtcEngineDelegate callbacks — may be on background thread]
+ ↓
+viewDidDisappear / willMove(toParent:)
+ → engine.leaveChannel()
+ → AgoraRtcEngineKit.destroy()
+```
+
+## Token Flow
+
+```swift
+NetworkManager.shared.generateToken(channelName: channelId, uid: uid) { token in
+ self.agoraKit?.joinChannel(byToken: token, channelId: channelId, uid: uid, mediaOptions: options)
+}
+```
+
+If `KeyCenter.Certificate` is nil, token generation is skipped and a nil token is used — valid for projects without App Certificate.
diff --git a/iOS/APIExample/CLAUDE.md b/iOS/APIExample/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/iOS/APIExample/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/iOS/ARCHITECTURE.md b/iOS/ARCHITECTURE.md
new file mode 100644
index 000000000..6648ac527
--- /dev/null
+++ b/iOS/ARCHITECTURE.md
@@ -0,0 +1,49 @@
+# ARCHITECTURE.md
+
+Four independent iOS example projects sharing one Xcode workspace, each managing dependencies via CocoaPods.
+For internal details of each project, see the project-level `ARCHITECTURE.md`.
+
+---
+
+## APIExample — Full Demo
+
+- Language: Swift
+- UI Framework: UIKit + Storyboards
+- SDK: AgoraRtcEngine_iOS (full-featured)
+- Architecture: Entry/Main ViewController pattern
+- Case registration: `MenuItem` array in `ViewController.swift`
+- Details: [APIExample/ARCHITECTURE.md](APIExample/ARCHITECTURE.md)
+
+---
+
+## APIExample-SwiftUI — SwiftUI Demo
+
+- Language: Swift
+- UI Framework: SwiftUI
+- SDK: AgoraRtcEngine_iOS (full-featured)
+- Architecture: MVVM (View + ViewModel)
+- Case registration: navigation destinations in `ContentView.swift`
+- Details: [APIExample-SwiftUI/ARCHITECTURE.md](APIExample-SwiftUI/ARCHITECTURE.md)
+
+---
+
+## APIExample-OC — Objective-C Demo
+
+- Language: Objective-C
+- UI Framework: UIKit + Storyboards
+- SDK: AgoraRtcEngine_iOS (full-featured)
+- Architecture: Entry/Main ViewController pattern (same as APIExample)
+- Case registration: `MenuItem` array in `ViewController.m`
+- Details: [APIExample-OC/ARCHITECTURE.md](APIExample-OC/ARCHITECTURE.md)
+
+---
+
+## APIExample-Audio — Audio-Only Demo
+
+- Language: Swift
+- UI Framework: UIKit + Storyboards
+- SDK: AgoraAudio_iOS (no video module)
+- Architecture: Entry/Main ViewController pattern
+- Case registration: `MenuItem` array in `ViewController.swift`
+- Constraint: no video rendering views
+- Details: [APIExample-Audio/ARCHITECTURE.md](APIExample-Audio/ARCHITECTURE.md)
diff --git a/iOS/CLAUDE.md b/iOS/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/iOS/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/macOS/.agent/skills/review-case/SKILL.md b/macOS/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..92d813ec2
--- /dev/null
+++ b/macOS/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,380 @@
+---
+name: review-case
+description: >
+ Code review for API examples. Ensures examples follow project conventions,
+ handle lifecycle correctly, manage threads safely, and use APIs properly.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: macOS
+---
+
+# Review Case Skill — macOS
+
+## When to Use
+
+Use this skill when you need to:
+- Review a new or modified example for correctness
+- Ensure the example follows project conventions
+- Verify lifecycle management and thread safety
+- Check API usage and error handling
+
+## Review Dimensions (Priority Order)
+
+### 1. Engine Lifecycle (CRITICAL)
+
+**Check:**
+- [ ] Engine is created in `initializeAgoraEngine()` or similar
+- [ ] Engine is initialized with `AgoraRtcEngineConfig`
+- [ ] `leaveChannel()` is called before `destroy()`
+- [ ] `destroy()` is called in `viewWillClose()` or cleanup method
+- [ ] No engine leaks (engine not recreated on every join)
+
+**Correct Pattern:**
+```swift
+override func viewDidLoad() {
+ super.viewDidLoad()
+ initializeAgoraEngine() // Create once
+}
+
+override func viewWillClose() {
+ leaveChannel()
+ agoraKit.destroy()
+ super.viewWillClose()
+}
+
+func joinChannel() {
+ agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0)
+}
+
+func leaveChannel() {
+ agoraKit.leaveChannel(nil)
+}
+```
+
+**Incorrect Pattern:**
+See `references/incorrect-lifecycle.swift` for common mistakes.
+
+---
+
+### 2. Thread Safety (CRITICAL)
+
+**Check:**
+- [ ] All UI updates in delegate callbacks use `DispatchQueue.main.async`
+- [ ] No direct UI updates from background threads
+- [ ] Video/audio frame callbacks dispatch to main thread before updating UI
+
+**Correct Pattern:**
+```swift
+func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) {
+ // Callback may arrive on background thread
+ DispatchQueue.main.async {
+ self.statusLabel.stringValue = "Joined channel"
+ }
+}
+```
+
+**Incorrect Pattern:**
+See `references/incorrect-thread-safety.swift` for common mistakes.
+
+---
+
+### 3. Permission Handling (HIGH)
+
+**Check:**
+- [ ] Microphone permission requested before `enableAudio()`
+- [ ] Camera permission requested before `enableVideo()`
+- [ ] Permissions checked before accessing devices
+- [ ] Review guidance stays macOS-specific and does not suggest iOS-only APIs such as `AVAudioSession.sharedInstance().requestRecordPermission`
+
+**Correct Pattern:**
+```swift
+func initializeAgoraEngine() {
+ // Request permissions first
+ AVCaptureDevice.requestAccess(for: .video) { granted in
+ if granted {
+ self.agoraKit.enableVideo()
+ }
+ }
+
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
+ if granted {
+ self.agoraKit.enableAudio()
+ }
+ }
+}
+```
+
+---
+
+### 4. Error Handling (HIGH)
+
+**Check:**
+- [ ] `joinChannel()` failures are handled
+- [ ] Token expiration is handled
+- [ ] Network errors are logged or displayed
+- [ ] Invalid parameters are validated
+
+**Correct Pattern:**
+```swift
+func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
+ DispatchQueue.main.async {
+ self.showError("Error: \(errorCode.rawValue)")
+ }
+}
+
+func rtcEngine(_ engine: AgoraRtcEngineKit, tokenPrivilegeWillExpire token: String) {
+ // Refresh token before expiration
+ let newToken = KeyCenter.Token(channelName: self.channelName)
+ self.agoraKit.renewToken(newToken)
+}
+```
+
+---
+
+### 5. Code Convention (MEDIUM)
+
+**Check:**
+- [ ] Class name follows pattern: `Main`
+- [ ] Extends `BaseViewController`
+- [ ] File name matches class name (PascalCase)
+- [ ] Properties are properly declared with `@IBOutlet` or `var`
+- [ ] Methods are organized with `// MARK:` sections
+- [ ] Comments explain non-obvious logic
+
+**Correct Pattern:**
+```swift
+class ScreenShareMain: BaseViewController {
+
+ var agoraKit: AgoraRtcEngineKit!
+ var remoteUid: UInt = 0
+
+ @IBOutlet weak var Container: AGEVideoContainer!
+
+ // MARK: - Lifecycle
+ override func viewDidLoad() { ... }
+
+ // MARK: - Agora Engine Setup
+ func initializeAgoraEngine() { ... }
+
+ // MARK: - Actions
+ @IBAction func joinButtonTapped(_ sender: Any) { ... }
+}
+```
+
+---
+
+### 6. API Usage Correctness (MEDIUM)
+
+**Check:**
+- [ ] SDK methods called in correct order
+- [ ] Required parameters are provided
+- [ ] Optional parameters are used correctly
+- [ ] Return values are checked where necessary
+- [ ] Deprecated APIs are not used
+
+**Correct Pattern:**
+```swift
+// Correct order: enable -> setup -> join
+agoraKit.enableVideo()
+agoraKit.setupLocalVideo(AgoraRtcVideoCanvas(uid: 0))
+agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0)
+```
+
+**Incorrect Pattern:**
+```swift
+// ❌ Wrong order
+agoraKit.joinChannel(...) // Join first
+agoraKit.enableVideo() // Enable after join (too late)
+```
+
+---
+
+### 7. Resource Cleanup (MEDIUM)
+
+**Check:**
+- [ ] Audio files are stopped and released
+- [ ] Video captures are stopped
+- [ ] Custom audio/video sources are cleaned up
+- [ ] Observers are unregistered
+- [ ] Timers are invalidated
+
+**Correct Pattern:**
+```swift
+func leaveChannel() {
+ agoraKit.stopAudioMixing() // Stop audio
+ agoraKit.stopScreenCapture() // Stop screen share
+ agoraKit.leaveChannel(nil)
+}
+
+override func viewWillClose() {
+ leaveChannel()
+ agoraKit.destroy()
+ super.viewWillClose()
+}
+```
+
+---
+
+## Review Output Format
+
+When reviewing, provide feedback in this format:
+
+```
+## Review Results
+
+### ✅ Passed
+- Engine lifecycle correctly managed
+- Thread safety ensured with DispatchQueue.main.async
+- Permissions requested before device access
+
+### ⚠️ Issues Found
+
+**[HIGH] Thread Safety Issue**
+- File: `ScreenShare.swift`
+- Line: 45
+- Issue: UI update in delegate callback without DispatchQueue.main.async
+- Suggestion: Wrap UI update with `DispatchQueue.main.async { ... }`
+
+**[MEDIUM] Missing Error Handling**
+- File: `ScreenShare.swift`
+- Line: 78
+- Issue: joinChannel() result not checked
+- Suggestion: Implement `rtcEngine(_:didOccurError:)` delegate method
+
+### 🔧 Recommendations
+- Add logging for debugging
+- Consider adding retry logic for network failures
+```
+
+---
+
+## Platform-Specific Checks
+
+### macOS-Specific
+
+**Check:**
+- [ ] Using Cocoa (AppKit) — not UIKit or SwiftUI
+- [ ] Window/view lifecycle properly handled
+- [ ] No Combine or async/await unless already in codebase
+- [ ] Storyboard/XIB files properly configured if used
+
+**Correct Pattern:**
+```swift
+// macOS: Use Cocoa
+import Cocoa
+import AgoraRtcKit
+
+class ExampleMain: BaseViewController {
+ @IBOutlet weak var Container: AGEVideoContainer!
+ // Cocoa-based UI
+}
+```
+
+**Incorrect Pattern:**
+```swift
+// ❌ iOS patterns in macOS
+import UIKit // Wrong framework
+class ExampleMain: UIViewController { } // Wrong base class
+```
+
+---
+
+## NEVER List
+
+**Do NOT accept:**
+- Engine not destroyed (memory leak)
+- UI updates from background threads without DispatchQueue.main.async
+- Multiple engine instances in one example
+- Hardcoded App ID or token (must use KeyCenter)
+- Missing `leaveChannel()` before `destroy()`
+- Objective-C files (Swift only)
+- UIKit or SwiftUI (Cocoa only)
+- Examples outside `APIExample/Examples/[Basic|Advanced]/` structure
+- Missing delegate implementation for event handling
+- No error handling for joinChannel failures
+
+---
+
+## Review Checklist
+
+Use this checklist when reviewing an example:
+
+**Lifecycle:**
+- [ ] Engine created once in initialization
+- [ ] `leaveChannel()` called before `destroy()`
+- [ ] `destroy()` called in cleanup
+- [ ] No engine leaks
+
+**Thread Safety:**
+- [ ] All UI updates use `DispatchQueue.main.async`
+- [ ] No direct UI updates from callbacks
+- [ ] Frame callbacks dispatch to main thread
+
+**Permissions:**
+- [ ] Microphone permission requested
+- [ ] Camera permission requested
+- [ ] Permissions checked before use
+
+**Error Handling:**
+- [ ] joinChannel failures handled
+- [ ] Token expiration handled
+- [ ] Network errors logged
+
+**Code Quality:**
+- [ ] Follows naming conventions
+- [ ] Properly organized with MARK sections
+- [ ] Comments explain non-obvious logic
+- [ ] No hardcoded credentials
+
+**API Usage:**
+- [ ] Methods called in correct order
+- [ ] Required parameters provided
+- [ ] Return values checked
+- [ ] No deprecated APIs
+
+**Resources:**
+- [ ] Audio/video properly stopped
+- [ ] Observers unregistered
+- [ ] Timers invalidated
+- [ ] No resource leaks
+
+**Platform:**
+- [ ] Using Cocoa (AppKit)
+- [ ] No UIKit or SwiftUI
+- [ ] Window lifecycle handled
+- [ ] No modern C++ patterns unless existing
+
+---
+
+## Common Issues and Fixes
+
+### Issue: "Engine not initialized"
+**Cause:** `destroy()` called without `leaveChannel()` first
+**Fix:** Always call `leaveChannel()` before `destroy()`
+
+### Issue: "UI updates crash the app"
+**Cause:** Direct UI update from background thread
+**Fix:** Wrap with `DispatchQueue.main.async { ... }`
+
+### Issue: "Memory leak detected"
+**Cause:** `destroy()` not called or engine recreated
+**Fix:** Ensure `destroy()` in `viewWillClose()` and create engine once
+
+### Issue: "Token expired error"
+**Cause:** No token refresh handling
+**Fix:** Implement `tokenPrivilegeWillExpire()` delegate method
+
+### Issue: "No audio/video"
+**Cause:** Permissions not requested
+**Fix:** Request permissions before `enableAudio()` / `enableVideo()`
+
+---
+
+## References
+
+- **Agora RTC SDK for macOS:** [Documentation](https://docs.agora.io/en/video-calling/reference/macos-sdk)
+- **Existing examples:** Review `APIExample/Examples/Basic/JoinChannelVideo/` for reference
+- **BaseViewController:** Check `APIExample/Common/` for base class implementation
diff --git a/macOS/.agent/skills/upsert-case/SKILL.md b/macOS/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..4e7b61909
--- /dev/null
+++ b/macOS/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,185 @@
+---
+name: upsert-case
+description: >
+ Add a new API example or modify an existing one. Covers both creation and modification scenarios,
+ including file structure, per-example storyboard creation, registration, and ARCHITECTURE.md updates.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: macOS
+---
+
+# Upsert Case Skill — macOS
+
+## When to Use
+
+Use this skill when you need to:
+- Create a new API example (case)
+- Modify an existing example
+- Ensure the example is properly registered and documented
+
+## Applicable Scenarios
+
+### Scenario 1: Create a New Example
+
+**Trigger:** User requests a new API demo (e.g., "Add a screen sharing example")
+
+**Steps:**
+1. Determine if the example belongs in `Basic/` or `Advanced/`
+2. Create the example folder with PascalCase name
+3. Create the Swift implementation file
+4. Create the example storyboard
+5. Register the example in `ViewController.swift`
+6. Update `ARCHITECTURE.md` Case Index
+7. Verify compilation and functionality
+
+### Scenario 2: Modify an Existing Example
+
+**Trigger:** User requests changes to an existing example (e.g., "Update JoinChannelVideo to support token")
+
+**Steps:**
+1. Locate the example in `APIExample/Examples/[Basic|Advanced]//`
+2. Modify the Swift file
+3. Modify the storyboard if outlets, actions, or controller identifiers changed
+4. Update `ARCHITECTURE.md` Case Index if APIs changed
+5. Verify compilation and functionality
+
+---
+
+## Files to Modify
+
+### New Example
+
+| File | Action | Notes |
+|------|--------|-------|
+| `APIExample/Examples/[Basic\|Advanced]//.swift` | Create | Main implementation file |
+| `APIExample/Examples/[Basic\|Advanced]//Base.lproj/.storyboard` | Create | Example UI and controller identifier |
+| `APIExample/ViewController.swift` | Modify | Register example in menu/list |
+| `ARCHITECTURE.md` | Modify | Add entry to Case Index |
+
+### Modify Existing Example
+
+| File | Action | Notes |
+|------|--------|-------|
+| `APIExample/Examples/[Basic\|Advanced]//.swift` | Modify | Update implementation |
+| `APIExample/Examples/[Basic\|Advanced]//Base.lproj/.storyboard` | Modify if needed | Keep outlets and controller identifiers aligned |
+| `ARCHITECTURE.md` | Modify | Update Case Index if APIs changed |
+
+---
+
+## Step-by-Step Guide
+
+### Step 1: Determine Example Category
+
+- **Basic:** Simple, single-feature examples (JoinChannelVideo, JoinChannelAudio)
+- **Advanced:** Complex features, multi-API examples (ScreenShare, CustomVideoSource)
+
+### Step 2: Create Example Folder
+
+```bash
+mkdir -p APIExample/Examples/[Basic|Advanced]/
+```
+
+Example folder name must be PascalCase and match the class name.
+
+### Step 3: Create Swift Implementation
+
+Create `APIExample/Examples/[Basic|Advanced]//.swift`
+
+Use the template from `references/example-template.swift` as a starting point. Replace `` with your example name.
+
+### Step 4: Create the Example Storyboard
+
+Create `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard`.
+
+The storyboard name must match the value passed to `NSStoryboard(name:bundle:)` from `ViewController.swift`, and the main controller identifier in the storyboard must match the `controller` field in `MenuItem`.
+
+### Step 5: Register in ViewController
+
+Edit `APIExample/ViewController.swift` and add the example to the menu/list:
+
+```swift
+MenuItem(name: "Example Name".localized,
+ identifier: "menuCell",
+ controller: "",
+ storyboard: "")
+```
+
+Place it in the correct section (Basic / Advanced).
+
+### Step 6: Update ARCHITECTURE.md
+
+Add a new row to the Case Index table in `ARCHITECTURE.md`:
+
+```markdown
+| ExampleName | `Examples/[Basic|Advanced]/ExampleName/` | `api1()`, `api2()`, `api3()` | Brief description of what this example demonstrates |
+```
+
+**Key APIs column:** List 2-5 core SDK methods used in this example.
+
+### Step 7: Verify
+
+- [ ] Code compiles without errors
+- [ ] Example appears in the menu/list
+- [ ] Example can join channel and receive callbacks
+- [ ] `leaveChannel()` and `destroy()` are called on close
+- [ ] UI updates happen on main thread
+- [ ] Storyboard loads with the expected controller identifier
+- [ ] ARCHITECTURE.md Case Index is updated
+
+---
+
+## Code Patterns
+
+See `references/` directory for code patterns:
+- `lifecycle-pattern.swift` — Proper engine lifecycle
+- `thread-safety-pattern.swift` — Thread-safe UI updates
+
+---
+
+## NEVER List
+
+**Do NOT:**
+- Forget to call `destroy()` — this causes engine leaks
+- Update UI directly from delegate callbacks — always use `DispatchQueue.main.async`
+- Create multiple engine instances in one example — use a single shared instance
+- Use Objective-C files — Swift only
+- Use UIKit or SwiftUI — Cocoa (AppKit) only
+- Hardcode App ID or token — use `KeyCenter`
+- Forget to implement `AgoraRtcEngineDelegate` for event handling
+- Leave the channel without calling `leaveChannel()` first
+- Modify examples outside the `APIExample/Examples/[Basic|Advanced]/` structure
+- Register a new menu item without also creating the per-example storyboard under the same example folder
+- Forget to update `ARCHITECTURE.md` Case Index after adding/modifying an example
+
+---
+
+## Verification Checklist
+
+After completing the upsert, verify:
+
+- [ ] Example folder is in correct location (`APIExample/Examples/[Basic|Advanced]//`)
+- [ ] Swift file is named `.swift` (PascalCase)
+- [ ] Class name is `Main` and extends `BaseViewController`
+- [ ] Storyboard exists at `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard`
+- [ ] Storyboard identifier matches the `controller` value registered in `ViewController.swift`
+- [ ] Example is registered in `ViewController.swift`
+- [ ] `initializeAgoraEngine()` creates engine with correct config
+- [ ] `joinChannel()` uses token from `KeyCenter`
+- [ ] `leaveChannel()` and `destroy()` are called in `viewWillClose()`
+- [ ] All delegate callbacks dispatch UI updates to main thread
+- [ ] `ARCHITECTURE.md` Case Index includes new/updated example
+- [ ] Code compiles without warnings or errors
+- [ ] Example appears in the application menu/list
+- [ ] Example can successfully join channel and receive callbacks
+- [ ] Example properly cleans up resources on close
+
+---
+
+## References
+
+- **Template files:** See `references/` directory for Swift code templates
+- **Existing examples:** Review `APIExample/Examples/Basic/JoinChannelVideo/` for reference implementation
+- **SDK documentation:** Refer to Agora RTC SDK for macOS documentation for API details
diff --git a/macOS/AGENTS.md b/macOS/AGENTS.md
new file mode 100644
index 000000000..2116452eb
--- /dev/null
+++ b/macOS/AGENTS.md
@@ -0,0 +1,69 @@
+# AGENTS.md — macOS
+
+## Project Context
+
+This is the Swift + Cocoa implementation of Agora RTC SDK examples for macOS. Before making any changes, read `ARCHITECTURE.md` to understand the structural rules.
+
+## Build Commands
+
+```bash
+# Install dependencies
+pod install
+
+# Build in Xcode
+xcodebuild -workspace APIExample.xcworkspace -scheme APIExample -configuration Release
+
+# Or open in Xcode and build manually
+open APIExample.xcworkspace
+```
+
+## App ID Configuration
+
+Configure your Agora App ID in `APIExample/Common/KeyCenter.swift`:
+
+```swift
+struct KeyCenter {
+ static let AppId: String = "<#YOUR_APP_ID#>"
+
+ // Token is optional for testing, but required for production
+ static func Token(channelName: String) -> String {
+ return "<#YOUR_TOKEN#>"
+ }
+}
+```
+
+## Architecture Red Lines
+
+**Do NOT:**
+- Introduce UIKit or SwiftUI — use Cocoa (AppKit) only
+- Use Combine or async/await patterns unless already present in the file being modified
+- Create examples outside the `APIExample/Examples/[Basic|Advanced]/` directory structure
+- Forget to call `leaveChannel()` and `destroy()` when closing an example
+- Update UI from background threads — always dispatch to main thread
+- Share engine instances between examples — each example manages its own lifecycle
+
+## Rules
+
+### Follow the Architecture
+
+All work must conform to the rules defined in `ARCHITECTURE.md`:
+- Every example is a self-contained class implementing `AgoraRtcEngineDelegate`
+- Each example manages its own Agora engine lifecycle
+- Configuration is passed via initialization or property injection
+- All examples are registered in `APIExample/ViewController.swift`
+
+### Follow the Existing Language and Framework
+
+- Language is Swift — do not introduce Objective-C files
+- UI framework is Cocoa (AppKit) — do not introduce UIKit or SwiftUI
+- State management uses instance variables and delegate callbacks — do not introduce Combine or async/await patterns unless they already exist in the file being modified
+- Match the code style, naming, and patterns of existing examples
+
+### Use Project-Level SKILLs
+
+For broader tasks, use the skills in `.agent/skills/`:
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify an example | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review example code for lifecycle, thread safety, and convention compliance |
diff --git a/macOS/ARCHITECTURE.md b/macOS/ARCHITECTURE.md
new file mode 100644
index 000000000..61149da5b
--- /dev/null
+++ b/macOS/ARCHITECTURE.md
@@ -0,0 +1,155 @@
+# macOS ARCHITECTURE
+
+macOS example project using Swift + Cocoa. Demonstrates Agora RTC SDK features through a collection of self-contained examples organized by complexity.
+
+## Technology Stack
+
+- Language: Swift
+- UI Framework: Cocoa (AppKit)
+- Architecture: Single-window application with example selection
+- State: Instance variables + delegate callbacks
+
+## Directory Structure
+
+```
+macOS/
+├── APIExample/
+│ ├── Examples/
+│ │ ├── Basic/
+│ │ │ └── /
+│ │ │ ├── .swift
+│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming)
+│ │ └── Advanced/
+│ │ └── /
+│ │ ├── .swift
+│ │ └── SKILL.md # Per-example agent guide (present or forthcoming)
+│ ├── Common/ # Shared utilities (KeyCenter, GlobalSettings, LogUtils, Util)
+│ ├── Resources/
+│ ├── Base.lproj/ # Storyboard and localization
+│ ├── AppDelegate.swift
+│ └── ViewController.swift # Main window controller
+├── SimpleFilter/ # Specialized filter example
+├── APIExample.xcodeproj/ # Xcode project
+├── APIExample.xcworkspace/ # Xcode workspace
+├── libs/ # SDK libraries
+├── Pods/ # CocoaPods dependencies
+├── .agent/skills/ # Agent skills
+│ ├── create-api-example/
+│ ├── find-api-example/
+│ └── migrate-api-to-project/
+├── AGENTS.md # Agent guide
+└── ARCHITECTURE.md # This file
+```
+
+## Architectural Rules
+
+### Example Structure
+
+Each example lives in its own folder under `APIExample/Examples/Basic/` or `APIExample/Examples/Advanced/` and consists of:
+- A Swift file containing the example implementation
+- A per-example storyboard, typically at `Base.lproj/.storyboard`
+
+### Example Pattern
+
+Each example is a self-contained class that:
+- Manages its own Agora engine lifecycle
+- Implements `AgoraRtcEngineDelegate`
+- Receives configuration via initialization or property injection
+- Owns all UI elements for that example
+
+### Menu Registration
+
+All examples are registered in `APIExample/ViewController.swift` via a menu or list structure. The example name must match the folder name.
+
+### Naming
+
+- Example folder names: PascalCase (e.g., `JoinChannelVideo`)
+- Example class: `` (e.g., `JoinChannelVideo`)
+
+### Common Utilities
+
+All examples share utilities from `APIExample/Common/`:
+- `KeyCenter` — App ID and token
+- `GlobalSettings` — Shared runtime configuration
+- `LogUtils` — SDK log path
+- `Util` — Privatization configuration
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/` | `createAgoraRtcEngine()`, `joinChannel()`, `leaveChannel()`, `destroy()` | Basic audio call — join channel and manage audio stream |
+| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/` | `createAgoraRtcEngine()`, `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()`, `leaveChannel()`, `destroy()` | Basic video call — join channel and render local/remote video |
+| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/` | `createAgoraRtcEngine()`, `joinChannel()` with token, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication |
+| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/` | `createAgoraRtcEngine()`, `joinChannel()`, `startAudioRecording()`, `stopAudioRecording()` | Video call with local audio recording |
+| AgoraBeauty | `Examples/Advanced/AgoraBeauty/` | `setBeautyEffectOptions()`, `setVideoEncoderConfiguration()` | Beauty filter and enhancement effects |
+| AudioMixing | `Examples/Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()` | Audio file mixing and playback control |
+| ChannelMediaRelay | `Examples/Advanced/ChannelMediaRelay/` | `startChannelMediaRelay()`, `updateChannelMediaRelay()`, `stopChannelMediaRelay()` | Relay media streams across multiple channels |
+| ContentInspect | `Examples/Advanced/ContentInspect/` | `enableContentInspect()`, `disableContentInspect()` | Content inspection and moderation |
+| CreateDataStream | `Examples/Advanced/CreateDataStream/` | `createDataStream()`, `sendStreamMessage()` | Custom data stream creation and messaging |
+| CustomAudioRender | `Examples/Advanced/CustomAudioRender/` | `setExternalAudioSink()`, `pullAudioFrame()` | Custom audio rendering pipeline |
+| CustomAudioSource | `Examples/Advanced/CustomAudioSource/` | `setExternalAudioSource()`, `pushAudioFrame()` | Custom audio source capture |
+| CustomVideoRender | `Examples/Advanced/CustomVideoRender/` | `setExternalVideoSink()`, `pullVideoFrame()` | Custom video rendering pipeline |
+| CustomVideoSourceMediaIO | `Examples/Advanced/CustomVideoSourceMediaIO/` | `setExternalVideoSource()`, `pushVideoFrame()` with MediaIO | Custom video source with media I/O |
+| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/` | `setExternalVideoSource()`, `pushVideoFrame()` | Custom video source push |
+| CustomVideoSourcePushMulti | `Examples/Advanced/CustomVideoSourcePushMulti/` | `setExternalVideoSource()`, `pushVideoFrame()` with multiple sources | Multiple custom video sources |
+| FaceCapture | `Examples/Advanced/FaceCapture/` | `enableFaceDetection()`, `getFaceDetectionResult()` | Face detection and capture |
+| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/` | `createRtcChannel()`, `joinChannel()` on multiple channels | Join and manage multiple channels simultaneously |
+| LiveStreaming | `Examples/Advanced/LiveStreaming/` | `setClientRole()`, `startRtmpStreamWithTranscoding()`, `stopRtmpStream()` | RTMP live streaming with transcoding |
+| LocalVideoTranscoding | `Examples/Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoding()`, `updateLocalTranscodingConfig()`, `stopLocalVideoTranscoding()` | Local video transcoding and composition |
+| MediaPlayer | `Examples/Advanced/MediaPlayer/` | `createMediaPlayer()`, `open()`, `play()`, `pause()`, `stop()` | Media file playback and control |
+| MultiCameraSourece | `Examples/Advanced/MultiCameraSourece/` | `enumerateDevices()`, `setDevice()` with multiple cameras | Multiple camera source selection |
+| Multipath | `Examples/Advanced/Multipath/` | `enableMultipath()`, `setMultipathConfig()` | Multipath redundancy for reliability |
+| PrecallTest | `Examples/Advanced/PrecallTest/` | `startEchoTest()`, `stopEchoTest()`, `startNetworkTest()`, `stopNetworkTest()` | Pre-call network and device testing |
+| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/` | `switchChannel()` | Quick channel switching without reconnection |
+| RawAudioData | `Examples/Advanced/RawAudioData/` | `setAudioFrameDelegate()`, `onMixedAudioFrame()` | Raw audio frame access and processing |
+| RawMediaData | `Examples/Advanced/RawMediaData/` | `setVideoFrameDelegate()`, `setAudioFrameDelegate()` | Raw audio and video frame access |
+| RawVideoData | `Examples/Advanced/RawVideoData/` | `setVideoFrameDelegate()`, `onCapturedVideoFrame()`, `onRemoteVideoFrame()` | Raw video frame capture and processing |
+| RtePlayer | `Examples/Advanced/RtePlayer/` | `createMediaPlayer()`, `open()` with RTE protocol | RTE protocol media playback |
+| RTMPStreaming | `Examples/Advanced/RTMPStreaming/` | `startRtmpStreamWithTranscoding()`, `updateRtmpTranscodingConfig()`, `stopRtmpStream()` | RTMP streaming with live transcoding |
+| ScreenShare | `Examples/Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCaptureParameters()`, `stopScreenCapture()` | Screen sharing and capture |
+| SimpleFilter | `Examples/Advanced/SimpleFilter/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Simple video filter effects |
+| Simulcast | `Examples/Advanced/Simulcast/` | `setSimulcastConfig()`, `enableSimulcast()` | Simulcast streaming with multiple bitrates |
+| SpatialAudio | `Examples/Advanced/SpatialAudio/` | `getLocalSpatialAudioEngine()`, `updateSelfPosition()`, `updateRemotePosition()` | 3D spatial audio positioning |
+| StreamEncryption | `Examples/Advanced/StreamEncryption/` | `enableEncryption()`, `setEncryptionConfig()` | Stream encryption and security |
+| VideoProcess | `Examples/Advanced/VideoProcess/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Video processing and enhancement |
+| VoiceChanger | `Examples/Advanced/VoiceChanger/` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()` | Voice effects and voice changer |
+
+## Engine Lifecycle
+
+```
+1. Create Engine
+ createAgoraRtcEngine()
+
+2. Initialize Engine
+ initialize(AgoraRtcEngineConfig)
+
+3. Enable Features (optional)
+ enableVideo(), enableAudio()
+
+4. Setup Local Media (optional)
+ setupLocalVideo(), startAudioMixing()
+
+5. Join Channel
+ joinChannel(token, channelName, uid)
+
+6. Handle Callbacks
+ onJoinChannelSuccess(), onUserJoined(), onUserOffline()
+
+7. Leave Channel
+ leaveChannel()
+
+8. Destroy Engine
+ destroy()
+```
+
+## Token Flow
+
+Token is obtained from `KeyCenter.swift` and passed to `joinChannel()`:
+
+```swift
+let token = KeyCenter.Token(channelName: channelName)
+agoraKit.joinChannel(byToken: token, channelName: channelName, info: nil, uid: 0)
+```
+
+For production, tokens should be generated server-side and refreshed before expiration.
diff --git a/macOS/CLAUDE.md b/macOS/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/macOS/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.
diff --git a/windows/.agent/skills/review-case/SKILL.md b/windows/.agent/skills/review-case/SKILL.md
new file mode 100644
index 000000000..ead1ccdfa
--- /dev/null
+++ b/windows/.agent/skills/review-case/SKILL.md
@@ -0,0 +1,461 @@
+---
+name: review-case
+description: >
+ Code review for API examples. Ensures examples follow project conventions,
+ handle lifecycle correctly, manage threads safely, and use APIs properly.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: Windows
+---
+
+# Review Case Skill — Windows
+
+## When to Use
+
+Use this skill when you need to:
+- Review a new or modified example for correctness
+- Ensure the example follows project conventions
+- Verify lifecycle management and thread safety
+- Check API usage and error handling
+
+## Review Dimensions (Priority Order)
+
+### 1. Engine Lifecycle (CRITICAL)
+
+**Check:**
+- [ ] Engine is created in `InitializeAgoraEngine()` or similar
+- [ ] Engine is initialized with `RtcEngineContext`
+- [ ] `leaveChannel()` is called before `release()`
+- [ ] `release()` is called in the case's real cleanup path
+- [ ] No engine leaks (engine not recreated on every join)
+
+**Correct Pattern A — standalone dialog teardown:**
+```cpp
+BOOL CExampleDlg::OnInitDialog() {
+ CDialogEx::OnInitDialog();
+ InitializeAgoraEngine(); // Create once
+ return TRUE;
+}
+
+void CExampleDlg::PostNcDestroy() {
+ LeaveChannel();
+ if (m_rtcEngine) {
+ m_rtcEngine->release();
+ m_rtcEngine = nullptr;
+ }
+ CDialogEx::PostNcDestroy();
+ delete this;
+}
+
+void CExampleDlg::JoinChannel() {
+ if (!m_rtcEngine) return;
+ m_rtcEngine->joinChannel(token, channelName, "", 0);
+}
+
+void CExampleDlg::LeaveChannel() {
+ if (!m_rtcEngine) return;
+ m_rtcEngine->leaveChannel();
+}
+```
+
+**Correct Pattern B — scene-switching dialog teardown:**
+```cpp
+bool CExampleDlg::InitAgora() {
+ m_rtcEngine = createAgoraRtcEngine();
+ // initialize once when the scene becomes active
+ return m_rtcEngine != nullptr;
+}
+
+void CExampleDlg::UnInitAgora() {
+ if (!m_rtcEngine) return;
+ if (m_joinChannel) {
+ m_rtcEngine->leaveChannel();
+ }
+ m_rtcEngine->release(nullptr);
+ m_rtcEngine = nullptr;
+}
+
+void CExampleDlg::OnShowWindow(BOOL bShow, UINT nStatus) {
+ CDialogEx::OnShowWindow(bShow, nStatus);
+ if (!bShow) {
+ UnInitAgora();
+ }
+}
+```
+
+Accept either pattern as long as the dialog follows one lifecycle consistently and does not leak the engine across scene switches.
+
+**Incorrect Pattern:**
+See `references/incorrect-lifecycle.cpp` for common mistakes.
+
+---
+
+### 2. Thread Safety (CRITICAL)
+
+**Check:**
+- [ ] All UI updates in event handler use message map pattern
+- [ ] Event handler posts messages to main thread via `PostMessage()`
+- [ ] No direct UI updates from background threads
+- [ ] Message handlers update UI on main thread
+
+**Correct Pattern:**
+```cpp
+// Event handler (may be called from background thread)
+void CExampleRtcEngineEventHandler::onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) {
+ if (m_hMsgHandler) {
+ // Post message to main thread
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), (WPARAM)uid, 0);
+ }
+}
+
+// Message handler (runs on main thread)
+LRESULT CExampleDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) {
+ // Safe to update UI here
+ m_statusText.SetWindowText(_T("Joined channel"));
+ return 0;
+}
+```
+
+**Incorrect Pattern:**
+See `references/incorrect-thread-safety.cpp` for common mistakes.
+
+---
+
+### 3. Permission Handling (HIGH)
+
+**Check:**
+- [ ] Microphone permission checked before `enableAudio()`
+- [ ] Camera permission checked before `enableVideo()`
+- [ ] Device availability verified
+
+**Correct Pattern:**
+```cpp
+void CExampleDlg::InitializeAgoraEngine() {
+ m_rtcEngine = createAgoraRtcEngine();
+ if (!m_rtcEngine) return;
+
+ RtcEngineContext context;
+ context.appId = CConfig::GetAppId();
+ context.eventHandler = &m_eventHandler;
+ m_eventHandler.SetMsgReceiver(m_hWnd);
+
+ m_rtcEngine->initialize(context);
+
+ // Check device availability
+ if (m_rtcEngine->enableVideo() == 0) {
+ // Video enabled successfully
+ }
+ if (m_rtcEngine->enableAudio() == 0) {
+ // Audio enabled successfully
+ }
+}
+```
+
+---
+
+### 4. Error Handling (HIGH)
+
+**Check:**
+- [ ] `joinChannel()` return value checked
+- [ ] Token expiration is handled
+- [ ] Network errors are logged or displayed
+- [ ] Invalid parameters are validated
+- [ ] `onError()` callback implemented
+
+**Correct Pattern:**
+```cpp
+void CExampleDlg::JoinChannel() {
+ if (!m_rtcEngine) return;
+
+ const char* token = CConfig::GetToken("test");
+ int ret = m_rtcEngine->joinChannel(token, "test", "", 0);
+ if (ret != 0) {
+ // Handle error
+ MessageBox(_T("Failed to join channel"), _T("Error"));
+ }
+}
+
+void CExampleRtcEngineEventHandler::onError(int err) {
+ if (m_hMsgHandler) {
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_ERROR), (WPARAM)err, 0);
+ }
+}
+
+LRESULT CExampleDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) {
+ if (wParam == EID_ERROR) {
+ int errorCode = (int)lParam;
+ // Handle error
+ }
+ return 0;
+}
+```
+
+---
+
+### 5. Code Convention (MEDIUM)
+
+**Check:**
+- [ ] Dialog class name follows pattern: `CDlg`
+- [ ] Event handler class name: `CRtcEngineEventHandler`
+- [ ] File names match class names (PascalCase with C prefix)
+- [ ] Member variables use `m_` prefix
+- [ ] Message map properly defined
+- [ ] Comments explain non-obvious logic
+
+**Correct Pattern:**
+```cpp
+// Header: CScreenShareDlg.h
+class CScreenShareRtcEngineEventHandler : public IRtcEngineEventHandler {
+ // ...
+};
+
+class CScreenShareDlg : public CDialogEx {
+ DECLARE_DYNAMIC(CScreenShareDlg)
+
+private:
+ IRtcEngine* m_rtcEngine = nullptr;
+ CScreenShareRtcEngineEventHandler m_eventHandler;
+ uid_t m_remoteUid = 0;
+ bool m_isJoined = false;
+
+ BEGIN_MESSAGE_MAP(CScreenShareDlg, CDialogEx)
+ ON_BN_CLICKED(IDC_BUTTON_JOIN, &CScreenShareDlg::OnBnClickedButtonJoin)
+ END_MESSAGE_MAP()
+};
+```
+
+---
+
+### 6. API Usage Correctness (MEDIUM)
+
+**Check:**
+- [ ] SDK methods called in correct order
+- [ ] Required parameters are provided
+- [ ] Optional parameters are used correctly
+- [ ] Return values are checked where necessary
+- [ ] Deprecated APIs are not used
+
+**Correct Pattern:**
+```cpp
+// Correct order: create -> initialize -> enable -> join
+m_rtcEngine = createAgoraRtcEngine();
+m_rtcEngine->initialize(context);
+m_rtcEngine->enableVideo();
+m_rtcEngine->enableAudio();
+m_rtcEngine->joinChannel(token, channelName, "", 0);
+```
+
+**Incorrect Pattern:**
+```cpp
+// ❌ Wrong order
+m_rtcEngine->joinChannel(...); // Join first
+m_rtcEngine->enableVideo(); // Enable after join (too late)
+```
+
+---
+
+### 7. Resource Cleanup (MEDIUM)
+
+**Check:**
+- [ ] Audio files are stopped and released
+- [ ] Video captures are stopped
+- [ ] Custom audio/video sources are cleaned up
+- [ ] Observers are unregistered
+- [ ] Timers are killed
+
+**Correct Pattern:**
+```cpp
+void CExampleDlg::LeaveChannel() {
+ if (!m_rtcEngine) return;
+
+ m_rtcEngine->stopAudioMixing(); // Stop audio
+ m_rtcEngine->stopScreenCapture(); // Stop screen share
+ m_rtcEngine->leaveChannel();
+ m_isJoined = false;
+}
+
+void CExampleDlg::PostNcDestroy() {
+ LeaveChannel();
+ if (m_rtcEngine) {
+ m_rtcEngine->release();
+ m_rtcEngine = nullptr;
+ }
+ CDialogEx::PostNcDestroy();
+ delete this;
+}
+```
+
+---
+
+## Review Output Format
+
+When reviewing, provide feedback in this format:
+
+```
+## Review Results
+
+### ✅ Passed
+- Engine lifecycle correctly managed
+- Thread safety ensured with message map pattern
+- Error handling implemented for joinChannel
+
+### ⚠️ Issues Found
+
+**[HIGH] Thread Safety Issue**
+- File: `CScreenShareDlg.cpp`
+- Line: 45
+- Issue: Direct UI update in event handler without PostMessage
+- Suggestion: Use PostMessage to post event to main thread
+
+**[MEDIUM] Missing Error Handling**
+- File: `CScreenShareDlg.cpp`
+- Line: 78
+- Issue: joinChannel() return value not checked
+- Suggestion: Check return value and handle errors
+
+### 🔧 Recommendations
+- Add logging for debugging
+- Consider adding retry logic for network failures
+```
+
+---
+
+## Platform-Specific Checks
+
+### Windows-Specific
+
+**Check:**
+- [ ] Using MFC — not WinForms or WPF
+- [ ] Using C++ — not C#
+- [ ] Following MFC naming conventions (C prefix, m_ prefix)
+- [ ] Message map properly defined
+- [ ] Dialog resource properly configured
+- [ ] No modern C++ patterns unless already in codebase
+
+**Correct Pattern:**
+```cpp
+// Windows: Use MFC
+#include "stdafx.h"
+#include "APIExample.h"
+
+class CExampleDlg : public CDialogEx {
+ DECLARE_DYNAMIC(CExampleDlg)
+
+ BEGIN_MESSAGE_MAP(CExampleDlg, CDialogEx)
+ ON_BN_CLICKED(IDC_BUTTON_JOIN, &CExampleDlg::OnBnClickedButtonJoin)
+ END_MESSAGE_MAP()
+};
+```
+
+**Incorrect Pattern:**
+```cpp
+// ❌ Non-MFC patterns
+using namespace std; // Avoid in MFC
+auto ptr = std::make_unique(); // Modern C++ not typical in MFC
+```
+
+---
+
+## NEVER List
+
+**Do NOT accept:**
+- Engine not released (memory leak)
+- Direct UI updates from event handler without PostMessage
+- Multiple engine instances in one example
+- Hardcoded App ID or token (must use CConfig)
+- Missing `leaveChannel()` before `release()`
+- C# or other languages (C++ only)
+- WinForms or WPF (MFC only)
+- Examples outside `APIExample/APIExample/[Basic|Advanced]/` structure
+- Missing event handler implementation
+- No error handling for joinChannel failures
+- Deviation from MFC naming conventions
+
+---
+
+## Review Checklist
+
+Use this checklist when reviewing an example:
+
+**Lifecycle:**
+- [ ] Engine created once in initialization
+- [ ] `leaveChannel()` called before `release()`
+- [ ] `release()` called in `PostNcDestroy()`
+- [ ] No engine leaks
+
+**Thread Safety:**
+- [ ] All UI updates use message map pattern
+- [ ] Event handler posts messages via `PostMessage()`
+- [ ] No direct UI updates from callbacks
+- [ ] Message handlers run on main thread
+
+**Permissions:**
+- [ ] Microphone availability checked
+- [ ] Camera availability checked
+- [ ] Device errors handled
+
+**Error Handling:**
+- [ ] joinChannel return value checked
+- [ ] Token expiration handled
+- [ ] Network errors logged
+- [ ] onError() callback implemented
+
+**Code Quality:**
+- [ ] Follows MFC naming conventions
+- [ ] Message map properly defined
+- [ ] Comments explain non-obvious logic
+- [ ] No hardcoded credentials
+
+**API Usage:**
+- [ ] Methods called in correct order
+- [ ] Required parameters provided
+- [ ] Return values checked
+- [ ] No deprecated APIs
+
+**Resources:**
+- [ ] Audio/video properly stopped
+- [ ] Observers unregistered
+- [ ] Timers killed
+- [ ] No resource leaks
+
+**Platform:**
+- [ ] Using MFC (not WinForms/WPF)
+- [ ] Using C++ (not C#)
+- [ ] Following MFC conventions
+- [ ] No modern C++ patterns unless existing
+
+---
+
+## Common Issues and Fixes
+
+### Issue: "Engine not initialized"
+**Cause:** `release()` called without `leaveChannel()` first
+**Fix:** Always call `leaveChannel()` before `release()`
+
+### Issue: "UI crashes or doesn't update"
+**Cause:** Direct UI update from event handler
+**Fix:** Use PostMessage to post event to main thread
+
+### Issue: "Memory leak detected"
+**Cause:** `release()` not called or engine recreated
+**Fix:** Ensure `release()` in `PostNcDestroy()` and create engine once
+
+### Issue: "Token expired error"
+**Cause:** No token refresh handling
+**Fix:** Implement token refresh in error handler
+
+### Issue: "No audio/video"
+**Cause:** Device not available or not enabled
+**Fix:** Check return values of `enableAudio()` / `enableVideo()`
+
+---
+
+## References
+
+- **Agora RTC SDK for Windows:** [Documentation](https://docs.agora.io/en/video-calling/reference/windows-sdk)
+- **Existing examples:** Review `APIExample/APIExample/Basic/JoinChannelVideoByToken/` for reference
+- **MFC Documentation:** [Microsoft Foundation Classes](https://docs.microsoft.com/en-us/cpp/mfc/mfc-desktop-applications)
+- **Message Map:** [MFC Message Maps](https://docs.microsoft.com/en-us/cpp/mfc/message-maps)
diff --git a/windows/.agent/skills/upsert-case/SKILL.md b/windows/.agent/skills/upsert-case/SKILL.md
new file mode 100644
index 000000000..f809c4b4a
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/SKILL.md
@@ -0,0 +1,213 @@
+---
+name: upsert-case
+description: >
+ Add a new API example or modify an existing one. Covers both creation and modification scenarios,
+ including dialog class structure, registration in APIExampleDlg, localization wiring, and
+ ARCHITECTURE.md updates.
+compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot]
+license: MIT
+metadata:
+ author: APIExample Team
+ version: 1.0.0
+ platform: Windows
+---
+
+# Upsert Case Skill — Windows
+
+## When to Use
+
+Use this skill when you need to:
+- Create a new API example (case)
+- Modify an existing example
+- Ensure the example is properly registered and documented
+
+## Applicable Scenarios
+
+### Scenario 1: Create a New Example
+
+**Trigger:** User requests a new API demo (e.g., "Add a screen sharing example")
+
+**Steps:**
+1. Determine if the example belongs in `Basic/` or `Advanced/`
+2. Create the example folder with PascalCase name
+3. Create `.h` and `.cpp` files for the dialog class
+4. Register the example in `APIExampleDlg.h` and `APIExampleDlg.cpp`
+5. Add scene label wiring in `Language.h`, `stdafx.cpp`, and language `.ini` files
+6. Update `ARCHITECTURE.md` Case Index
+7. Verify compilation and functionality
+
+### Scenario 2: Modify an Existing Example
+
+**Trigger:** User requests changes to an existing example (e.g., "Update JoinChannelVideo to support token")
+
+**Steps:**
+1. Locate the example in `APIExample/APIExample/[Basic|Advanced]//`
+2. Modify the `.h` and `.cpp` files
+3. Update `APIExampleDlg.cpp` if routing or lifecycle hooks changed
+4. Update `ARCHITECTURE.md` Case Index if APIs changed
+5. Verify compilation and functionality
+
+---
+
+## Files to Modify
+
+### New Example
+
+| File | Action | Notes |
+|------|--------|-------|
+| `APIExample/APIExample/[Basic\|Advanced]//CDlg.h` | Create | Dialog class header |
+| `APIExample/APIExample/[Basic\|Advanced]//CDlg.cpp` | Create | Dialog class implementation |
+| `APIExample/APIExample/APIExampleDlg.h` | Modify | Add include and dialog member pointer |
+| `APIExample/APIExample/APIExampleDlg.cpp` | Modify | Register, create, show, and release the dialog |
+| `APIExample/APIExample/Language.h` | Modify | Declare the localized scene label |
+| `APIExample/APIExample/stdafx.cpp` | Modify | Initialize the localized scene label in `InitKeyInfomation()` |
+| `APIExample/APIExample/en.ini` | Modify | Add English display text |
+| `APIExample/APIExample/zh-cn.ini` | Modify | Add Chinese display text |
+| `APIExample/APIExample/APIExample.vcxproj` | Modify if needed | Add new source/header files when not using the Visual Studio UI |
+| `APIExample/APIExample/APIExample.vcxproj.filters` | Modify if needed | Keep Solution Explorer grouping correct |
+| `ARCHITECTURE.md` | Modify | Add entry to Case Index |
+
+### Modify Existing Example
+
+| File | Action | Notes |
+|------|--------|-------|
+| `APIExample/APIExample/[Basic\|Advanced]//CDlg.h` | Modify | Update dialog class |
+| `APIExample/APIExample/[Basic\|Advanced]//CDlg.cpp` | Modify | Update implementation |
+| `APIExample/APIExample/APIExampleDlg.cpp` | Modify if routing changes | Update show/hide or scene selection behavior if needed |
+| `ARCHITECTURE.md` | Modify | Update Case Index if APIs changed |
+
+---
+
+## Step-by-Step Guide
+
+### Step 1: Determine Example Category
+
+- **Basic:** Simple, single-feature examples (JoinChannelVideo, LiveBroadcasting)
+- **Advanced:** Complex features, multi-API examples (ScreenShare, CustomVideoCapture)
+
+### Step 2: Create Example Folder
+
+```bash
+mkdir APIExample\APIExample\[Basic|Advanced]\
+```
+
+Example folder name must be PascalCase.
+
+### Step 3: Create Dialog Header File
+
+Create `APIExample/APIExample/[Basic|Advanced]//CDlg.h`
+
+Use the template from `references/example-template.h` as a starting point. Replace `` with your example name.
+
+### Step 4: Create Dialog Implementation File
+
+Create `APIExample/APIExample/[Basic|Advanced]//CDlg.cpp`
+
+Use the template from `references/example-template.cpp` as a starting point. Replace `` with your example name.
+
+### Step 5: Register in APIExampleDlg
+
+Do not edit `CSceneDialog.cpp` for case registration. In this project, scene ownership lives in the main dialog:
+
+- `APIExample/APIExample/APIExampleDlg.h`
+ Add the example header include and a member pointer such as `CDlg* m_pDlg = nullptr;`
+- `APIExample/APIExample/APIExampleDlg.cpp`
+ Mirror an existing example across:
+ - `InitSceneDialog()` to push the localized label into `m_vecBasic` or `m_vecAdvanced`, create the dialog, and position it
+ - `CreateScene()` to call the dialog's init/show path when the tree item is selected
+ - `ReleaseScene()` to call the dialog's cleanup/hide path when the tree item is left
+
+`InitSceneList()` reads from `m_vecBasic` and `m_vecAdvanced`, so the tree updates automatically once the vectors are populated in `InitSceneDialog()`.
+
+### Step 6: Add localized scene labels
+
+Register the example name used by the tree view:
+
+- Add an `extern wchar_t ...[INFO_LEN];` declaration to `APIExample/APIExample/Language.h`
+- Initialize it in `APIExample/APIExample/stdafx.cpp` inside `InitKeyInfomation()`
+- Add matching keys to `APIExample/APIExample/en.ini` and `APIExample/APIExample/zh-cn.ini`
+
+Follow the existing naming pattern such as `Basic.JoinChannelVideoByToken` or `Advanced.ScreenCap`.
+
+### Step 7: Update ARCHITECTURE.md
+
+Add a new row to the Case Index table in `ARCHITECTURE.md`:
+
+```markdown
+| ExampleName | `[Basic|Advanced]/ExampleName/` | `api1()`, `api2()`, `api3()` | Brief description of what this example demonstrates |
+```
+
+**Key APIs column:** List 2-5 core SDK methods used in this example.
+
+### Step 8: Verify
+
+- [ ] Code compiles without errors
+- [ ] Example appears in the scene list
+- [ ] Example can join channel and receive callbacks
+- [ ] `leaveChannel()` and `release()` are called on close
+- [ ] UI updates happen on main thread (via message map)
+- [ ] Localized labels resolve correctly in both `en.ini` and `zh-cn.ini`
+- [ ] ARCHITECTURE.md Case Index is updated
+
+---
+
+## Code Patterns
+
+See `references/` directory for code patterns:
+- `lifecycle-pattern.cpp` — Proper engine lifecycle
+- `message-map-pattern.cpp` — Message map pattern for thread-safe UI updates
+- `event-handler-pattern.cpp` — Event handler pattern
+
+---
+
+## NEVER List
+
+**Do NOT:**
+- Forget to call `release()` — this causes engine leaks
+- Update UI directly from event handler callbacks — use message map pattern
+- Create multiple engine instances in one example — use a single shared instance
+- Use C# or other languages — C++ only
+- Use WinForms or WPF — MFC only
+- Deviate from MFC naming conventions (`C` prefix, `m_` prefix)
+- Hardcode App ID or token — use `CConfig`
+- Forget to implement `IRtcEngineEventHandler` for event handling
+- Leave the channel without calling `leaveChannel()` first
+- Register a new case in `CSceneDialog.cpp` — registration lives in `APIExampleDlg.h` and `APIExampleDlg.cpp`
+- Modify examples outside the `APIExample/APIExample/[Basic|Advanced]/` structure
+- Forget to update `ARCHITECTURE.md` Case Index after adding/modifying an example
+- Use modern C++ patterns (lambdas, smart pointers) unless already in the file
+
+---
+
+## Verification Checklist
+
+After completing the upsert, verify:
+
+- [ ] Example folder is in correct location (`APIExample/APIExample/[Basic|Advanced]//`)
+- [ ] Header file is named `CDlg.h` (with C prefix)
+- [ ] Implementation file is named `CDlg.cpp`
+- [ ] Dialog class inherits from `CDialogEx` or `CDialog`
+- [ ] Event handler implements `IRtcEngineEventHandler`
+- [ ] Message map properly defined with `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP`
+- [ ] Example is registered in `APIExampleDlg.h` and `APIExampleDlg.cpp`
+- [ ] Scene label is declared in `Language.h` and initialized in `stdafx.cpp`
+- [ ] Scene label has entries in both `en.ini` and `zh-cn.ini`
+- [ ] `InitializeAgoraEngine()` creates engine with correct config
+- [ ] `JoinChannel()` uses token from `CConfig`
+- [ ] `LeaveChannel()` and `release()` are called in `PostNcDestroy()`
+- [ ] All engine events posted to main thread via `PostMessage()`
+- [ ] `APIExample.vcxproj` and `.filters` include the new files when they were added outside the IDE
+- [ ] `ARCHITECTURE.md` Case Index includes new/updated example
+- [ ] Code compiles without warnings or errors
+- [ ] Example appears in the scene list
+- [ ] Example can successfully join channel and receive callbacks
+- [ ] Example properly cleans up resources on close
+
+---
+
+## References
+
+- **Template files:** See `references/` directory for C++ code templates
+- **Existing examples:** Review `APIExample/APIExample/Basic/JoinChannelVideoByToken/` for reference implementation
+- **SDK documentation:** Refer to Agora RTC SDK for Windows documentation for API details
+- **MFC Documentation:** [Microsoft Foundation Classes](https://docs.microsoft.com/en-us/cpp/mfc/mfc-desktop-applications)
diff --git a/windows/AGENTS.md b/windows/AGENTS.md
new file mode 100644
index 000000000..5e3601839
--- /dev/null
+++ b/windows/AGENTS.md
@@ -0,0 +1,70 @@
+# AGENTS.md — Windows
+
+## Project Context
+
+This is the C++ + MFC implementation of Agora RTC SDK examples for Windows. Before making any changes, read `ARCHITECTURE.md` to understand the structural rules.
+
+## Build Commands
+
+```bash
+# Build using Visual Studio (from command line)
+cd windows/APIExample
+msbuild APIExample.sln /p:Configuration=Release /p:Platform=x64
+
+# Or open in Visual Studio and build manually
+start APIExample.sln
+```
+
+## App ID Configuration
+
+Configure your Agora App ID in `APIExample/APIExample/CConfig.h` and `CConfig.cpp`:
+
+```cpp
+// CConfig.h
+class CConfig {
+public:
+ static const char* GetAppId() { return "<#YOUR_APP_ID#>"; }
+ static const char* GetToken(const char* channelName) { return "<#YOUR_TOKEN#>"; }
+};
+```
+
+## Architecture Red Lines
+
+**Do NOT:**
+- Introduce C# or other languages — use C++ only
+- Use WinForms or WPF — use MFC only
+- Deviate from MFC naming conventions (`C` prefix for classes, `m_` prefix for members)
+- Use modern C++ patterns (lambdas, smart pointers) unless already present in the file being modified
+- Forget to call `leaveChannel()` and `release()` when closing an example
+- Update UI from background threads — always post messages to the main thread
+- Share engine instances between examples — each example manages its own lifecycle
+- Forget to implement `IAgoraRtcEngineEventHandler` for event handling
+
+## Rules
+
+### Follow the Architecture
+
+All work must conform to the rules defined in `ARCHITECTURE.md`:
+- Every example is a dialog class inheriting from `CDialogEx` or `CDialog`
+- Each example implements `IAgoraRtcEngineEventHandler` interface
+- Each example manages its own Agora engine lifecycle
+- Message handlers are defined via `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP`
+- All examples are registered in `APIExampleDlg.h` and `APIExampleDlg.cpp`
+- Configuration is managed centrally via `CConfig` class
+
+### Follow the Existing Language and Framework
+
+- Language is C++ — do not introduce C# or other languages
+- UI framework is MFC — do not introduce WinForms or WPF
+- Use MFC conventions: `C` prefix for classes, `m_` prefix for member variables
+- Use message map pattern for event handling — do not introduce modern C++ patterns unless they already exist in the file being modified
+- Match the code style, naming, and patterns of existing examples
+
+### Use Project-Level SKILLs
+
+For broader tasks, use the skills in `.agent/skills/`:
+
+| Task | Skill | When to use |
+|------|-------|-------------|
+| Add or modify an example | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one |
+| Code review | `.agent/skills/review-case/` | Review example code for lifecycle, thread safety, and convention compliance |
diff --git a/windows/ARCHITECTURE.md b/windows/ARCHITECTURE.md
new file mode 100644
index 000000000..e92efe599
--- /dev/null
+++ b/windows/ARCHITECTURE.md
@@ -0,0 +1,172 @@
+# Windows ARCHITECTURE
+
+Windows example project using C++ + MFC (Microsoft Foundation Classes). Demonstrates Agora RTC SDK features through a collection of self-contained dialog-based examples organized by complexity.
+
+## Technology Stack
+
+- Language: C++
+- UI Framework: MFC (Microsoft Foundation Classes)
+- Architecture: Dialog-based application with example selection
+- State: Member variables + message map callbacks
+
+## Directory Structure
+
+```
+windows/
+├── APIExample/
+│ ├── APIExample/
+│ │ ├── Basic/
+│ │ │ └── /
+│ │ │ ├── CDlg.cpp
+│ │ │ ├── CDlg.h
+│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming)
+│ │ ├── Advanced/
+│ │ │ └── /
+│ │ │ ├── CDlg.cpp
+│ │ │ ├── CDlg.h
+│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming)
+│ │ ├── DirectShow/ # DirectShow utilities
+│ │ ├── dsound/ # DirectSound utilities
+│ │ ├── res/ # Resources (icons, dialogs, strings)
+│ │ ├── APIExample.cpp
+│ │ ├── APIExample.h
+│ │ ├── APIExampleDlg.cpp # Main dialog and example registration
+│ │ ├── APIExampleDlg.h
+│ │ ├── CConfig.cpp # Configuration management
+│ │ ├── CConfig.h
+│ │ ├── CSceneDialog.cpp # Shared dialog helper, not the case registration source
+│ │ └── CSceneDialog.h
+│ ├── APIExample.sln # Visual Studio solution
+│ ├── cicd/ # CI/CD scripts
+│ └── .vscode/ # VS Code configuration
+├── .agent/skills/ # Agent skills
+│ ├── create-api-example/
+│ ├── find-api-example/
+│ └── migrate-api-to-project/
+├── AGENTS.md # Agent guide
+└── ARCHITECTURE.md # This file
+```
+
+## Architectural Rules
+
+### Example Structure
+
+Each example lives in its own folder under `APIExample/APIExample/Basic/` or `APIExample/APIExample/Advanced/` and consists of:
+- A `.h` + `.cpp` pair for the dialog class
+- Optional: Resource definitions in `.rc` file
+
+### Dialog-Based Pattern
+
+Each example is a dialog class that:
+- Inherits from `CDialogEx` or `CDialog`
+- Implements message handlers via `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP`
+- Manages its own Agora engine lifecycle
+- Implements `IAgoraRtcEngineEventHandler` interface
+- Owns all UI controls and state for that example
+
+### Naming Convention
+
+- Example folder names: PascalCase (e.g., `JoinChannelVideo`)
+- Dialog class: `CDlg` (e.g., `CJoinChannelVideoDlg`)
+- Header file: `CDlg.h`
+- Implementation file: `CDlg.cpp`
+
+### Menu Registration
+
+All examples are registered in `APIExampleDlg.h` and `APIExampleDlg.cpp`. The localized scene name is wired through `Language.h`, `stdafx.cpp`, `en.ini`, and `zh-cn.ini`, and the example name should still match the folder name.
+
+### Configuration Management
+
+Configuration is centralized in `CConfig` class:
+- App ID management
+- Token generation
+- Global settings (resolution, frame rate, etc.)
+
+### Common Utilities
+
+All examples share utilities:
+- `CConfig` — App ID, token, and global settings
+- `VideoExtractor` — Video frame extraction
+- `YUVReader` — YUV file reading
+- DirectShow and DirectSound wrappers
+
+## Case Index
+
+| Case | Path | Key APIs | Description |
+|------|------|----------|-------------|
+| JoinChannelVideoByToken | `Basic/JoinChannelVideoByToken/` | `createAgoraRtcEngine()`, `joinChannel()` with token, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call with token authentication |
+| LiveBroadcasting | `Basic/LiveBroadcasting/` | `setClientRole()`, `joinChannel()`, `startRtmpStreamWithTranscoding()` | Live broadcasting with RTMP streaming |
+| AudioEffect | `Advanced/AudioEffect/` | `setAudioEffectPreset()`, `setVoiceBeautifierPreset()` | Audio effects and voice beautification |
+| AudioMixing | `Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()` | Audio file mixing and playback control |
+| AudioProfile | `Advanced/AudioProfile/` | `setAudioProfile()`, `setAudioScenario()` | Audio profile and scenario configuration |
+| AudioVolume | `Advanced/AudioVolume/` | `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()`, `adjustUserPlaybackSignalVolume()` | Audio volume adjustment and control |
+| Beauty | `Advanced/Beauty/` | `setBeautyEffectOptions()`, `setVideoEncoderConfiguration()` | Beauty filter and enhancement effects |
+| Beauty2.0 | `Advanced/Beauty2.0/` | `setBeautyEffectOptions()` with v2.0 API | Enhanced beauty effects with v2.0 API |
+| BeautyAudio | `Advanced/BeautyAudio/` | `setBeautyEffectOptions()`, `setAudioEffectPreset()` | Combined audio and video beauty effects |
+| CrossChannel | `Advanced/CrossChannel/` | `startChannelMediaRelay()`, `updateChannelMediaRelay()`, `stopChannelMediaRelay()` | Media relay across multiple channels |
+| CustomAudioCapture | `Advanced/CustomAudioCapture/` | `setExternalAudioSource()`, `pushAudioFrame()` | Custom audio source capture |
+| CustomEncrypt | `Advanced/CustomEncrypt/` | `setEncryptionConfig()`, `enableEncryption()` | Custom stream encryption |
+| CustomVideoCapture | `Advanced/CustomVideoCapture/` | `setExternalVideoSource()`, `pushVideoFrame()` | Custom video source capture |
+| FaceCapture | `Advanced/FaceCapture/` | `enableFaceDetection()`, `getFaceDetectionResult()` | Face detection and capture |
+| LocalVideoTranscoding | `Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoding()`, `updateLocalTranscodingConfig()`, `stopLocalVideoTranscoding()` | Local video transcoding and composition |
+| MediaEncrypt | `Advanced/MediaEncrypt/` | `setEncryptionConfig()`, `enableEncryption()` | Media stream encryption |
+| MediaPlayer | `Advanced/MediaPlayer/` | `createMediaPlayer()`, `open()`, `play()`, `pause()`, `stop()` | Media file playback and control |
+| MediaRecorder | `Advanced/MediaRecorder/` | `startRecording()`, `stopRecording()`, `setRecordingAudioFrameParameters()` | Media recording with custom parameters |
+| Metadata | `Advanced/Metadata/` | `registerMediaMetadataObserver()`, `onMetadataReceived()` | Metadata transmission and reception |
+| MultiCamera | `Advanced/MultiCamera/` | `enumerateDevices()`, `setDevice()` with multiple cameras | Multiple camera source selection |
+| MultiChannel | `Advanced/MultiChannel/` | `createRtcChannel()`, `joinChannel()` on multiple channels | Join and manage multiple channels simultaneously |
+| Multipath | `Advanced/Multipath/` | `enableMultipath()`, `setMultipathConfig()` | Multipath redundancy for reliability |
+| MultiVideoSource | `Advanced/MultiVideoSource/` | `setExternalVideoSource()`, `pushVideoFrame()` with multiple sources | Multiple video sources |
+| MultiVideoSourceTracks | `Advanced/MultiVideoSourceTracks/` | `createCustomVideoTrack()`, `pushVideoFrame()` on custom tracks | Multiple video tracks with custom sources |
+| OriginalAudio | `Advanced/OriginalAudio/` | `setAudioFrameDelegate()`, `onPlaybackAudioFrame()` | Raw audio frame access |
+| OriginalVideo | `Advanced/OriginalVideo/` | `setVideoFrameDelegate()`, `onCapturedVideoFrame()`, `onRemoteVideoFrame()` | Raw video frame access |
+| PreCallTest | `Advanced/PreCallTest/` | `startEchoTest()`, `stopEchoTest()`, `startNetworkTest()`, `stopNetworkTest()` | Pre-call network and device testing |
+| PushExternalVideoYUV | `Advanced/PushExternalVideoYUV/` | `setExternalVideoSource()`, `pushVideoFrame()` with YUV format | Push external YUV video frames |
+| RegionConn | `Advanced/RegionConn/` | `setCloudProxy()`, `setRegion()` | Region connection and cloud proxy |
+| ReportInCall | `Advanced/ReportInCall/` | `startRtcStats()`, `getRtcStats()` | In-call statistics and reporting |
+| RtePlayer | `Advanced/RtePlayer/` | `createMediaPlayer()`, `open()` with RTE protocol | RTE protocol media playback |
+| RTMPinject | `Advanced/RTMPinject/` | `addInjectStreamUrl()`, `removeInjectStreamUrl()` | RTMP stream injection |
+| RTMPStream | `Advanced/RTMPStream/` | `startRtmpStreamWithTranscoding()`, `updateRtmpTranscodingConfig()`, `stopRtmpStream()` | RTMP streaming with live transcoding |
+| ScreenShare | `Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCaptureParameters()`, `stopScreenCapture()` | Screen sharing and capture |
+| Simulcast | `Advanced/Simulcast/` | `setSimulcastConfig()`, `enableSimulcast()` | Simulcast streaming with multiple bitrates |
+| SpatialAudio | `Advanced/SpatialAudio/` | `getLocalSpatialAudioEngine()`, `updateSelfPosition()`, `updateRemotePosition()` | 3D spatial audio positioning |
+| TransparentBg | `Advanced/TransparentBg/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Transparent background effects |
+
+## Engine Lifecycle
+
+```
+1. Create Engine
+ createAgoraRtcEngine()
+
+2. Initialize Engine
+ initialize(RtcEngineContext)
+
+3. Enable Features (optional)
+ enableVideo(), enableAudio()
+
+4. Setup Local Media (optional)
+ setupLocalVideo(), startAudioMixing()
+
+5. Join Channel
+ joinChannel(token, channelName, uid)
+
+6. Handle Callbacks
+ onJoinChannelSuccess(), onUserJoined(), onUserOffline()
+
+7. Leave Channel
+ leaveChannel()
+
+8. Release Engine
+ release()
+```
+
+## Token Flow
+
+Token is obtained from `CConfig` and passed to `joinChannel()`:
+
+```cpp
+const char* token = CConfig::GetToken(channelName);
+m_rtcEngine->joinChannel(token, channelName, "", 0);
+```
+
+For production, tokens should be generated server-side and refreshed before expiration.
diff --git a/windows/CLAUDE.md b/windows/CLAUDE.md
new file mode 100644
index 000000000..2d1c323ad
--- /dev/null
+++ b/windows/CLAUDE.md
@@ -0,0 +1,5 @@
+# CLAUDE.md
+
+This project uses `AGENTS.md` instead of a `CLAUDE.md` file.
+
+Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project.