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/.agent/skills/upsert-case/references/fragment-template.java b/Android/APIExample-Audio/.agent/skills/upsert-case/references/fragment-template.java
new file mode 100644
index 000000000..b4e9214c7
--- /dev/null
+++ b/Android/APIExample-Audio/.agent/skills/upsert-case/references/fragment-template.java
@@ -0,0 +1,209 @@
+package io.agora.api.example.examples.advanced;
+
+import static io.agora.api.example.common.model.Examples.ADVANCED;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Random;
+
+import io.agora.api.example.MainApplication;
+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.common.widget.AudioSeatManager;
+import io.agora.api.example.utils.PermissonUtils;
+import io.agora.api.example.utils.TokenUtils;
+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;
+
+// NOTE: This project uses voice-sdk.
+// Do NOT import or call: enableVideo(), setupLocalVideo(), VideoCanvas, VideoReportLayout,
+// setVideoEncoderConfiguration(), or any other video API — the module does not exist.
+
+/**
+ * This demo demonstrates how to use [describe the audio feature here].
+ *
+ * Key APIs used:
+ * - RtcEngine.yourAudioApi()
+ */
+@Example(
+ index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+
+ group = ADVANCED, // BASIC or ADVANCED
+ name = R.string.item_your_case_name,
+ actionId = R.id.action_mainFragment_to_yourCaseName,
+ tipsId = R.string.your_case_name_tips
+)
+public class YourCaseName extends BaseFragment implements View.OnClickListener {
+ private static final String TAG = YourCaseName.class.getSimpleName();
+
+ private Button join;
+ private EditText et_channel;
+ private RtcEngine engine;
+ private int myUid;
+ private boolean joined = false;
+
+ // AudioSeatManager visualizes remote audio participants — add seat views in your XML layout
+ private AudioSeatManager audioSeatManager;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_your_case_name, container, false);
+ }
+
+ @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);
+ join.setOnClickListener(this);
+ // bind additional feature-specific views here
+
+ // Wire AudioSeatManager to seat views defined in your XML layout
+ // audioSeatManager = new AudioSeatManager(
+ // view.findViewById(R.id.audio_place_01),
+ // view.findViewById(R.id.audio_place_02)
+ // );
+ }
+
+ @Override
+ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ Context context = getContext();
+ if (context == null) return;
+ try {
+ RtcEngineConfig config = new RtcEngineConfig();
+ config.mContext = context.getApplicationContext();
+ config.mAppId = getAgoraAppId();
+ config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
+ config.mEventHandler = iRtcEngineEventHandler;
+ config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT);
+ config.mAreaCode = ((MainApplication) getActivity().getApplication())
+ .getGlobalSettings().getAreaCode();
+ engine = RtcEngine.create(config);
+ // REQUIRED in every case — do not remove
+ engine.setParameters("{"
+ + "\"rtc.report_app_scenario\":"
+ + "{"
+ + "\"appScenario\":" + 100 + ","
+ + "\"serviceType\":" + 11 + ","
+ + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
+ + "}"
+ + "}");
+ // null-check is mandatory — returns null on non-private-cloud builds
+ LocalAccessPointConfiguration localAccessPointConfiguration =
+ ((MainApplication) getActivity().getApplication())
+ .getGlobalSettings().getPrivateCloudConfig();
+ if (localAccessPointConfiguration != null) {
+ engine.setLocalAccessPoint(localAccessPointConfiguration);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ getActivity().onBackPressed();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (engine != null) {
+ engine.leaveChannel();
+ }
+ // MUST use handler.post — do NOT call RtcEngine.destroy() directly on main thread
+ handler.post(RtcEngine::destroy);
+ engine = null;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v.getId() == R.id.btn_join) {
+ if (!joined) {
+ String channelId = et_channel.getText().toString();
+ checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() {
+ @Override
+ public void onPermissionsResult(boolean allPermissionsGranted,
+ String[] permissions, int[] grantResults) {
+ if (allPermissionsGranted) {
+ joinChannel(channelId);
+ }
+ }
+ });
+ } else {
+ joined = false;
+ engine.leaveChannel();
+ join.setText(getString(R.string.join));
+ }
+ }
+ }
+
+ private void joinChannel(String channelId) {
+ engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
+ // --- feature-specific audio setup goes here ---
+ // e.g. engine.setAudioProfile(...); engine.setVoiceBeautifierPreset(...);
+
+ ChannelMediaOptions options = new ChannelMediaOptions();
+ options.autoSubscribeAudio = true;
+ options.publishMicrophoneTrack = true;
+ // Do NOT set publishCameraTrack or autoSubscribeVideo — voice-sdk has no video module
+
+ int uid = new Random().nextInt(1000) + 100000;
+ TokenUtils.gen(requireContext(), channelId, uid, token -> {
+ int res = engine.joinChannel(token, channelId, uid, options);
+ if (res != 0) {
+ showAlert(RtcEngine.getErrorDescription(Math.abs(res)));
+ return;
+ }
+ join.setEnabled(false);
+ });
+ }
+
+ private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
+ @Override
+ public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
+ Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
+ myUid = uid;
+ joined = true;
+ // ALL UI updates must go through runOnUIThread — callbacks run on background thread
+ runOnUIThread(() -> {
+ join.setEnabled(true);
+ join.setText(getString(R.string.leave));
+ });
+ }
+
+ @Override
+ public void onUserJoined(int uid, int elapsed) {
+ Log.i(TAG, "onUserJoined -> " + uid);
+ runOnUIThread(() -> {
+ // audioSeatManager.addUser(uid);
+ });
+ }
+
+ @Override
+ public void onUserOffline(int uid, int reason) {
+ Log.i(TAG, String.format("user %d offline, reason %d", uid, reason));
+ runOnUIThread(() -> {
+ // audioSeatManager.removeUser(uid);
+ });
+ }
+
+ @Override
+ public void onError(int err) {
+ showLongToast("Error code:" + err + ", msg:" + RtcEngine.getErrorDescription(err));
+ }
+ };
+}
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..c33918a41 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;
/**
@@ -182,6 +183,14 @@ protected final void runOnUIThread(Runnable runnable, long delay) {
}
}
+ protected final String getAgoraAppId() {
+ return AgoraConfig.getAppId();
+ }
+
+ protected final String getAgoraAppCertificate() {
+ return AgoraConfig.getAppCertificate();
+ }
+
@Override
public void onDestroy() {
super.onDestroy();
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..6a440245c 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
@@ -40,7 +40,7 @@
import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
@Example(
- index = 15,
+ index = 14,
group = ADVANCED,
name = R.string.item_playaudiofiles,
actionId = R.id.action_mainFragment_to_PlayAudioFiles,
@@ -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.
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..95c66efff 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
@@ -34,7 +34,7 @@
import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
@Example(
- index = 16,
+ index = 15,
group = ADVANCED,
name = R.string.item_precalltest,
actionId = R.id.action_mainFragment_to_PreCallTest,
@@ -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..18276db68 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
@@ -44,7 +44,7 @@
* @author cjw
*/
@Example(
- index = 9,
+ index = 13,
group = ADVANCED,
name = R.string.item_raw_audio,
actionId = R.id.action_mainFragment_raw_audio,
@@ -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.
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..bd6801bb4 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
@@ -37,7 +37,7 @@
* This demo demonstrates how to make a VideoProcessExtension
*/
@Example(
- index = 19,
+ index = 16,
group = ADVANCED,
name = R.string.item_rhythmplayer,
actionId = R.id.action_mainFragment_rhythm_player,
@@ -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.
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..a05becde0 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
@@ -57,7 +57,7 @@
* The type Spatial sound.
*/
@Example(
- index = 22,
+ index = 17,
group = ADVANCED,
name = R.string.item_spatial_sound,
actionId = R.id.action_mainFragment_to_spatial_sound,
@@ -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;
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..0cb03300b 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
@@ -91,7 +91,7 @@
* The type Voice effects.
*/
@Example(
- index = 4,
+ index = 10,
group = ADVANCED,
name = R.string.item_voiceeffects,
actionId = R.id.action_mainFragment_to_VoiceEffects,
@@ -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.
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..e26c4fbda 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
@@ -38,7 +38,7 @@
/**
* This demo demonstrates how to make a one-to-one voice call
*/
-@Example(index = 6, group = ADVANCED, name = R.string.item_customaudiorender, actionId = R.id.action_mainFragment_to_CustomAudioRender, tipsId = R.string.customaudiorender)
+@Example(index = 12, group = ADVANCED, name = R.string.item_customaudiorender, actionId = R.id.action_mainFragment_to_CustomAudioRender, tipsId = R.string.customaudiorender)
public class CustomAudioRender extends BaseFragment implements View.OnClickListener {
private static final String TAG = CustomAudioRender.class.getSimpleName();
private EditText et_channel;
@@ -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.
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..3df640efa 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
@@ -39,7 +39,7 @@
* This demo demonstrates how to make a one-to-one voice call
*/
@Example(
- index = 5,
+ index = 11,
group = ADVANCED,
name = R.string.item_customaudiosource,
actionId = R.id.action_mainFragment_to_CustomAudioSource,
@@ -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.
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..1b35bfc66 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
@@ -28,7 +28,7 @@
import io.agora.rtc2.proxy.LocalAccessPointConfiguration;
@Example(
- index = 7,
+ index = 18,
group = Examples.ADVANCED,
name = R.string.item_audiowaveform,
actionId = R.id.action_mainFragment_to_AudioWaveform,
@@ -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.
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..4e84a5b0d 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
@@ -62,7 +62,7 @@
* @author cjw
*/
@Example(
- index = 2,
+ index = 1,
group = BASIC,
name = R.string.item_joinaudio,
actionId = R.id.action_mainFragment_to_joinChannelAudio,
@@ -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.
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..872df41ae 100644
--- a/Android/APIExample-Audio/ci.env.py
+++ b/Android/APIExample-Audio/ci.env.py
@@ -1,22 +1,29 @@
-#!/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..a19f7eadb 100755
--- a/Android/APIExample-Audio/cloud_build.sh
+++ b/Android/APIExample-Audio/cloud_build.sh
@@ -38,10 +38,22 @@ fi
#sed -ie "s#https://services.gradle.org/distributions#https://mirrors.cloud.tencent.com/gradle#g" gradle/wrapper/gradle-wrapper.properties
## 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() {
+ 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
+}
+
+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
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/.agent/skills/upsert-case/references/composable-template.kt b/Android/APIExample-Compose/.agent/skills/upsert-case/references/composable-template.kt
new file mode 100644
index 000000000..29d8c0da3
--- /dev/null
+++ b/Android/APIExample-Compose/.agent/skills/upsert-case/references/composable-template.kt
@@ -0,0 +1,161 @@
+package io.agora.api.example.compose.samples
+
+import android.Manifest
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.tooling.preview.Preview
+import io.agora.api.example.compose.data.SettingPreferences
+import io.agora.api.example.compose.ui.common.ChannelNameInput
+import io.agora.api.example.compose.utils.AgoraConfig
+import io.agora.api.example.compose.utils.TokenUtils
+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
+
+// For video cases, also import:
+// import io.agora.api.example.compose.ui.common.VideoGrid
+// import io.agora.rtc2.video.VideoCanvas
+// import io.agora.rtc2.video.VideoEncoderConfiguration
+
+/**
+ * Demonstrates how to use [describe the feature here].
+ *
+ * Key APIs used:
+ * - RtcEngine.yourApi()
+ */
+// PUBLIC stateful entry point — no @Preview here
+@Composable
+fun YourCaseName() {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+
+ // rememberSaveable: survives rotation — use for channelName, isJoined, uid, videoIdList
+ var isJoined by rememberSaveable { mutableStateOf(false) }
+ var channelName by rememberSaveable { mutableStateOf("") }
+ var localUid by rememberSaveable { mutableIntStateOf(0) }
+
+ // remember: survives recomposition but NOT rotation — use for RtcEngine, collections
+ val rtcEngine = remember {
+ RtcEngine.create(RtcEngineConfig().apply {
+ mAreaCode = SettingPreferences.getArea() // REQUIRED — do not hardcode
+ mContext = context
+ mAppId = AgoraConfig.getAppId()
+ mEventHandler = object : IRtcEngineEventHandler() {
+ // IRtcEngineEventHandler callbacks are safe to mutate Compose state directly —
+ // the snapshot system is thread-safe. No runOnUIThread() needed.
+ override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
+ super.onJoinChannelSuccess(channel, uid, elapsed)
+ isJoined = true
+ localUid = uid
+ }
+
+ override fun onLeaveChannel(stats: RtcStats?) {
+ super.onLeaveChannel(stats)
+ isJoined = false
+ }
+
+ override fun onUserJoined(uid: Int, elapsed: Int) {
+ super.onUserJoined(uid, elapsed)
+ // add uid to videoIdList for video cases
+ }
+
+ override fun onUserOffline(uid: Int, reason: Int) {
+ super.onUserOffline(uid, reason)
+ // remove uid from videoIdList for video cases
+ }
+ }
+ }).apply {
+ // feature-specific engine setup goes here
+ // e.g. enableVideo(); setVideoEncoderConfiguration(...)
+ }
+ }
+
+ // MUST use lifecycleOwner as key — ensures cleanup fires when screen leaves composition
+ DisposableEffect(lifecycleOwner) {
+ onDispose {
+ if (isJoined) rtcEngine.leaveChannel()
+ RtcEngine.destroy()
+ // NOTE: Toast/Dialog/AlertDialog MUST be called on main thread.
+ // Inside onDispose this is fine. Inside IRtcEngineEventHandler callbacks,
+ // wrap with: coroutineScope.launch(Dispatchers.Main) { ... }
+ }
+ }
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { grantedMap ->
+ // Permission callbacks run on main thread — Toast is safe here
+ if (grantedMap.values.all { it }) {
+ TokenUtils.gen(channelName, 0) { token ->
+ val options = ChannelMediaOptions().apply {
+ channelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING
+ clientRoleType = Constants.CLIENT_ROLE_BROADCASTER
+ publishMicrophoneTrack = true
+ // publishCameraTrack = true // add for video cases
+ }
+ rtcEngine.joinChannel(token, channelName, 0, options)
+ }
+ }
+ }
+
+ // Delegate all UI to the private stateless View function
+ YourCaseNameView(
+ channelName = channelName,
+ isJoined = isJoined,
+ onJoinClick = { name ->
+ channelName = name
+ permissionLauncher.launch(
+ arrayOf(Manifest.permission.RECORD_AUDIO)
+ // add Manifest.permission.CAMERA for video cases
+ )
+ },
+ onLeaveClick = { rtcEngine.leaveChannel() }
+ )
+}
+
+// @Preview goes here on the PRIVATE stateless function — never on the stateful entry above
+@Preview
+@Composable
+private fun YourCaseNamePreview() {
+ YourCaseNameView(
+ channelName = "test",
+ isJoined = false,
+ onJoinClick = {},
+ onLeaveClick = {}
+ )
+}
+
+// PRIVATE stateless View — receives only plain data and lambdas, no engine/state
+@Composable
+private fun YourCaseNameView(
+ channelName: String,
+ isJoined: Boolean,
+ onJoinClick: (String) -> Unit,
+ onLeaveClick: () -> Unit
+) {
+ Column(Modifier.fillMaxSize()) {
+ // feature-specific UI here
+ // For video cases: VideoGrid(videoIdList, setupVideo, ...)
+ ChannelNameInput(
+ channelName = channelName,
+ isJoined = isJoined,
+ onJoinClick = onJoinClick,
+ onLeaveClick = onLeaveClick
+ )
+ }
+}
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-Compose/app/src/main/java/io/agora/api/example/compose/samples/ChannelEncryption.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ChannelEncryption.kt
index c0409085d..780aed6c5 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ChannelEncryption.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ChannelEncryption.kt
@@ -26,7 +26,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -64,7 +64,7 @@ fun ChannelEncryption() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioRender.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioRender.kt
index 34bde5ec8..5cdb4540a 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioRender.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioRender.kt
@@ -26,7 +26,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -70,7 +70,7 @@ fun CustomAudioRender() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioSource.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioSource.kt
index b8ed266d2..f0dc59a53 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioSource.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomAudioSource.kt
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -57,7 +57,7 @@ fun CustomAudioSource() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoRender.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoRender.kt
index b0eae0fe0..15eda975e 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoRender.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoRender.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -64,7 +64,7 @@ fun CustomVideoRender() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoSource.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoSource.kt
index daabf75ba..98a0fa923 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoSource.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/CustomVideoSource.kt
@@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -66,7 +66,7 @@ fun CustomVideoSource() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/HostAcrossChannel.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/HostAcrossChannel.kt
index 32d51e53f..208e2a0da 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/HostAcrossChannel.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/HostAcrossChannel.kt
@@ -21,7 +21,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -58,7 +58,7 @@ fun HostAcrossChannel() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelAudio.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelAudio.kt
index b568104a3..ca8a2341b 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelAudio.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelAudio.kt
@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -59,7 +59,7 @@ fun JoinChannelAudio() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onAudioRouteChanged(routing: Int) {
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideo.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideo.kt
index 97dd506db..4c1c4fca7 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideo.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideo.kt
@@ -26,7 +26,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -58,7 +58,7 @@ fun JoinChannelVideo() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideoToken.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideoToken.kt
index 6856f33fa..1602b7602 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideoToken.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinChannelVideoToken.kt
@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -61,7 +61,7 @@ fun JoinChannelVideoToken() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinMultiChannel.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinMultiChannel.kt
index c2b2c21ad..c0e018736 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinMultiChannel.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/JoinMultiChannel.kt
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -63,7 +63,7 @@ fun JoinMultiChannel() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LiveStreaming.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LiveStreaming.kt
index 85b580d7c..79f649c95 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LiveStreaming.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LiveStreaming.kt
@@ -35,7 +35,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -90,7 +90,7 @@ fun LiveStreaming() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LocalVideoTranscoding.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LocalVideoTranscoding.kt
index 576028134..dca899af8 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LocalVideoTranscoding.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/LocalVideoTranscoding.kt
@@ -23,7 +23,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -63,7 +63,7 @@ fun LocalVideoTranscoding() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaMetadata.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaMetadata.kt
index b5340b845..335571e50 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaMetadata.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaMetadata.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -62,7 +62,7 @@ fun MediaMetadata() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaPlayer.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaPlayer.kt
index 941d56a11..eb17d1d27 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaPlayer.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaPlayer.kt
@@ -26,7 +26,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -68,7 +68,7 @@ fun MediaPlayer() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaRecorder.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaRecorder.kt
index 5ec29865b..ea1828f05 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaRecorder.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/MediaRecorder.kt
@@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContentProviderCompat.requireContext
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -70,7 +70,7 @@ fun MediaRecorder() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginAudioData.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginAudioData.kt
index e545e6f6f..08339a7a0 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginAudioData.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginAudioData.kt
@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -61,7 +61,7 @@ fun OriginAudioData() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginVideoData.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginVideoData.kt
index 8855afd69..5c81ea0e5 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginVideoData.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/OriginVideoData.kt
@@ -33,7 +33,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -75,7 +75,7 @@ fun OriginVideoData() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt
index 19bcb9e45..5b3ff8064 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PictureInPicture.kt
@@ -43,7 +43,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.core.app.PictureInPictureModeChangedInfo
import androidx.core.util.Consumer
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -163,7 +163,7 @@ fun PictureInPicture() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
@@ -354,74 +354,74 @@ fun PictureInPicture() {
Log.d("PiPDebug", "PictureInPicture: Rendering normal mode - full UI")
// Normal mode with full UI - let Example component handle the scaffold
Column(modifier = Modifier.fillMaxWidth()) {
- videoView()
- Spacer(modifier = Modifier.weight(1f))
+ videoView()
+ Spacer(modifier = Modifier.weight(1f))
- Button(
- modifier = Modifier.padding(16.dp, 8.dp),
- enabled = isJoined,
- onClick = {
- if (Build.VERSION.SDK_INT >= 26) {
- val appOpsManager: AppOpsManager =
- context.getSystemService(AppOpsManager::class.java)
- if (appOpsManager.checkOpNoThrow(
- AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
- Process.myUid(),
- context.packageName
- ) == AppOpsManager.MODE_ALLOWED
- ) {
- context.enterPictureInPictureMode(
- PictureInPictureParams.Builder()
- .setAspectRatio(
- Rational(
- videoViewBound.width().toInt(),
- videoViewBound.height().toInt()
+ Button(
+ modifier = Modifier.padding(16.dp, 8.dp),
+ enabled = isJoined,
+ onClick = {
+ if (Build.VERSION.SDK_INT >= 26) {
+ val appOpsManager: AppOpsManager =
+ context.getSystemService(AppOpsManager::class.java)
+ if (appOpsManager.checkOpNoThrow(
+ AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
+ Process.myUid(),
+ context.packageName
+ ) == AppOpsManager.MODE_ALLOWED
+ ) {
+ context.enterPictureInPictureMode(
+ PictureInPictureParams.Builder()
+ .setAspectRatio(
+ Rational(
+ videoViewBound.width().toInt(),
+ videoViewBound.height().toInt()
+ )
)
- )
- .setActions(emptyList()) // Hide system actions (back button, etc.)
- .build()
- )
- val homeIntent = Intent(Intent.ACTION_MAIN)
- homeIntent.addCategory(Intent.CATEGORY_HOME)
- context.startActivity(homeIntent)
- // isPipOn is now managed by rememberIsInPipMode(), no need to manually set
- } else {
- Toast.makeText(
- context,
- "Picture-in-Picture permission is not granted",
- Toast.LENGTH_SHORT
- ).show()
- }
+ .setActions(emptyList()) // Hide system actions (back button, etc.)
+ .build()
+ )
+ val homeIntent = Intent(Intent.ACTION_MAIN)
+ homeIntent.addCategory(Intent.CATEGORY_HOME)
+ context.startActivity(homeIntent)
+ // isPipOn is now managed by rememberIsInPipMode(), no need to manually set
} else {
Toast.makeText(
context,
- "Picture-in-Picture requires Android 8.0 (API 26) or higher",
+ "Picture-in-Picture permission is not granted",
Toast.LENGTH_SHORT
).show()
}
+ } else {
+ Toast.makeText(
+ context,
+ "Picture-in-Picture requires Android 8.0 (API 26) or higher",
+ Toast.LENGTH_SHORT
+ ).show()
}
- ) {
- Text(text = "Enter Picture-in-Picture Mode")
}
+ ) {
+ Text(text = "Enter Picture-in-Picture Mode")
+ }
- ChannelNameInput(
- channelName = channelName,
- isJoined = isJoined,
- onJoinClick = {
- channelName = it
- keyboard?.hide()
- permissionLauncher.launch(
- arrayOf(
- android.Manifest.permission.RECORD_AUDIO,
- android.Manifest.permission.CAMERA
- )
+ ChannelNameInput(
+ channelName = channelName,
+ isJoined = isJoined,
+ onJoinClick = {
+ channelName = it
+ keyboard?.hide()
+ permissionLauncher.launch(
+ arrayOf(
+ android.Manifest.permission.RECORD_AUDIO,
+ android.Manifest.permission.CAMERA
)
- },
- onLeaveClick = {
- rtcEngine.stopPreview()
- rtcEngine.leaveChannel()
- }
- )
+ )
+ },
+ onLeaveClick = {
+ rtcEngine.stopPreview()
+ rtcEngine.leaveChannel()
+ }
+ )
}
}
}
\ No newline at end of file
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PlayAudioFiles.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PlayAudioFiles.kt
index 0119bc612..6b87d9853 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PlayAudioFiles.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PlayAudioFiles.kt
@@ -30,7 +30,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -63,7 +63,7 @@ fun PlayAudioFiles() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PreCallTest.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PreCallTest.kt
index acbb38381..e0d7f265f 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PreCallTest.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/PreCallTest.kt
@@ -34,7 +34,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.VideoCell
@@ -65,7 +65,7 @@ fun PreCallTest() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onLastmileQuality(quality: Int) {
super.onLastmileQuality(quality)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RTMPStreaming.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RTMPStreaming.kt
index ff62acb62..8c7cac0c2 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RTMPStreaming.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RTMPStreaming.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -62,7 +62,7 @@ fun RTMPStreaming() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RhythmPlayer.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RhythmPlayer.kt
index 2d0baab9f..480688fa2 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RhythmPlayer.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/RhythmPlayer.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -50,7 +50,7 @@ fun RhythmPlayer() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ScreenSharing.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ScreenSharing.kt
index 855a8a01a..ffedfe400 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ScreenSharing.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/ScreenSharing.kt
@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -82,7 +82,7 @@ fun ScreenSharing() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context.applicationContext
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SendDataStream.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SendDataStream.kt
index ca77b3b22..189f11bfa 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SendDataStream.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SendDataStream.kt
@@ -19,7 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -53,7 +53,7 @@ fun SendDataStream() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SpatialSound.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SpatialSound.kt
index 2fc0e176c..631190434 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SpatialSound.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/SpatialSound.kt
@@ -50,7 +50,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -88,7 +88,7 @@ fun SpatialSound() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VideoProcessExtension.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VideoProcessExtension.kt
index 2bb0038a3..f82b315d6 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VideoProcessExtension.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VideoProcessExtension.kt
@@ -22,7 +22,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.ChannelNameInput
@@ -64,7 +64,7 @@ fun VideoProcessExtension() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VoiceEffects.kt b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VoiceEffects.kt
index f46e8a806..4866f7bb3 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VoiceEffects.kt
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/samples/VoiceEffects.kt
@@ -28,7 +28,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import io.agora.api.example.compose.BuildConfig
+import io.agora.api.example.compose.utils.AgoraConfig
import io.agora.api.example.compose.R
import io.agora.api.example.compose.data.SettingPreferences
import io.agora.api.example.compose.ui.common.AudioGrid
@@ -57,7 +57,7 @@ fun VoiceEffects() {
RtcEngine.create(RtcEngineConfig().apply {
mAreaCode = SettingPreferences.getArea()
mContext = context
- mAppId = BuildConfig.AGORA_APP_ID
+ mAppId = AgoraConfig.getAppId()
mEventHandler = object : IRtcEngineEventHandler() {
override fun onJoinChannelSuccess(channel: String?, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed)
diff --git a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/AgoraConfig.java b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/AgoraConfig.java
new file mode 100644
index 000000000..2401bec0f
--- /dev/null
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/AgoraConfig.java
@@ -0,0 +1,22 @@
+package io.agora.api.example.compose.utils;
+
+import android.text.TextUtils;
+
+import io.agora.api.example.compose.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-Compose/app/src/main/java/io/agora/api/example/compose/utils/TokenUtils.java b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/TokenUtils.java
index e744eb40e..b7de5683e 100644
--- a/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/TokenUtils.java
+++ b/Android/APIExample-Compose/app/src/main/java/io/agora/api/example/compose/utils/TokenUtils.java
@@ -14,7 +14,6 @@
import java.io.IOException;
import java.util.Objects;
-import io.agora.api.example.compose.BuildConfig;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
@@ -44,11 +43,11 @@ private TokenUtils() {
}
public static void genToken(String channelName, int uid, OnTokenGenCallback onGetToken) {
- String cert = BuildConfig.AGORA_APP_CERT;
+ String cert = AgoraConfig.getAppCertificate();
if (cert.isEmpty()) {
onGetToken.onTokenGen("");
} else {
- gen(BuildConfig.AGORA_APP_ID, BuildConfig.AGORA_APP_CERT, channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), AgoraConfig.getAppCertificate(), channelName, uid, ret -> {
if (onGetToken != null) {
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
@@ -73,7 +72,7 @@ public static void genToken(String channelName, int uid, OnTokenGenCallback onGetToken) {
- gen(BuildConfig.AGORA_APP_ID, BuildConfig.AGORA_APP_CERT, channelName, uid, ret -> {
+ gen(AgoraConfig.getAppId(), AgoraConfig.getAppCertificate(), channelName, uid, ret -> {
if (onGetToken != null) {
runOnUiThread(() -> {
onGetToken.onTokenGen(ret);
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/.agent/skills/upsert-case/references/fragment-template.java b/Android/APIExample/.agent/skills/upsert-case/references/fragment-template.java
new file mode 100644
index 000000000..a96649654
--- /dev/null
+++ b/Android/APIExample/.agent/skills/upsert-case/references/fragment-template.java
@@ -0,0 +1,207 @@
+package io.agora.api.example.examples.advanced;
+
+import static io.agora.api.example.common.model.Examples.ADVANCED;
+import static io.agora.rtc2.Constants.RENDER_MODE_HIDDEN;
+
+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 java.util.Map;
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+
+import io.agora.api.example.MainApplication;
+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.common.widget.VideoReportLayout;
+import io.agora.api.example.utils.PermissonUtils;
+import io.agora.api.example.utils.TokenUtils;
+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;
+
+/**
+ * This demo demonstrates how to use [describe the feature here].
+ *
+ * Key APIs used:
+ * - RtcEngine.yourApi()
+ */
+@Example(
+ index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+
+ group = ADVANCED, // BASIC or ADVANCED
+ name = R.string.item_your_case_name,
+ actionId = R.id.action_mainFragment_to_yourCaseName,
+ tipsId = R.string.your_case_name_tips
+)
+public class YourCaseName extends BaseFragment implements View.OnClickListener {
+ private static final String TAG = YourCaseName.class.getSimpleName();
+
+ // For video cases: add VideoReportLayout fields here
+ // private VideoReportLayout fl_local, fl_remote;
+ // private Map remoteViews = new ConcurrentHashMap<>();
+
+ private Button join;
+ private EditText et_channel;
+ private RtcEngine engine;
+ private int myUid;
+ private boolean joined = false;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater.inflate(R.layout.fragment_your_case_name, container, false);
+ }
+
+ @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);
+ join.setOnClickListener(this);
+ // bind additional views here
+ }
+
+ @Override
+ public void onActivityCreated(@Nullable Bundle savedInstanceState) {
+ super.onActivityCreated(savedInstanceState);
+ Context context = getContext();
+ if (context == null) return;
+ try {
+ RtcEngineConfig config = new RtcEngineConfig();
+ config.mContext = context.getApplicationContext();
+ config.mAppId = getAgoraAppId();
+ config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING;
+ config.mEventHandler = iRtcEngineEventHandler;
+ config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT);
+ config.mAreaCode = ((MainApplication) getActivity().getApplication())
+ .getGlobalSettings().getAreaCode();
+ engine = RtcEngine.create(config);
+ // REQUIRED in every case — do not remove
+ engine.setParameters("{"
+ + "\"rtc.report_app_scenario\":"
+ + "{"
+ + "\"appScenario\":" + 100 + ","
+ + "\"serviceType\":" + 11 + ","
+ + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\""
+ + "}"
+ + "}");
+ // null-check is mandatory — returns null on non-private-cloud builds
+ LocalAccessPointConfiguration localAccessPointConfiguration =
+ ((MainApplication) getActivity().getApplication())
+ .getGlobalSettings().getPrivateCloudConfig();
+ if (localAccessPointConfiguration != null) {
+ engine.setLocalAccessPoint(localAccessPointConfiguration);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ getActivity().onBackPressed();
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ if (engine != null) {
+ engine.leaveChannel();
+ }
+ // MUST use handler.post — do NOT call RtcEngine.destroy() directly on main thread
+ handler.post(RtcEngine::destroy);
+ engine = null;
+ }
+
+ @Override
+ public void onClick(View v) {
+ if (v.getId() == R.id.btn_join) {
+ if (!joined) {
+ String channelId = et_channel.getText().toString();
+ checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() {
+ @Override
+ public void onPermissionsResult(boolean allPermissionsGranted,
+ String[] permissions, int[] grantResults) {
+ if (allPermissionsGranted) {
+ joinChannel(channelId);
+ }
+ }
+ });
+ } else {
+ joined = false;
+ engine.leaveChannel();
+ join.setText(getString(R.string.join));
+ }
+ }
+ }
+
+ private void joinChannel(String channelId) {
+ engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER);
+ // --- feature-specific setup goes here ---
+ // e.g. engine.enableVideo(); engine.setVideoEncoderConfiguration(...);
+
+ ChannelMediaOptions options = new ChannelMediaOptions();
+ options.autoSubscribeAudio = true;
+ options.autoSubscribeVideo = true;
+ options.publishMicrophoneTrack = true;
+ options.publishCameraTrack = true; // remove for audio-only cases
+
+ int uid = new Random().nextInt(1000) + 100000;
+ TokenUtils.gen(requireContext(), channelId, uid, token -> {
+ int res = engine.joinChannel(token, channelId, uid, options);
+ if (res != 0) {
+ showAlert(RtcEngine.getErrorDescription(Math.abs(res)));
+ return;
+ }
+ join.setEnabled(false);
+ });
+ }
+
+ private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() {
+ @Override
+ public void onJoinChannelSuccess(String channel, int uid, int elapsed) {
+ Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid));
+ myUid = uid;
+ joined = true;
+ // ALL UI updates must go through runOnUIThread — callbacks run on background thread
+ runOnUIThread(() -> {
+ join.setEnabled(true);
+ join.setText(getString(R.string.leave));
+ });
+ }
+
+ @Override
+ public void onUserJoined(int uid, int elapsed) {
+ Log.i(TAG, "onUserJoined -> " + uid);
+ runOnUIThread(() -> {
+ // For video cases: create SurfaceView, call engine.setupRemoteVideo(...)
+ });
+ }
+
+ @Override
+ public void onUserOffline(int uid, int reason) {
+ Log.i(TAG, String.format("user %d offline, reason %d", uid, reason));
+ runOnUIThread(() -> {
+ // For video cases: removeAllViews(), call engine.setupRemoteVideo(null, ...)
+ });
+ }
+
+ @Override
+ public void onError(int err) {
+ showLongToast("Error code:" + err + ", msg:" + RtcEngine.getErrorDescription(err));
+ }
+ };
+}
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..2edb8c468 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;
/**
@@ -182,6 +183,14 @@ protected final void runOnUIThread(Runnable runnable, long delay) {
}
}
+ protected final String getAgoraAppId() {
+ return AgoraConfig.getAppId();
+ }
+
+ protected final String getAgoraAppCertificate() {
+ return AgoraConfig.getAppCertificate();
+ }
+
@Override
public void onDestroy() {
super.onDestroy();
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..5af6f98dc 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
@@ -62,7 +62,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..a48f07d71 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
@@ -54,7 +54,7 @@
* The type Agora beauty.
*/
@Example(
- index = 27,
+ index = 43,
group = ADVANCED,
name = R.string.item_agora_beauty,
actionId = R.id.action_mainFragment_agora_beauty,
@@ -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.
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..b61249795 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
@@ -47,7 +47,7 @@
* This demo demonstrates how to make a one-to-one video call
*/
@Example(
- index = 14,
+ index = 26,
group = ADVANCED,
name = R.string.item_channelencryption,
actionId = R.id.action_mainFragment_to_channel_encryption,
@@ -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.
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..4839435fb 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
@@ -40,7 +40,7 @@
* This demo demonstrates how to make a one-to-one video call
*/
@Example(
- index = 23,
+ index = 38,
group = ADVANCED,
name = R.string.item_content_inspect,
actionId = R.id.action_mainFragment_to_content_inspect,
@@ -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.
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..790f995d8 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
@@ -56,7 +56,7 @@
* This example demonstrates how to customize the renderer to render the local scene of the remote video stream.
*/
@Example(
- index = 8,
+ index = 17,
group = ADVANCED,
name = R.string.item_customremoterender,
actionId = R.id.action_mainFragment_to_CustomRemoteRender,
@@ -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.
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..7b002db61 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
@@ -45,7 +45,7 @@
* The type Process raw data.
*/
@Example(
- index = 12,
+ index = 23,
group = ADVANCED,
name = R.string.item_face_capture,
actionId = R.id.action_mainFragment_to_face_capture,
@@ -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.
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..770e0cdf5 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
@@ -41,7 +41,7 @@
* This demo demonstrates how to make a one-to-one video call
*/
@Example(
- index = 21,
+ index = 36,
group = ADVANCED,
name = R.string.item_hostacrosschannel,
actionId = R.id.action_mainFragment_to_hostacrosschannel,
@@ -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.
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
index 36ae325be..2eb40d915 100644
--- 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
@@ -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.
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..17e40f8ff 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
@@ -49,7 +49,7 @@
* The type Join multiple channel.
*/
@Example(
- index = 13,
+ index = 25,
group = ADVANCED,
name = R.string.item_joinmultichannel,
actionId = R.id.action_mainFragment_to_MultiChannel,
@@ -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.
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/KtvCopyrightMusic.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/KtvCopyrightMusic.java
index fe2c14df3..cd49cf805 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/KtvCopyrightMusic.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/KtvCopyrightMusic.java
@@ -12,7 +12,7 @@
* The type Ktv copyright music.
*/
@Example(
- index = 24,
+ index = 40,
group = ADVANCED,
name = R.string.item_ktv_copyright_music,
actionId = R.id.action_mainFragment_to_ktv_copyright_music,
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..b82fa47d8 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
@@ -68,7 +68,7 @@
* When turn the Co-host on, others will see you.
*/
@Example(
- index = 0,
+ index = 10,
group = ADVANCED,
name = R.string.item_livestreaming,
actionId = R.id.action_mainFragment_to_live_streaming,
@@ -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. */
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..36e88262d 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
@@ -49,7 +49,7 @@
* This demo demonstrates how to make a one-to-one video call
*/
@Example(
- index = 19,
+ index = 33,
group = ADVANCED,
name = R.string.item_localvideotranscoding,
actionId = R.id.action_mainFragment_to_LocalVideoTranscoding,
@@ -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.
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..8747dc500 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
@@ -47,7 +47,7 @@
* The type Video metadata.
*/
@Example(
- index = 3,
+ index = 12,
group = ADVANCED,
name = R.string.item_mediametadata,
actionId = R.id.action_mainFragment_to_MediaMetadata,
@@ -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.
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..4278d7540 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
@@ -65,7 +65,7 @@
* The type Media player.
*/
@Example(
- index = 17,
+ index = 29,
group = ADVANCED,
name = R.string.item_mediaplayer,
actionId = R.id.action_mainFragment_to_MediaPlayer,
@@ -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.
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..f93c4b8a9 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
@@ -54,7 +54,7 @@
* The type Media recorder.
*/
@Example(
- index = 17,
+ index = 30,
group = ADVANCED,
name = R.string.item_media_recorder,
actionId = R.id.action_mainFragment_to_MediaRecorder,
@@ -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.
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..5dcf0d621 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
@@ -66,7 +66,7 @@
* The type Multi video source tracks.
*/
@Example(
- index = 10,
+ index = 19,
group = ADVANCED,
name = R.string.item_multiVideoSourceTracks,
actionId = R.id.action_mainFragment_to_MultiVideoSourceTracks,
@@ -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.
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..5b3139186 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
@@ -47,7 +47,7 @@
* This example demonstrates how to use Multipath
*/
@Example(
- index = 29,
+ index = 45,
group = ADVANCED,
name = R.string.item_multipath,
actionId = R.id.action_mainFragment_to_multipath,
@@ -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.
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..7c73b7303 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
@@ -50,7 +50,7 @@
/**
* This demo demonstrates how to make a one-to-one video call
*/
-@Example(index = 11, group = ADVANCED, name = R.string.item_picture_in_picture, actionId = R.id.action_mainFragment_to_picture_in_picture, tipsId = R.string.picture_in_picture)
+@Example(index = 22, group = ADVANCED, name = R.string.item_picture_in_picture, actionId = R.id.action_mainFragment_to_picture_in_picture, tipsId = R.string.picture_in_picture)
public class PictureInPicture extends BaseFragment implements View.OnClickListener {
private static final String TAG = PictureInPicture.class.getSimpleName();
@@ -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.
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..0363996b6 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
@@ -43,7 +43,7 @@
* The type Play audio files.
*/
@Example(
- index = 15,
+ index = 27,
group = ADVANCED,
name = R.string.item_playaudiofiles,
actionId = R.id.action_mainFragment_to_PlayAudioFiles,
@@ -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.
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..ee2abd493 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
@@ -36,7 +36,7 @@
* The type Pre call test.
*/
@Example(
- index = 16,
+ index = 28,
group = ADVANCED,
name = R.string.item_precalltest,
actionId = R.id.action_mainFragment_to_PreCallTest,
@@ -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..5b6450bc5 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
@@ -44,7 +44,7 @@
* @author cjw
*/
@Example(
- index = 9,
+ index = 18,
group = ADVANCED,
name = R.string.item_raw_audio,
actionId = R.id.action_mainFragment_raw_audio,
@@ -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.
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..e051003d1 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
@@ -55,7 +55,7 @@
* The type Process raw data.
*/
@Example(
- index = 11,
+ index = 20,
group = ADVANCED,
name = R.string.item_processraw,
actionId = R.id.action_mainFragment_to_ProcessRawData,
@@ -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.
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
index dcc49c6fc..e31816c58 100644
--- 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
@@ -144,7 +144,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/PushExternalVideoYUV.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideoYUV.java
index 1bcb3a580..17b3d7127 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
@@ -59,7 +59,7 @@
* The type Push external video yuv.
*/
@Example(
- index = 7,
+ index = 16,
group = ADVANCED,
name = R.string.item_pushexternal,
actionId = R.id.action_mainFragment_to_PushExternalVideo,
@@ -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.
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..eea6a0007 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
@@ -49,7 +49,7 @@
* otherwise unexpected errors will occur.
*/
@Example(
- index = 1,
+ index = 11,
group = ADVANCED,
name = R.string.item_rtctortmp,
actionId = R.id.action_mainFragment_to_RTCToRTMP,
@@ -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.
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..28386196f 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
@@ -37,7 +37,7 @@
* This demo demonstrates how to make a VideoProcessExtension
*/
@Example(
- index = 19,
+ index = 34,
group = ADVANCED,
name = R.string.item_rhythmplayer,
actionId = R.id.action_mainFragment_rhythm_player,
@@ -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.
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..001fb78eb 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
@@ -60,7 +60,7 @@
* screen share stream during an audio-video call.
*/
@Example(
- index = 18,
+ index = 31,
group = ADVANCED,
name = R.string.item_screensharing,
actionId = R.id.action_mainFragment_to_ScreenSharing,
@@ -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.
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..a5ea8a318 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
@@ -41,7 +41,7 @@
* The type Send data stream.
*/
@Example(
- index = 20,
+ index = 35,
group = ADVANCED,
name = R.string.item_senddatastream,
actionId = R.id.action_mainFragment_senddatastream,
@@ -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.
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..b3430bc2e 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
@@ -46,7 +46,7 @@
* @author cjw
*/
@Example(
- index = 11,
+ index = 21,
group = ADVANCED,
name = R.string.item_ext,
actionId = R.id.action_mainFragment_extension,
@@ -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.
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..3e7442449 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
@@ -50,7 +50,7 @@
* This example demonstrates how to use Simulcast
*/
@Example(
- index = 28,
+ index = 44,
group = ADVANCED,
name = R.string.item_simulcast,
actionId = R.id.action_mainFragment_to_simulcast,
@@ -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.
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..6e796ce0f 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
@@ -57,7 +57,7 @@
* The type Spatial sound.
*/
@Example(
- index = 22,
+ index = 37,
group = ADVANCED,
name = R.string.item_spatial_sound,
actionId = R.id.action_mainFragment_to_spatial_sound,
@@ -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;
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..213be52f7 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.
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ThirdPartyBeauty.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ThirdPartyBeauty.java
index ccc9669ad..e75fedc51 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ThirdPartyBeauty.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/ThirdPartyBeauty.java
@@ -29,7 +29,7 @@
* The type Third party beauty.
*/
@Example(
- index = 24,
+ index = 39,
group = ADVANCED,
name = R.string.item_third_party_beauty,
actionId = R.id.action_mainFragment_to_third_party_beauty,
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..f53e803e1 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
@@ -45,7 +45,7 @@
* This demo demonstrates how to make a one-to-one video call
*/
@Example(
- index = 25,
+ index = 41,
group = ADVANCED,
name = R.string.item_transparentrendering,
actionId = R.id.action_mainFragment_to_transparentrendering,
@@ -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.
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..25bb10b35 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
@@ -32,7 +32,7 @@
* This demo demonstrates how to make a live stream with url
*/
@Example(
- index = 26,
+ index = 42,
group = ADVANCED,
name = R.string.ultra_live_streaming_with_url,
actionId = R.id.action_mainFragment_to_url_live_stream,
@@ -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..a890e3ecf 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
@@ -57,7 +57,7 @@
* This demo demonstrates how to make a VideoProcessExtension
*/
@Example(
- index = 19,
+ index = 32,
group = ADVANCED,
name = R.string.item_videoProcessExtension,
actionId = R.id.action_mainFragment_video_enhancement,
@@ -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.
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..6a7cb8abc 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
@@ -56,7 +56,7 @@
* @author cjw
*/
@Example(
- index = 12,
+ index = 24,
group = ADVANCED,
name = R.string.item_quickswitck,
actionId = R.id.action_mainFragment_to_QuickSwitch,
@@ -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.
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..9a9439ec1 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
@@ -91,7 +91,7 @@
* The type Voice effects.
*/
@Example(
- index = 4,
+ index = 13,
group = ADVANCED,
name = R.string.item_voiceeffects,
actionId = R.id.action_mainFragment_to_VoiceEffects,
@@ -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.
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..f7cb510f8 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
@@ -38,7 +38,7 @@
/**
* This demo demonstrates how to make a one-to-one voice call
*/
-@Example(index = 6, group = ADVANCED, name = R.string.item_customaudiorender, actionId = R.id.action_mainFragment_to_CustomAudioRender, tipsId = R.string.customaudiorender)
+@Example(index = 15, group = ADVANCED, name = R.string.item_customaudiorender, actionId = R.id.action_mainFragment_to_CustomAudioRender, tipsId = R.string.customaudiorender)
public class CustomAudioRender extends BaseFragment implements View.OnClickListener {
private static final String TAG = CustomAudioRender.class.getSimpleName();
private EditText et_channel;
@@ -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.
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..d1f79394b 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
@@ -38,7 +38,7 @@
/**
* This demo demonstrates how to make a one-to-one voice call
*/
-@Example(index = 5, group = ADVANCED, name = R.string.item_customaudiosource, actionId = R.id.action_mainFragment_to_CustomAudioSource, tipsId = R.string.customaudio)
+@Example(index = 14, group = ADVANCED, name = R.string.item_customaudiosource, actionId = R.id.action_mainFragment_to_CustomAudioSource, tipsId = R.string.customaudio)
public class CustomAudioSource extends BaseFragment implements View.OnClickListener, CompoundButton.OnCheckedChangeListener {
private static final String TAG = CustomAudioSource.class.getSimpleName();
private EditText et_channel;
@@ -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.
diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayer.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayer.java
index 66a693653..45b7859c8 100644
--- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayer.java
+++ b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/audio/AudioRouterPlayer.java
@@ -21,7 +21,7 @@
* The type Audio router player.
*/
@Example(
- index = 17,
+ index = 6,
group = ADVANCED,
name = R.string.item_audiorouter_player,
actionId = R.id.action_mainFragment_to_AudioRouterPlayer,
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..ae75d7edc 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.
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..339a95ce4 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.
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..c8876a924 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.
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..e083f8479 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
@@ -32,7 +32,7 @@
* The type Audio waveform.
*/
@Example(
- index = 17,
+ index = 5,
group = ADVANCED,
name = R.string.item_audiowaveform,
actionId = R.id.action_mainFragment_to_AudioWaveform,
@@ -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.
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..f2591d056 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.
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..7d341f5f4 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.
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/navigation/nav_graph.xml b/Android/APIExample/app/src/main/res/navigation/nav_graph.xml
index 08f4fdc45..a70d590f7 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 @@
-
-
-
@@ -175,20 +172,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" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- YOUR APP ID
-
-
-
- YOUR APP CERTIFICATE
-
-
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..af877e6ce 100755
--- a/Android/APIExample/cloud_build.sh
+++ b/Android/APIExample/cloud_build.sh
@@ -35,10 +35,22 @@ fi
#sed -ie "s#https://services.gradle.org/distributions#https://mirrors.cloud.tencent.com/gradle#g" gradle/wrapper/gradle-wrapper.properties
## 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() {
+ 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
+}
+
+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
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/review-case/references/correct-lifecycle.swift b/macOS/.agent/skills/review-case/references/correct-lifecycle.swift
new file mode 100644
index 000000000..853b274f6
--- /dev/null
+++ b/macOS/.agent/skills/review-case/references/correct-lifecycle.swift
@@ -0,0 +1,20 @@
+// Correct Engine Lifecycle Pattern
+
+override func viewDidLoad() {
+ super.viewDidLoad()
+ initializeAgoraEngine() // Create once
+}
+
+override func viewWillClose() {
+ leaveChannel()
+ super.viewWillClose()
+}
+
+func joinChannel() {
+ agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0)
+}
+
+func leaveChannel() {
+ agoraKit.leaveChannel(nil)
+ agoraKit.destroy()
+}
diff --git a/macOS/.agent/skills/review-case/references/correct-thread-safety.swift b/macOS/.agent/skills/review-case/references/correct-thread-safety.swift
new file mode 100644
index 000000000..6e46a7b49
--- /dev/null
+++ b/macOS/.agent/skills/review-case/references/correct-thread-safety.swift
@@ -0,0 +1,8 @@
+// Correct: Thread-safe UI updates
+
+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"
+ }
+}
diff --git a/macOS/.agent/skills/review-case/references/incorrect-lifecycle.swift b/macOS/.agent/skills/review-case/references/incorrect-lifecycle.swift
new file mode 100644
index 000000000..81180c567
--- /dev/null
+++ b/macOS/.agent/skills/review-case/references/incorrect-lifecycle.swift
@@ -0,0 +1,11 @@
+// ❌ INCORRECT: Creating engine on every join
+func joinChannel() {
+ agoraKit = AgoraRtcEngineKit(...) // WRONG: recreates engine
+ agoraKit.joinChannel(...)
+}
+
+// ❌ INCORRECT: Not calling destroy
+override func viewWillClose() {
+ agoraKit.leaveChannel(nil)
+ // Missing: agoraKit.destroy()
+}
diff --git a/macOS/.agent/skills/review-case/references/incorrect-thread-safety.swift b/macOS/.agent/skills/review-case/references/incorrect-thread-safety.swift
new file mode 100644
index 000000000..6f2274205
--- /dev/null
+++ b/macOS/.agent/skills/review-case/references/incorrect-thread-safety.swift
@@ -0,0 +1,4 @@
+// ❌ INCORRECT: Direct UI update from callback
+func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) {
+ self.statusLabel.stringValue = "Joined channel" // WRONG: may crash
+}
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/.agent/skills/upsert-case/references/example-template.swift b/macOS/.agent/skills/upsert-case/references/example-template.swift
new file mode 100644
index 000000000..1c0c8327e
--- /dev/null
+++ b/macOS/.agent/skills/upsert-case/references/example-template.swift
@@ -0,0 +1,62 @@
+import Cocoa
+import AgoraRtcKit
+import AGEVideoLayout
+
+class Main: BaseViewController {
+
+ var agoraKit: AgoraRtcEngineKit!
+ var remoteUid: UInt = 0
+
+ @IBOutlet weak var Container: AGEVideoContainer!
+
+ // MARK: - Lifecycle
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ initializeAgoraEngine()
+ }
+
+ override func viewWillClose() {
+ leaveChannel()
+ super.viewWillClose()
+ }
+
+ // MARK: - Agora Engine Setup
+
+ func initializeAgoraEngine() {
+ let config = AgoraRtcEngineConfig()
+ config.appId = KeyCenter.AppId
+ config.areaCode = .global
+
+ agoraKit = AgoraRtcEngineKit(config: config, delegate: self)
+ agoraKit.enableVideo()
+ agoraKit.enableAudio()
+ }
+
+ func joinChannel() {
+ let token = KeyCenter.Token(channelName: "test")
+ agoraKit.joinChannel(byToken: token, channelName: "test", info: nil, uid: 0)
+ }
+
+ func leaveChannel() {
+ agoraKit.leaveChannel(nil)
+ agoraKit.destroy()
+ }
+}
+
+// MARK: - AgoraRtcEngineDelegate
+
+extension Main: AgoraRtcEngineDelegate {
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) {
+ // Handle join success
+ }
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
+ // Handle remote user joined
+ }
+
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) {
+ // Handle remote user offline
+ }
+}
diff --git a/macOS/.agent/skills/upsert-case/references/lifecycle-pattern.swift b/macOS/.agent/skills/upsert-case/references/lifecycle-pattern.swift
new file mode 100644
index 000000000..3821b2c3f
--- /dev/null
+++ b/macOS/.agent/skills/upsert-case/references/lifecycle-pattern.swift
@@ -0,0 +1,13 @@
+// Proper Engine Lifecycle Pattern
+
+// Create
+let config = AgoraRtcEngineConfig()
+config.appId = KeyCenter.AppId
+agoraKit = AgoraRtcEngineKit(config: config, delegate: self)
+
+// Use
+agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0)
+
+// Cleanup
+agoraKit.leaveChannel(nil)
+agoraKit.destroy()
diff --git a/macOS/.agent/skills/upsert-case/references/thread-safety-pattern.swift b/macOS/.agent/skills/upsert-case/references/thread-safety-pattern.swift
new file mode 100644
index 000000000..6658ba75e
--- /dev/null
+++ b/macOS/.agent/skills/upsert-case/references/thread-safety-pattern.swift
@@ -0,0 +1,10 @@
+// Thread Safety Pattern - Always dispatch UI updates to main thread
+
+extension ExampleMain: AgoraRtcEngineDelegate {
+ func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) {
+ // Callbacks may arrive on background thread
+ DispatchQueue.main.async {
+ self.updateUI()
+ }
+ }
+}
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/review-case/references/correct-lifecycle.cpp b/windows/.agent/skills/review-case/references/correct-lifecycle.cpp
new file mode 100644
index 000000000..1e2b464ac
--- /dev/null
+++ b/windows/.agent/skills/review-case/references/correct-lifecycle.cpp
@@ -0,0 +1,27 @@
+// Correct Engine Lifecycle Pattern
+
+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();
+}
diff --git a/windows/.agent/skills/review-case/references/correct-thread-safety.cpp b/windows/.agent/skills/review-case/references/correct-thread-safety.cpp
new file mode 100644
index 000000000..487cc5b7c
--- /dev/null
+++ b/windows/.agent/skills/review-case/references/correct-thread-safety.cpp
@@ -0,0 +1,16 @@
+// Correct: Thread-safe UI updates via message map
+
+// 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;
+}
diff --git a/windows/.agent/skills/review-case/references/incorrect-lifecycle.cpp b/windows/.agent/skills/review-case/references/incorrect-lifecycle.cpp
new file mode 100644
index 000000000..173fc60c3
--- /dev/null
+++ b/windows/.agent/skills/review-case/references/incorrect-lifecycle.cpp
@@ -0,0 +1,12 @@
+// ❌ INCORRECT: Creating engine on every join
+void CExampleDlg::JoinChannel() {
+ m_rtcEngine = createAgoraRtcEngine(); // WRONG: recreates engine
+ m_rtcEngine->joinChannel(...);
+}
+
+// ❌ INCORRECT: Not calling release
+void CExampleDlg::PostNcDestroy() {
+ m_rtcEngine->leaveChannel();
+ // Missing: m_rtcEngine->release()
+ CDialogEx::PostNcDestroy();
+}
diff --git a/windows/.agent/skills/review-case/references/incorrect-thread-safety.cpp b/windows/.agent/skills/review-case/references/incorrect-thread-safety.cpp
new file mode 100644
index 000000000..d6f25c8f5
--- /dev/null
+++ b/windows/.agent/skills/review-case/references/incorrect-thread-safety.cpp
@@ -0,0 +1,4 @@
+// ❌ INCORRECT: Direct UI update from event handler
+void CExampleRtcEngineEventHandler::onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) {
+ m_pDlg->m_statusText.SetWindowText(_T("Joined")); // WRONG: may crash
+}
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/.agent/skills/upsert-case/references/event-handler-pattern.cpp b/windows/.agent/skills/upsert-case/references/event-handler-pattern.cpp
new file mode 100644
index 000000000..e4dc26afc
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/references/event-handler-pattern.cpp
@@ -0,0 +1,16 @@
+// Event Handler Pattern - Post messages to main thread
+
+class CExampleRtcEngineEventHandler : public IRtcEngineEventHandler {
+public:
+ void SetMsgReceiver(HWND hWnd) { m_hMsgHandler = hWnd; }
+
+ virtual void onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) override {
+ if (m_hMsgHandler) {
+ // Post message to main thread
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), (WPARAM)uid, 0);
+ }
+ }
+
+private:
+ HWND m_hMsgHandler = nullptr;
+};
diff --git a/windows/.agent/skills/upsert-case/references/example-template.cpp b/windows/.agent/skills/upsert-case/references/example-template.cpp
new file mode 100644
index 000000000..4d53ce765
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/references/example-template.cpp
@@ -0,0 +1,109 @@
+#include "stdafx.h"
+#include "APIExample.h"
+#include "CDlg.h"
+#include "CConfig.h"
+
+IMPLEMENT_DYNAMIC(CDlg, CDialogEx)
+
+// Event Handler Implementation
+void CRtcEngineEventHandler::onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) {
+ if (m_hMsgHandler) {
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), (WPARAM)uid, (LPARAM)elapsed);
+ }
+}
+
+void CRtcEngineEventHandler::onUserJoined(uid_t uid, int elapsed) {
+ if (m_hMsgHandler) {
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_USER_JOINED), (WPARAM)uid, (LPARAM)elapsed);
+ }
+}
+
+void CRtcEngineEventHandler::onUserOffline(uid_t uid, USER_OFFLINE_REASON_TYPE reason) {
+ if (m_hMsgHandler) {
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_USER_OFFLINE), (WPARAM)uid, (LPARAM)reason);
+ }
+}
+
+void CRtcEngineEventHandler::onError(int err) {
+ if (m_hMsgHandler) {
+ ::PostMessage(m_hMsgHandler, WM_MSGID(EID_ERROR), (WPARAM)err, 0);
+ }
+}
+
+// Dialog Implementation
+CDlg::CDlg(CWnd* pParent)
+ : CDialogEx(IDD_DIALOG_, pParent) {
+}
+
+CDlg::~CDlg() {
+ if (m_rtcEngine) {
+ m_rtcEngine->release();
+ m_rtcEngine = nullptr;
+ }
+}
+
+void CDlg::DoDataExchange(CDataExchange* pDX) {
+ CDialogEx::DoDataExchange(pDX);
+}
+
+BEGIN_MESSAGE_MAP(CDlg, CDialogEx)
+ ON_BN_CLICKED(IDC_BUTTON_JOIN, &CDlg::OnBnClickedButtonJoin)
+ ON_BN_CLICKED(IDC_BUTTON_LEAVE, &CDlg::OnBnClickedButtonLeave)
+ ON_MESSAGE(WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), &CDlg::OnMsgEngineEvent)
+ ON_MESSAGE(WM_MSGID(EID_USER_JOINED), &CDlg::OnMsgEngineEvent)
+ ON_MESSAGE(WM_MSGID(EID_USER_OFFLINE), &CDlg::OnMsgEngineEvent)
+ ON_MESSAGE(WM_MSGID(EID_ERROR), &CDlg::OnMsgEngineEvent)
+END_MESSAGE_MAP()
+
+BOOL CDlg::OnInitDialog() {
+ CDialogEx::OnInitDialog();
+ InitializeAgoraEngine();
+ return TRUE;
+}
+
+void CDlg::PostNcDestroy() {
+ LeaveChannel();
+ CDialogEx::PostNcDestroy();
+ delete this;
+}
+
+void CDlg::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);
+ m_rtcEngine->enableVideo();
+ m_rtcEngine->enableAudio();
+}
+
+void CDlg::JoinChannel() {
+ if (!m_rtcEngine) return;
+
+ const char* token = CConfig::GetToken("test");
+ m_rtcEngine->joinChannel(token, "test", "", 0);
+}
+
+void CDlg::LeaveChannel() {
+ if (!m_rtcEngine) return;
+
+ m_rtcEngine->leaveChannel();
+ m_isJoined = false;
+}
+
+void CDlg::OnBnClickedButtonJoin() {
+ JoinChannel();
+}
+
+void CDlg::OnBnClickedButtonLeave() {
+ LeaveChannel();
+}
+
+LRESULT CDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) {
+ // Handle engine events
+ return 0;
+}
diff --git a/windows/.agent/skills/upsert-case/references/example-template.h b/windows/.agent/skills/upsert-case/references/example-template.h
new file mode 100644
index 000000000..d84095b53
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/references/example-template.h
@@ -0,0 +1,51 @@
+#pragma once
+#include "AGVideoWnd.h"
+#include
+#include
+
+// Event handler for Agora engine callbacks
+class CRtcEngineEventHandler : public IRtcEngineEventHandler {
+public:
+ void SetMsgReceiver(HWND hWnd) { m_hMsgHandler = hWnd; }
+
+ virtual void onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) override;
+ virtual void onUserJoined(uid_t uid, int elapsed) override;
+ virtual void onUserOffline(uid_t uid, USER_OFFLINE_REASON_TYPE reason) override;
+ virtual void onError(int err) override;
+
+private:
+ HWND m_hMsgHandler = nullptr;
+};
+
+// Main dialog class
+class CDlg : public CDialogEx {
+ DECLARE_DYNAMIC(CDlg)
+
+public:
+ CDlg(CWnd* pParent = nullptr);
+ virtual ~CDlg();
+
+ enum { IDD = IDD_DIALOG_ };
+
+protected:
+ virtual BOOL OnInitDialog();
+ virtual void DoDataExchange(CDataExchange* pDX);
+ virtual void PostNcDestroy();
+
+ afx_msg void OnBnClickedButtonJoin();
+ afx_msg void OnBnClickedButtonLeave();
+ afx_msg LRESULT OnMsgEngineEvent(WPARAM wParam, LPARAM lParam);
+
+ DECLARE_MESSAGE_MAP()
+
+private:
+ void InitializeAgoraEngine();
+ void JoinChannel();
+ void LeaveChannel();
+ void UpdateUI();
+
+ IRtcEngine* m_rtcEngine = nullptr;
+ CRtcEngineEventHandler m_eventHandler;
+ uid_t m_remoteUid = 0;
+ bool m_isJoined = false;
+};
diff --git a/windows/.agent/skills/upsert-case/references/lifecycle-pattern.cpp b/windows/.agent/skills/upsert-case/references/lifecycle-pattern.cpp
new file mode 100644
index 000000000..506c9a003
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/references/lifecycle-pattern.cpp
@@ -0,0 +1,18 @@
+// Proper Engine Lifecycle Pattern
+
+// Create
+m_rtcEngine = createAgoraRtcEngine();
+
+// Initialize
+RtcEngineContext context;
+context.appId = CConfig::GetAppId();
+context.eventHandler = &m_eventHandler;
+m_rtcEngine->initialize(context);
+
+// Use
+m_rtcEngine->joinChannel(token, channelName, "", 0);
+
+// Cleanup
+m_rtcEngine->leaveChannel();
+m_rtcEngine->release();
+m_rtcEngine = nullptr;
diff --git a/windows/.agent/skills/upsert-case/references/message-map-pattern.cpp b/windows/.agent/skills/upsert-case/references/message-map-pattern.cpp
new file mode 100644
index 000000000..211e774c9
--- /dev/null
+++ b/windows/.agent/skills/upsert-case/references/message-map-pattern.cpp
@@ -0,0 +1,17 @@
+// Message Map Pattern - Thread-safe UI updates
+
+// Header
+BEGIN_MESSAGE_MAP(CExampleDlg, CDialogEx)
+ ON_BN_CLICKED(IDC_BUTTON_JOIN, &CExampleDlg::OnBnClickedButtonJoin)
+ ON_MESSAGE(WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), &CExampleDlg::OnMsgEngineEvent)
+END_MESSAGE_MAP()
+
+// Implementation
+void CExampleDlg::OnBnClickedButtonJoin() {
+ JoinChannel();
+}
+
+LRESULT CExampleDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) {
+ // Handle engine events on main thread
+ return 0;
+}
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.