From ba24734b548703f957ce35e4f461636787e5abc1 Mon Sep 17 00:00:00 2001 From: HeZhengQing Date: Fri, 3 Apr 2026 18:00:17 +0800 Subject: [PATCH 1/4] docs: sync markdown changes from dev/ai_improve --- AGENTS.md | 56 +++ Android/AGENTS.md | 30 ++ .../.agent/skills/query-cases/SKILL.md | 114 +++++ .../.agent/skills/review-case/SKILL.md | 57 +++ .../.agent/skills/upsert-case/SKILL.md | 231 +++++++++ Android/APIExample-Audio/AGENTS.md | 38 ++ Android/APIExample-Audio/ARCHITECTURE.md | 140 ++++++ Android/APIExample-Audio/CLAUDE.md | 5 + Android/APIExample-Audio/README.md | 15 +- Android/APIExample-Audio/README.zh.md | 16 +- .../.agent/skills/query-cases/SKILL.md | 117 +++++ .../.agent/skills/review-case/SKILL.md | 48 ++ .../.agent/skills/upsert-case/SKILL.md | 185 +++++++ Android/APIExample-Compose/AGENTS.md | 38 ++ Android/APIExample-Compose/ARCHITECTURE.md | 197 ++++++++ Android/APIExample-Compose/CLAUDE.md | 5 + Android/APIExample-Compose/README.md | 7 +- Android/APIExample-Compose/README.zh.md | 7 +- .../.agent/skills/query-cases/SKILL.md | 110 +++++ .../.agent/skills/review-case/SKILL.md | 52 ++ .../.agent/skills/upsert-case/SKILL.md | 342 +++++++++++++ Android/APIExample/AGENTS.md | 49 ++ Android/APIExample/ARCHITECTURE.md | 215 ++++++++ Android/APIExample/CLAUDE.md | 5 + Android/APIExample/README.md | 28 +- Android/APIExample/README.zh.md | 28 +- Android/ARCHITECTURE.md | 38 ++ Android/CLAUDE.md | 5 + CLAUDE.md | 5 + iOS/AGENTS.md | 38 ++ .../.agent/skills/query-cases/SKILL.md | 47 ++ .../.agent/skills/review-case/SKILL.md | 131 +++++ .../.agent/skills/upsert-case/SKILL.md | 158 ++++++ iOS/APIExample-Audio/AGENTS.md | 32 ++ iOS/APIExample-Audio/ARCHITECTURE.md | 131 +++++ iOS/APIExample-Audio/CLAUDE.md | 5 + .../.agent/skills/query-cases/SKILL.md | 50 ++ .../.agent/skills/review-case/SKILL.md | 158 ++++++ .../.agent/skills/upsert-case/SKILL.md | 173 +++++++ iOS/APIExample-OC/AGENTS.md | 37 ++ iOS/APIExample-OC/ARCHITECTURE.md | 164 +++++++ iOS/APIExample-OC/CLAUDE.md | 5 + .../.agent/skills/query-cases/SKILL.md | 50 ++ .../.agent/skills/review-case/SKILL.md | 140 ++++++ .../.agent/skills/upsert-case/SKILL.md | 163 +++++++ iOS/APIExample-SwiftUI/AGENTS.md | 32 ++ iOS/APIExample-SwiftUI/ARCHITECTURE.md | 185 +++++++ iOS/APIExample-SwiftUI/CLAUDE.md | 5 + .../.agent/skills/query-cases/SKILL.md | 51 ++ .../.agent/skills/review-case/SKILL.md | 154 ++++++ .../.agent/skills/upsert-case/SKILL.md | 185 +++++++ iOS/APIExample/AGENTS.md | 32 ++ iOS/APIExample/ARCHITECTURE.md | 209 ++++++++ iOS/APIExample/CLAUDE.md | 5 + iOS/ARCHITECTURE.md | 49 ++ iOS/CLAUDE.md | 5 + macOS/.agent/skills/review-case/SKILL.md | 380 +++++++++++++++ macOS/.agent/skills/upsert-case/SKILL.md | 185 +++++++ macOS/AGENTS.md | 69 +++ macOS/ARCHITECTURE.md | 155 ++++++ macOS/CLAUDE.md | 5 + windows/.agent/skills/review-case/SKILL.md | 461 ++++++++++++++++++ windows/.agent/skills/upsert-case/SKILL.md | 213 ++++++++ windows/AGENTS.md | 70 +++ windows/ARCHITECTURE.md | 172 +++++++ windows/CLAUDE.md | 5 + 66 files changed, 6223 insertions(+), 69 deletions(-) create mode 100644 AGENTS.md create mode 100644 Android/AGENTS.md create mode 100644 Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md create mode 100644 Android/APIExample-Audio/.agent/skills/review-case/SKILL.md create mode 100644 Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md create mode 100644 Android/APIExample-Audio/AGENTS.md create mode 100644 Android/APIExample-Audio/ARCHITECTURE.md create mode 100644 Android/APIExample-Audio/CLAUDE.md create mode 100644 Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md create mode 100644 Android/APIExample-Compose/.agent/skills/review-case/SKILL.md create mode 100644 Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md create mode 100644 Android/APIExample-Compose/AGENTS.md create mode 100644 Android/APIExample-Compose/ARCHITECTURE.md create mode 100644 Android/APIExample-Compose/CLAUDE.md create mode 100644 Android/APIExample/.agent/skills/query-cases/SKILL.md create mode 100644 Android/APIExample/.agent/skills/review-case/SKILL.md create mode 100644 Android/APIExample/.agent/skills/upsert-case/SKILL.md create mode 100644 Android/APIExample/AGENTS.md create mode 100644 Android/APIExample/ARCHITECTURE.md create mode 100644 Android/APIExample/CLAUDE.md create mode 100644 Android/ARCHITECTURE.md create mode 100644 Android/CLAUDE.md create mode 100644 CLAUDE.md create mode 100644 iOS/AGENTS.md create mode 100644 iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md create mode 100644 iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md create mode 100644 iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md create mode 100644 iOS/APIExample-Audio/AGENTS.md create mode 100644 iOS/APIExample-Audio/ARCHITECTURE.md create mode 100644 iOS/APIExample-Audio/CLAUDE.md create mode 100644 iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md create mode 100644 iOS/APIExample-OC/.agent/skills/review-case/SKILL.md create mode 100644 iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md create mode 100644 iOS/APIExample-OC/AGENTS.md create mode 100644 iOS/APIExample-OC/ARCHITECTURE.md create mode 100644 iOS/APIExample-OC/CLAUDE.md create mode 100644 iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md create mode 100644 iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md create mode 100644 iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md create mode 100644 iOS/APIExample-SwiftUI/AGENTS.md create mode 100644 iOS/APIExample-SwiftUI/ARCHITECTURE.md create mode 100644 iOS/APIExample-SwiftUI/CLAUDE.md create mode 100644 iOS/APIExample/.agent/skills/query-cases/SKILL.md create mode 100644 iOS/APIExample/.agent/skills/review-case/SKILL.md create mode 100644 iOS/APIExample/.agent/skills/upsert-case/SKILL.md create mode 100644 iOS/APIExample/AGENTS.md create mode 100644 iOS/APIExample/ARCHITECTURE.md create mode 100644 iOS/APIExample/CLAUDE.md create mode 100644 iOS/ARCHITECTURE.md create mode 100644 iOS/CLAUDE.md create mode 100644 macOS/.agent/skills/review-case/SKILL.md create mode 100644 macOS/.agent/skills/upsert-case/SKILL.md create mode 100644 macOS/AGENTS.md create mode 100644 macOS/ARCHITECTURE.md create mode 100644 macOS/CLAUDE.md create mode 100644 windows/.agent/skills/review-case/SKILL.md create mode 100644 windows/.agent/skills/upsert-case/SKILL.md create mode 100644 windows/AGENTS.md create mode 100644 windows/ARCHITECTURE.md create mode 100644 windows/CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..af39f90f7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# AGENTS.md + +Entry point for AI agents working on the Agora RTC Native SDK API-Examples repository. +Read this file first, then navigate to the relevant platform directory. + +## Repository Overview + +This repository contains sample projects demonstrating Agora RTC Native SDK APIs across four independent platforms. Each platform is self-contained — do not share source files, build scripts, or dependencies across platforms. + +| Platform | Language(s) | Directory | SDK | +|----------|-------------|-----------|-----| +| Android | Java / Kotlin | `Android/` | RTC Java SDK (full / voice) | +| iOS | Swift / Objective-C | `iOS/` | RTC Objective-C SDK (full / audio) | +| macOS | Swift | `macOS/` | RTC Objective-C SDK (full) | +| Windows | C++ | `windows/` | RTC C++ SDK (full) | + +## Navigation + +Each platform directory contains its own `AGENTS.md` with platform-specific rules, project selection guidance, and architecture constraints. Always read the platform-level `AGENTS.md` before making any changes. + +| Platform | Entry Point | +|----------|-------------| +| Android | `Android/AGENTS.md` | +| iOS | `iOS/AGENTS.md` | +| macOS | `macOS/AGENTS.md` | +| Windows | `windows/AGENTS.md` | + +## Cross-Platform Rules + +1. Never share source files, build scripts, or SDK dependencies between platforms. +2. Each platform manages its own SDK version — check the platform-level config file before assuming a version. +3. All examples follow the same structural pattern within their platform: one self-contained class per API feature, managing its own engine lifecycle. +4. Always call the SDK's leave-channel and destroy APIs when an example screen is closed. +5. SDK event/delegate callbacks may arrive on a background thread — always dispatch UI updates to the main thread. + +## Repository-Level Files + +| File | Purpose | +|------|---------| +| `HOOKS-GUIDE.md` | Git hook installation (sensitive-info detection, commit-message rules) | +| `.pre-commit-config.yaml` | Pre-commit hook configuration | +| `.gitleaks.toml` | Gitleaks allowlist configuration | +| `azure-pipelines.yml` | CI/CD pipeline definition | + +## Git Hooks + +This repository enforces two rules via Git hooks: +- No sensitive information (API keys, tokens) in committed code. +- Commit messages must be in English only (no Chinese characters). + +Run `.git-hooks/install-hooks.sh` once after cloning to activate the hooks. +See `HOOKS-GUIDE.md` for details and troubleshooting. + +## Sensitive Configuration + +API keys and App IDs are never committed. Each platform stores them in a `KeyCenter` file (Swift/OC) or `KeyCenter.java` / `KeyCenter.kt` (Android) or `CConfig` (Windows). These files are git-ignored and must be populated locally before building. diff --git a/Android/AGENTS.md b/Android/AGENTS.md new file mode 100644 index 000000000..77d601ec8 --- /dev/null +++ b/Android/AGENTS.md @@ -0,0 +1,30 @@ +# AGENTS.md — Android + +Android platform entry point. Read this first, then go to the relevant project's `AGENTS.md`. + +## Projects + +| Project | SDK | Language | Purpose | +|---------|-----|----------|---------| +| `APIExample/` | full-sdk | Java / Kotlin + XML | All APIs — default choice | +| `APIExample-Audio/` | voice-sdk | Java + XML | Audio-only — no video APIs | +| `APIExample-Compose/` | full-sdk | Kotlin + Compose | Compose UI, mirrors APIExample cases | + +SDK version: each project's `gradle.properties` → `rtc_sdk_version` (currently `4.6.3`) + +## Project Selection + +- Video / screen sharing / beauty / extensions → `APIExample/` +- Audio-only → `APIExample-Audio/` +- Compose UI or porting an existing case → `APIExample-Compose/` +- No project specified → default to `APIExample/` + +Never share source files between projects. + +## Navigation + +| Project | Entry Point | +|---------|-------------| +| `APIExample/` | `APIExample/AGENTS.md` | +| `APIExample-Audio/` | `APIExample-Audio/AGENTS.md` | +| `APIExample-Compose/` | `APIExample-Compose/AGENTS.md` | diff --git a/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md b/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..4636e2b71 --- /dev/null +++ b/Android/APIExample-Audio/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,114 @@ +--- +name: query-cases +description: > + Query and browse existing API example cases in the APIExample-Audio Android demo — + lists cases by group, finds which case demonstrates a specific Agora audio API, + checks sort index availability, and resolves display names from string resources. + Use when: someone asks what cases exist, which audio APIs are demonstrated, wants + to find a case by name or API (e.g. setVoiceBeautifierPreset, enableSpatialAudio), + needs a free sort index before adding a new case, or wants to know if an audio + feature is already implemented. This project uses voice-sdk — no video APIs. + Keywords: list cases, find case, query cases, @Example, sort index, BASIC, ADVANCED, + available cases, existing cases, which case, is there a case, audio case. +--- + +# Query Cases — APIExample-Audio + +## How cases are registered + +Every case is a Fragment under `app/src/main/java/io/agora/api/example/examples/{basic|advanced|audio}/` with an `@Example` annotation: + +```java +@Example( + index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+ + group = ADVANCED, + name = R.string.item_xxx, + actionId = R.id.action_mainFragment_to_xxx, + tipsId = R.string.xxx_tips +) +``` + +A commented-out `@Example` (`//@Example`) means the case is disabled and won't appear in the app. + +This project uses `voice-sdk` — all cases are audio-only, no video APIs exist. + +--- + +## Query procedure + +### Step 1: Decide scope before scanning + +Before listing files, ask: +- **Looking for a specific API?** — scan Javadoc comments for the API name; no need to read all files +- **Need a free sort index?** — collect all `index` values for the target group, then find the gap +- **Listing all cases?** — scan all three directories and collect annotations + +### Step 2: Read ARCHITECTURE.md first + +Read `ARCHITECTURE.md` (the `examples/` section of the Directory Layout). It contains a pre-built index of all cases with group, index, display name, and key API — no file scanning needed for most queries. + +Use ARCHITECTURE.md as the primary source. Fall back to scanning the source directories only when: +- The query requires data not in ARCHITECTURE.md (e.g. full `@Example` field values, `tipsId`) +- ARCHITECTURE.md appears stale (a case exists in source but not in the doc) +- The output involves free-index claims, index collisions, or "is index X available?" decisions — these must be validated from source immediately before final output + +### Step 3: Scan case directories (fallback only) + +| Directory | Group | Contents | +|-----------|-------|----------| +| `examples/basic/` | BASIC | Core audio join/leave patterns | +| `examples/advanced/` | ADVANCED | Feature-specific audio APIs | +| `examples/audio/` | ADVANCED | Audio visualization (still grouped ADVANCED) | + +Each `.java` file is a case. Subdirectories (e.g. `customaudio/`) contain multi-file cases — the main class is the file whose name matches the directory name; if no name match, look for the file containing `@Example`. + +### Step 4: Extract `@Example` fields + +For each file, read the annotation for `group`, `index`, `name` (string resource ID), and `tipsId`. If the annotation is commented out, the case is disabled. + +Resolve display names from `app/src/main/res/values/strings.xml`: +`R.string.item_voice_effects` → `Voice Effects` + +### Step 5: Read class Javadoc for API mapping + +The Javadoc above each class lists the key APIs demonstrated: + +```java +/** + * This demo demonstrates how to apply voice beautifier effects. + * + * Key APIs used: + * - RtcEngine.setVoiceBeautifierPreset() + */ +``` + +Use this to answer "which case uses X?" queries without reading the full implementation. + +If no Javadoc is present, scan the method body for the API name as a method call. If still not found, note "API mapping unavailable" in the results table. + +### Step 6: Present results + +Full listing — table format: + +| Group | Index | Case Name | File | Key APIs | +|-------|-------|-----------|------|----------| +| BASIC | 0 | Join Channel Audio | JoinChannelAudio.java | joinChannel() | +| ADVANCED | 4 | Voice Effects | VoiceEffects.java | setVoiceBeautifierPreset() | + +For a specific query (e.g. "which case uses enableSpatialAudio?"), return only matching rows. + +For a free-index query, list all used indices in the target group and identify the next available slot: +> BASIC range: 0–9. ADVANCED range: 10+. +> ADVANCED indices in use: 10, 11, 12, 15, 20 → next free: 13 + +Before returning any free-index/collision result, re-scan source registration points (`@Example` across `basic/`, `advanced/`, `audio/`) and recompute once from source-of-truth data. + +--- + +## NEVER + +- **NEVER** count a commented-out `@Example` (`//@Example`) as an active case — it is disabled and won't appear in the app. +- **NEVER** mix index spaces across groups — `audio/` cases use `group=ADVANCED` but share the same index namespace as `advanced/`; always scan both directories together when finding a free index. +- **NEVER** use filename alone to identify a subdirectory case — the main class is the file whose name matches the directory name; if no match, look for the file with `@Example`. +- **NEVER** report a free index without scanning all three directories (`basic/`, `advanced/`, `audio/`) for the target group — missing one causes index collisions. +- **NEVER** suggest video APIs — this project uses voice-sdk only; video APIs do not exist. diff --git a/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md b/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..ba517c3ea --- /dev/null +++ b/Android/APIExample-Audio/.agent/skills/review-case/SKILL.md @@ -0,0 +1,57 @@ +--- +name: review-case +description: > + Review an existing case implementation against project-specific red lines + and coding standards. Use after implementing or modifying a case. + Use when: reviewing a case for correctness, checking red-line compliance, + verifying lifecycle and threading patterns, auditing an existing Fragment. + Keywords: review, audit, check, red lines, lifecycle, threading, compliance. +--- + +# Review Case — APIExample-Audio + +Run through every item below before considering a case implementation complete. +Open the case's Fragment source file and verify each point against the actual code. + +## Checklist + +### Teardown & Lifecycle + +- [ ] **leaveChannel before destroy** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the teardown path (typically `onDestroy()`). Destroying without leaving first leaks the channel session on the server side. + +- [ ] **handler.post for destroy** — `RtcEngine.destroy()` is invoked via `handler.post(RtcEngine::destroy)` and **not** called directly on the main thread. A direct call blocks the UI thread and causes ANR. + +### Threading + +- [ ] **runOnUIThread for callbacks** — All `IRtcEngineEventHandler` callbacks that update UI are wrapped with `runOnUIThread()`. SDK callbacks arrive on a background thread; touching Views without dispatching to the main thread causes crashes or silent rendering corruption. + +### Permissions + +- [ ] **Permission check before join** — `checkOrRequestPermission()` is called before `joinChannel()`. Joining without the required permissions (RECORD_AUDIO) causes a silent failure — no error callback, just no audio. + +### Backend Reporting + +- [ ] **setParameters present** — `setParameters(...)` is called during engine initialisation. This is required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally. + +### Private Cloud + +- [ ] **getPrivateCloudConfig null-check** — `getPrivateCloudConfig()` is null-checked before `setLocalAccessPoint()` is called. The method returns `null` on standard (non-private-cloud) builds, so calling `setLocalAccessPoint()` without the guard causes a NullPointerException. + +### Audio-Only Constraint + +- [ ] **No video APIs** — The case must not call `enableVideo()`, `setupLocalVideo()`, or reference `VideoCanvas`. APIExample-Audio uses the voice-SDK which has no video module; calling video APIs causes a compile error or runtime crash. + +## If a Check Fails + +- Teardown order wrong (`destroy` before `leaveChannel`) — fix teardown to `leaveChannel()` first, then `handler.post(RtcEngine::destroy)`, and re-test back navigation. +- UI touched in SDK callback without main-thread dispatch — wrap UI updates in `runOnUIThread()` and re-run to verify no thread exceptions. +- Permission flow missing before `joinChannel()` — add `checkOrRequestPermission()` gate and verify join only after `RECORD_AUDIO` is granted. +- Any video API appears in code — remove all video API calls/usages immediately and replace with audio-only equivalents. +- Missing `setParameters(...)` or private-cloud null-check — add both safeguards in engine init and re-run initialization. + +## NEVER + +- **NEVER** approve a case review if any video API (`enableVideo`, `setupLocalVideo`, `VideoCanvas`) exists in APIExample-Audio. +- **NEVER** approve a case review with direct `RtcEngine.destroy()` on main thread. +- **NEVER** approve a case review when `leaveChannel()` is missing before destroy. +- **NEVER** ignore background-thread UI updates inside `IRtcEngineEventHandler` callbacks. diff --git a/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md b/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..9cb3d65e2 --- /dev/null +++ b/Android/APIExample-Audio/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,231 @@ +--- +name: upsert-case +description: > + Add a new audio API example case or modify an existing one in the APIExample-Audio Android demo — + creates or updates Fragment class, XML layout, string resources, and nav_graph registration. + Use when: adding a new Agora audio API demo screen, modifying an existing case's implementation + or registration, implementing a new audio feature example in Java + XML layouts, registering a new + case via @Example annotation, subclassing BaseFragment for a new audio demo screen, or updating + an existing case's strings, layout, or nav entry. + This project uses voice-sdk — no video APIs available. + Keywords: add case, modify case, update case, new fragment, nav_graph, @Example, BaseFragment, + APIExample-Audio, audio case, voice-sdk, new screen, audio demo, upsert case. +--- + +# Upsert Case — APIExample-Audio + +## Adding a New Case + +Touch exactly 4 files (all paths relative to `app/src/main/`): + +| File | What to add | +|---|---| +| `java/.../examples/{basic\|advanced\|audio}/YourCaseName.java` | Fragment class | +| `res/layout/fragment_your_case_name.xml` | XML layout | +| `res/values/strings.xml` | 2 strings | +| `res/navigation/nav_graph.xml` | 1 action + 1 destination | + +Registration is automatic via reflection — no other files needed. + +**voice-sdk constraint**: Do NOT call `enableVideo()`, `setupLocalVideo()`, `VideoCanvas`, or any video API — the module does not exist and will crash at runtime. + +--- + +### Step 1: Clarify before coding + +Before writing a single line, ask: +- **What audio API am I demonstrating?** — determines which existing case is the closest reference to copy patterns from +- **BASIC or ADVANCED group?** — BASIC for fundamental join/leave audio patterns; ADVANCED for feature-specific audio APIs +- **What's the sort index?** — index must be unique within the group. BASIC uses 0–9, ADVANCED starts from 10. Run `query-cases` skill first; a collision causes silent ordering bugs at runtime +- **Any special permissions beyond `RECORD_AUDIO`?** — most audio cases only need `RECORD_AUDIO`; check if the API requires anything else + +--- + +### Step 2: Create the Fragment + +**MANDATORY — READ ENTIRE FILE before writing any code**: +[`references/fragment-template.java`](references/fragment-template.java) + +Do NOT skip — the `setParameters`, `handler.post`, `getPrivateCloudConfig()` null-check, `AudioSeatManager` wiring, and voice-sdk constraints are only fully shown there and are required in every case. + +**Do NOT load** any other reference files for this task. + +Non-obvious points the template highlights: + +- `setParameters(...)` for app scenario reporting — **required in every case**, do not remove +- `handler.post(RtcEngine::destroy)` — NOT `RtcEngine.destroy()` directly; direct call blocks UI thread (ANR) +- `getPrivateCloudConfig()` null-check before `setLocalAccessPoint()` — returns null on non-private-cloud builds (NPE) +- All `IRtcEngineEventHandler` callbacks run on a **background thread** — always `runOnUIThread()` for UI +- `onActivityCreated` → create engine; `onDestroy` → `leaveChannel()` then `handler.post(RtcEngine::destroy)` +- `ChannelMediaOptions` must NOT set `publishCameraTrack` or `autoSubscribeVideo` — voice-sdk has no video module +- Use `AudioSeatManager` (not `VideoReportLayout`) to visualize remote participants + +--- + +### Step 3: Create the XML layout + +Typical audio layout — channel input + join button + audio controls: + +```xml + + + + + + + + + + + + +``` + +For waveform visualization, copy the `WaveformView` pattern from `fragment_join_channel_audio.xml`. + +--- + +### Step 4: Add nav entries + +File: `res/navigation/nav_graph.xml` + +**Action** — inside `` (NOT mainFragment — mainFragment only has one action, to Ready): + +```xml + +``` + +**Destination** — at root `` level: + +```xml + +``` + +`action android:id` must exactly match `actionId` in `@Example`. + +--- + +### Step 5: Update ARCHITECTURE.md + +Add one line to the case list in `ARCHITECTURE.md` under the correct directory section (`basic/`, `advanced/`, or `audio/`): + +``` +├── YourCaseName.java # [index] "Display Name" — key API description +``` + +Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans. + +--- + +## Modifying an Existing Case + +When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating: + +| What changed | Files to touch | +|---|---| +| Implementation logic (API calls, event handling) | `java/.../examples/{basic\|advanced\|audio}/CaseName.java` | +| UI layout (views, controls) | `res/layout/fragment_case_name.xml` | +| Display name or tips text | `res/values/strings.xml` | +| Sort index or group (BASIC ↔ ADVANCED) | `@Example` annotation in the Fragment class | +| Navigation label | `res/navigation/nav_graph.xml` (fragment label attribute) | +| Class rename or package move | Fragment class, `nav_graph.xml` (android:name + destination id), `@Example` annotation (actionId), layout file name, `ARCHITECTURE.md` | + +After making changes: + +1. **Verify `@Example` annotation consistency** — ensure `index`, `group`, `name`, `actionId`, and `tipsId` still match the actual string resources, nav action ID, and intended group/position. A mismatch causes the case to silently disappear from the list or navigate to the wrong screen. +2. **Update `res/values/strings.xml`** if the display name or tips text changed. +3. **Update `res/navigation/nav_graph.xml`** if the class name, package, or label changed. +4. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description. + +--- + +## Verify + +```bash +./gradlew assembleDebug +``` + +- [ ] Case appears in correct group at expected sort position +- [ ] Tap navigates to the case screen (silent failure = nav action in wrong fragment) +- [ ] `onJoinChannelSuccess` fires in Logcat +- [ ] After pressing back, check Logcat for `RtcEngine.destroy` within ~2 seconds — if missing, there is a lifecycle bug in `onDestroy` +- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description +- [ ] `@Example` annotation fields (`index`, `group`, `name`, `actionId`, `tipsId`) are consistent with string resources and nav_graph entries + +--- + +## When to Use a Spec Instead + +If the case meets any of the following criteria, create a Spec rather than using this skill directly: + +1. Involves coordinated calls across two or more Agora API modules +2. Requires a custom UI layout (not one of the standard templates above) +3. Manages multiple channels or multiple engine instances +4. Requires a foreground Service or background thread coordination +5. Involves developing new shared components (widget/utils, etc.) +6. Requires optional module integration (e.g. streamEncrypt) + +If none apply → use this skill directly; no Spec needed. + +### Spec Requirements Document Must Include + +- List of APIs the case demonstrates (audio APIs only) +- User interaction flow description +- Expected RtcEngine lifecycle behavior +- Required permissions (typically only `RECORD_AUDIO`) + +### Spec Design Document Must Include + +- Target project identifier: `APIExample-Audio` +- Class/file structure design +- API call sequence (Mermaid sequence diagram recommended) +- State management approach +- UI layout plan +- Integration points with existing shared components +- Case registration info: class name, display name, group (BASIC/ADVANCED), sort index — finalize during design to avoid conflicts +- Generate `@Example` annotation parameters, `nav_graph.xml` action + destination, `strings.xml` key names (`item_` prefix) +- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing indices +- voice-sdk checks: no video APIs (`enableVideo`, `setupLocalVideo`, `setupRemoteVideo`, `VideoCanvas`, `startScreenCapture`) — violations must be eliminated at design time +- Risk identification and mitigation (API availability, permissions, thread safety, performance) + +### Spec Task List Integration + +- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters +- Mark which sub-tasks require manual coding, and provide target file paths and change summaries +- New shared component creation tasks must come before case implementation tasks + +--- + +## NEVER + +- **NEVER** call any video API (`enableVideo`, `setupLocalVideo`, `VideoCanvas`) — voice-sdk has no video module; crash is immediate. +- **NEVER** put the nav action inside `` — it belongs in ``. mainFragment only routes to Ready; all case actions live in Ready. Wrong placement causes silent navigation failure at runtime. +- **NEVER** call `RtcEngine.destroy()` directly on the main thread — always `handler.post(RtcEngine::destroy)`. Direct call blocks the UI thread and causes ANR. +- **NEVER** call `setLocalAccessPoint()` without null-checking `getPrivateCloudConfig()` first — it returns null on standard builds, causing NPE. +- **NEVER** update UI directly inside `IRtcEngineEventHandler` callbacks — they run on a background thread. Always wrap with `runOnUIThread()`. +- **NEVER** omit `setParameters(...)` — it's required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally. diff --git a/Android/APIExample-Audio/AGENTS.md b/Android/APIExample-Audio/AGENTS.md new file mode 100644 index 000000000..092f45f59 --- /dev/null +++ b/Android/APIExample-Audio/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md — APIExample-Audio + +Audio-only demo project. Uses `voice-sdk` — the video module is not available. +Use this project for audio-only features. + +## Build Commands + +```bash +./gradlew assembleDebug # build debug APK +./gradlew installDebug # build + install to connected device +./gradlew test # unit tests +./gradlew connectedAndroidTest # instrumented tests (device required) +``` + +## App ID Configuration + +See [README.md — Obtain an App Id](README.md#obtain-an-app-id). + +## Architecture Red Lines + +- Do NOT call `enableVideo()`, `setupLocalVideo()`, or `VideoCanvas` — `voice-sdk` has no video module and will crash at runtime. +- Do NOT add video, screen sharing, or beauty cases — use `APIExample/` instead. +- Each case Fragment must create and destroy its own `RtcEngine` instance. +- Always call `engine.leaveChannel()` before `RtcEngine.destroy()` in `onDestroy()`. +- All `IRtcEngineEventHandler` callbacks run on a background thread — use `handler.post {}` for UI updates. +- Always call `checkOrRequestPermission()` before `joinChannel()`. Audio cases only need `RECORD_AUDIO`. + +## Skills + +| Skill | Path | Description | +|-------|------|-------------| +| upsert-case | `.agent/skills/upsert-case/` | Add a new audio case or modify an existing one | +| query-cases | `.agent/skills/query-cases/` | Query and browse existing audio cases | +| review-case | `.agent/skills/review-case/` | Review a case against project red lines | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout and case registration details diff --git a/Android/APIExample-Audio/ARCHITECTURE.md b/Android/APIExample-Audio/ARCHITECTURE.md new file mode 100644 index 000000000..1aab8f934 --- /dev/null +++ b/Android/APIExample-Audio/ARCHITECTURE.md @@ -0,0 +1,140 @@ +# ARCHITECTURE.md — APIExample-Audio + +## Directory Layout + +``` +APIExample-Audio/ +├── gradle.properties # rtc_sdk_version +└── app/src/main/ + ├── AndroidManifest.xml + ├── assets/ # Audio sample files + ├── res/ + │ ├── navigation/nav_graph.xml # Single nav graph — all case destinations live here + │ ├── values/strings.xml # All display names and tips strings + │ └── layout/ # XML layouts for each case Fragment + └── java/io/agora/api/example/ + ├── MainApplication.java # Scans DEX and registers all @Example cases at startup + ├── MainActivity.java # Single-Activity host, owns NavController + ├── MainFragment.java # Home screen — renders BASIC / ADVANCED section list + ├── ReadyFragment.java # Splash / config check screen + ├── SettingActivity.java # Global settings (area code, audio profile) + │ + ├── annotation/ + │ └── Example.java # @Example annotation — identical to APIExample + │ + ├── common/ + │ ├── BaseFragment.java # Base class ALL case Fragments must extend + │ ├── Constant.java # App-wide constants + │ ├── adapter/ + │ │ └── SectionAdapter.java # RecyclerView adapter for the grouped case list + │ ├── model/ + │ │ ├── Examples.java # Static registry: ITEM_MAP keyed by group name + │ │ ├── GlobalSettings.java # Audio config shared across cases + │ │ ├── ExampleBean.java + │ │ ├── Peer.java + │ │ └── StatisticsInfo.java + │ ├── widget/ + │ │ ├── AudioOnlyLayout.java # Audio seat layout (no video surface) + │ │ ├── AudioSeatManager.java + │ │ └── WaveformView.java + │ └── gles/ # OpenGL ES helpers (for waveform visualization) + │ + ├── examples/ # All cases live here — ClassUtils scans this package + │ ├── basic/ # group = "BASIC" (index 0–9) + │ │ ├── JoinChannelAudioByToken.java # [0] "Live Interactive Audio Streaming(Token Verify)" + │ │ └── JoinChannelAudio.java # [1] "Live Interactive Audio Streaming" + │ ├── advanced/ # group = "ADVANCED" (index 10+) + │ │ ├── VoiceEffects.java # [10] "Set the Voice Beautifier and Effects" — setVoiceBeautifierPreset + │ │ ├── customaudio/CustomAudioSource.java # [11] "Custom Audio Sources" — push external audio + │ │ ├── customaudio/CustomAudioRender.java # [12] "Custom Audio Render" — pull audio for custom rendering + │ │ ├── customaudio/AudioPlayer.java # helper for CustomAudioRender + │ │ ├── ProcessAudioRawData.java # [13] "Raw Audio Data" — audio raw data processing + │ │ ├── PlayAudioFiles.java # [14] "Play Audio Files" — audio mixing + │ │ ├── PreCallTest.java # [15] "Pre-call Tests" — network/device test before joining + │ │ ├── RhythmPlayer.java # [16] "Rhythm Player" — metronome/rhythm playback + │ │ └── SpatialSound.java # [17] "Spatial Audio" — 3D spatial audio + │ └── audio/ # Audio-specific cases (grouped as ADVANCED) + │ └── AudioWaveform.java # [18] "Audio Waveform" — audio visualization + │ + └── utils/ + ├── ClassUtils.java # DEX scanner — auto-discovers @Example classes + ├── TokenUtils.java # Fetches RTC tokens from Agora token server + ├── PermissonUtils.java # Permission check/request helpers + ├── CommonUtil.java + ├── ErrorUtil.java + ├── FileUtils.java + ├── AudioFileReader.java + └── YUVUtils.java +``` + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| Live Interactive Audio Streaming(Token Verify) | `basic/JoinChannelAudioByToken.java` | `RtcEngine.create()`, `joinChannel()`, `setClientRole()` | Demonstrates audio-only calling with manual App ID and token input | +| Live Interactive Audio Streaming | `basic/JoinChannelAudio.java` | `RtcEngine.create()`, `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `muteLocalAudioStream()`, `enableInEarMonitoring()`, `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()` | Demonstrates audio-only calling with volume controls, in-ear monitoring, and audio routing | +| Set the Voice Beautifier and Effects | `advanced/VoiceEffects.java` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAudioEffectParameters()`, `setLocalVoicePitch()`, `setLocalVoiceEqualization()`, `setLocalVoiceReverb()`, `setLocalVoiceFormant()`, `setAINSMode()`, `enableVoiceAITuner()` | Demonstrates voice beautifier presets, audio effects, voice conversion, and AI noise suppression | +| Custom Audio Sources | `advanced/customaudio/CustomAudioSource.java` | `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `enableCustomAudioLocalPlayback()`, `destroyCustomAudioTrack()` | Demonstrates pushing external audio frames via a custom audio track | +| Custom Audio Render | `advanced/customaudio/CustomAudioRender.java` | `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Demonstrates pulling audio frames for custom audio rendering | +| Raw Audio Data | `advanced/ProcessAudioRawData.java` | `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Demonstrates processing raw audio data through the audio frame observer | +| Play Audio Files | `advanced/PlayAudioFiles.java` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()`, `getAudioEffectManager()`, `adjustAudioMixingVolume()` | Demonstrates audio mixing and sound effect playback | +| Pre-call Tests | `advanced/PreCallTest.java` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()` | Demonstrates network quality probing and echo testing before joining a channel | +| Rhythm Player | `advanced/RhythmPlayer.java` | `startRhythmPlayer()`, `stopRhythmPlayer()`, `enableAudioVolumeIndication()` | Demonstrates metronome/rhythm playback synchronized with audio streaming | +| Spatial Audio | `advanced/SpatialSound.java` | `ILocalSpatialAudioEngine.create()`, `updateSelfPosition()`, `updateRemotePosition()`, `updatePlayerPositionInfo()`, `setZones()`, `createMediaPlayer()` | Demonstrates 3D spatial audio positioning for remote users and media players | +| Audio Waveform | `audio/AudioWaveform.java` | `enableAudio()`, `enableAudioVolumeIndication()` | Demonstrates real-time audio waveform visualization | + +## Case Registration Mechanism + +Identical to `APIExample` — automatic via reflection, no manual list. + +**Startup flow:** +1. `MainApplication.onCreate()` calls `ClassUtils.getFileNameByPackageName(context, "io.agora.api.example.examples")`. +2. `ClassUtils` scans all DEX entries whose class name starts with that prefix. +3. For each class, it checks for `@Example` annotation and calls `Examples.addItem(annotation)`. +4. `Examples.sortItem()` sorts each group by `index`. +5. `MainFragment` reads `Examples.ITEM_MAP` and renders the list. + +**`@Example` annotation — all four fields are required:** +```java +@Example( + index = 2, // sort order within the group; BASIC: 0–9, ADVANCED: 10+ + group = BASIC, // "BASIC" or "ADVANCED" + name = R.string.item_my_case, // display name string resource + actionId = R.id.action_mainFragment_to_myCase, // nav action ID in nav_graph.xml + tipsId = R.string.my_case_tips // description string resource +) +public class MyCase extends BaseFragment { … } +``` + +## Navigation + +Identical to `APIExample` — single `nav_graph.xml` with Jetpack Navigation Component. + +Every case needs: +- A `` destination entry in `nav_graph.xml` +- An `` inside `` +- The action `id` must exactly match `actionId` in `@Example` + +## RtcEngine Lifecycle + +``` +onActivityCreated → RtcEngine.create() (voice-sdk — no video APIs) + → engine.setAudioProfile / setAudioScenario + → joinChannel() (after RECORD_AUDIO permission granted) + ↓ + [IRtcEngineEventHandler callbacks — background thread] + ↓ +onDestroy → engine.leaveChannel() + → RtcEngine.destroy() + → engine = null +``` + +## Token Flow + +```java +TokenUtils.gen(requireContext(), channelId, uid, token -> { + engine.joinChannel(token, channelId, uid, options); +}); +``` + +`TokenUtils` reads `AGORA_APP_ID` and `AGORA_APP_CERT` from `local.properties` via `BuildConfig`. If `AGORA_APP_CERT` is empty, token generation is skipped — valid for projects without certificate. diff --git a/Android/APIExample-Audio/CLAUDE.md b/Android/APIExample-Audio/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/Android/APIExample-Audio/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/Android/APIExample-Audio/README.md b/Android/APIExample-Audio/README.md index 6cbdf868a..f2e1d131a 100644 --- a/Android/APIExample-Audio/README.md +++ b/Android/APIExample-Audio/README.md @@ -22,19 +22,16 @@ To build and run the sample application, get an App Id: 2. Navigate in the Dashboard tree on the left to **Projects** > **Project List**. 3. Save the **App Id** from the Dashboard for later use. 4. Save the **App Certificate** from the Dashboard for later use. -5. Generate a temp **Access Token** (valid for 24 hours) from dashboard page with given channel name, save for later use. - -6. Open `Android/APIExample` and edit the `app/src/main/res/values/string-config.xml` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard, and update `YOUR ACCESS TOKEN` with the temp Access Token generated from dashboard. Note you can leave the token and certificate variable `null` if your project has not turned on security token. +5. Open `Android/APIExample-Audio` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well. ``` - YOUR APP ID - // assign token and certificate to null if you have not enabled app certificate - YOUR APP CERTIFICATE - // assign token and certificate to null if you have not enabled app certificate or you have set the certificate above. - // PS:It is unsafe to place the App Certificate on the client side, it is recommended to place it on the server side to ensure that the App Certificate is not leaked. - YOUR ACCESS TOKEN + sdk.dir=/path/to/Android/sdk + AGORA_APP_ID=YOUR APP ID + AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you prefer to generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the `JoinChannelAudio(Token)` example to paste the token at runtime. + You are all set. Now connect your Android device and run the project. diff --git a/Android/APIExample-Audio/README.zh.md b/Android/APIExample-Audio/README.zh.md index 6828cc087..38204654d 100644 --- a/Android/APIExample-Audio/README.zh.md +++ b/Android/APIExample-Audio/README.zh.md @@ -22,20 +22,16 @@ 2. 前往后台页面,点击左部导航栏的 **项目 > 项目列表** 菜单 3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它 4. 复制后台的 **App Certificate** 并备注,稍后启动应用时会用到它 -5. 在项目页面生成临时 **Access Token** (24小时内有效)并备注,注意生成的Token只能适用于对应的频道名。 - -6. 打开 `Android/APIExample` 并编辑 `app/src/main/res/values/string-config.xml`,将你的 AppID 、App主证书、 临时Token 分别替换到 `Your App Id` 、 `YOUR ACCESS TOKEN` 和 `YOUR APP CERTIFICATE` +5. 打开 `Android/APIExample-Audio` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE` ``` - YOUR APP ID - // 如果你没有打开Token功能,certificate可以直接不填 - YOUR APP CERTIFICATE - // 如果你没有打开Token功能或者已经配置了certificate,token可以直接不填 - // 注意:App证书放在客户端不安全,推荐放在服务端以确保 App 证书不会泄露。 - YOUR ACCESS TOKEN - + sdk.dir=/path/to/Android/sdk + AGORA_APP_ID=YOUR APP ID + AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 `JoinChannelAudio(Token)` 示例在运行时粘贴 token。 + 然后你就可以编译并运行项目了。 ## 联系我们 diff --git a/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md b/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..e4bb11024 --- /dev/null +++ b/Android/APIExample-Compose/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,117 @@ +--- +name: query-cases +description: > + Query and browse existing API example cases in the APIExample-Compose Android demo — + lists cases by group, finds which case demonstrates a specific Agora API, checks list + position availability, and resolves display names from string resources. Use when: + someone asks what Compose cases exist, which APIs are demonstrated, wants to find a + case by name or API (e.g. takeSnapshot, setClientRole), needs to know the current + list position before adding a new case, or wants to know if a feature is already + implemented in Compose. Registration is manual via Examples.kt — no @Example annotation. + Keywords: list cases, find case, query cases, Examples.kt, BasicExampleList, + AdvanceExampleList, available cases, existing cases, which case, is there a case, + Compose case, Jetpack Compose. +--- + +# Query Cases — APIExample-Compose + +## How cases are registered + +Unlike APIExample, this project does NOT use reflection. Cases are manually registered in: + +`app/src/main/java/io/agora/api/example/compose/model/Examples.kt` + +Two lists define the groups: + +```kotlin +val BasicExampleList = listOf( + Example(R.string.example_join_channel_video) { JoinChannelVideo() }, + // … +) + +val AdvanceExampleList = listOf( + Example(R.string.example_live_streaming) { LiveStreaming() }, + // … +) +``` + +List position is display order — there is no `index` field. String keys use the `example_` prefix. + +--- + +## Query procedure + +### Step 1: Decide scope before scanning + +Before reading files, ask: +- **Looking for a specific API?** — read Composable KDoc comments for the API name; no need to read all files +- **Need to know current list positions?** — read Examples.kt only; positions are 1-based list indices +- **Listing all cases?** — read Examples.kt for the full registry, then resolve names from strings.xml + +### Step 2: Read ARCHITECTURE.md first + +Read `ARCHITECTURE.md` (the `samples/` section of the Directory Layout). It contains a pre-built index of all cases with group, position, display name, and key API — no file scanning needed for most queries. + +Use ARCHITECTURE.md as the primary source. Fall back to reading Examples.kt only when: +- The query requires data not in ARCHITECTURE.md (e.g. exact list position, `description` field) +- ARCHITECTURE.md appears stale (a case exists in Examples.kt but not in the doc) +- The output involves list position availability, duplicate registration checks, or "is this case already registered?" decisions — these must be validated from `Examples.kt` immediately before final output + +### Step 3: Read Examples.kt (fallback / position queries) + +File: `app/src/main/java/io/agora/api/example/compose/model/Examples.kt` + +Parse `BasicExampleList` and `AdvanceExampleList`. Each entry is: + +```kotlin +Example(R.string.example_your_case_name) { YourCaseName() } +``` + +Position in the list (1-based) is the display order. There is no `index` field and no disabled/commented-out mechanism equivalent to `//@Example`. + +### Step 4: Resolve display names + +Resolve `R.string.example_*` from `app/src/main/res/values/strings.xml`: +`R.string.example_video_snapshot` → `Video Snapshot` + +### Step 5: Read Composable KDoc for API mapping + +Case implementations are in `app/src/main/java/io/agora/api/example/compose/samples/`. The KDoc above each public Composable lists key APIs: + +```kotlin +/** + * Demonstrates how to take a snapshot of the local video stream. + * + * Key APIs used: + * - RtcEngine.takeSnapshot() + */ +@Composable +fun VideoSnapshot() { … } +``` + +Use this to answer "which case uses X?" without reading the full implementation. If no KDoc, scan the function body for the API name. + +### Step 6: Present results + +Full listing — table format: + +| Group | Position | Case Name | File | Key APIs | +|-------|----------|-----------|------|----------| +| Basic | 1 | Join Channel Video | JoinChannelVideo.kt | joinChannel(), setupLocalVideo() | +| Advanced | 3 | Video Snapshot | VideoSnapshot.kt | takeSnapshot() | + +For a specific query, return only matching rows. + +For a position query, list current entries in the target list and identify the next available slot: +> AdvanceExampleList has 12 entries → next position: 13 (append at end) + +Before returning any position/registration-conflict result, re-read `Examples.kt` and recompute from the current list entries. + +--- + +## NEVER + +- **NEVER** look for `@Example` annotations — this project uses manual registration in Examples.kt, not reflection. +- **NEVER** treat list position as a unique ID that must be gap-free — position is just list order; new cases always append at the end of the appropriate list. +- **NEVER** use the `item_` string prefix — Compose cases use `example_` prefix; `item_` belongs to APIExample. +- **NEVER** scan `nav_graph.xml` for case registration — Compose navigation is position-based and requires no nav graph changes. diff --git a/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md b/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..fd0b84bad --- /dev/null +++ b/Android/APIExample-Compose/.agent/skills/review-case/SKILL.md @@ -0,0 +1,48 @@ +--- +name: review-case +description: > + Review an existing case implementation against project-specific red lines + and coding standards. Use after implementing or modifying a case. + Use when: reviewing a Compose case for correctness, checking red-line compliance, + verifying lifecycle and state patterns, auditing an existing Composable. + Keywords: review, audit, check, red lines, lifecycle, state, compliance, Compose. +--- + +# Review Case — APIExample-Compose + +Run through every item below before considering a case implementation complete. +Open the case's Composable source file and verify each point against the actual code. + +## Checklist + +### Teardown & Lifecycle + +- [ ] **leaveChannel before destroy in onDispose** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the `onDispose` block. Destroying without leaving first leaks the channel session on the server side. + +- [ ] **DisposableEffect key is lifecycleOwner not Unit** — `DisposableEffect(lifecycleOwner)` not `DisposableEffect(Unit)`. Using `Unit` fires only once and won't clean up on back navigation; the `onDispose` block never re-executes when the lifecycle owner changes. + +### State Management + +- [ ] **rememberSaveable for channelName/isJoined/uid and remember for RtcEngine** — `channelName`, `isJoined`, `uid` use `rememberSaveable`; `RtcEngine` uses `remember`. `rememberSaveable` survives configuration changes (rotation); `RtcEngine` is not serializable and will crash if placed in `rememberSaveable`. + +### Threading + +- [ ] **Callbacks dispatch to main thread via coroutineScope.launch(Dispatchers.Main)** — `IRtcEngineEventHandler` callbacks that show `Toast`, `Dialog`, or `AlertDialog` dispatch to the main thread via `coroutineScope.launch(Dispatchers.Main)`. SDK callbacks arrive on a background thread; `Toast` and dialog APIs require the main thread or they throw `CalledFromWrongThreadException`. Note: simple Compose state mutations (e.g. `isJoined = true`) are thread-safe via the snapshot system and do **not** need main-thread dispatch. + +### Permissions + +- [ ] **Permission check before joinChannel** — Permission launcher (`rememberLauncherForActivityResult`) is called before `joinChannel()`. Joining without the required permissions (`RECORD_AUDIO`, and `CAMERA` for video cases) causes a silent failure — no error callback, just no audio/video. + +## If a Check Fails + +- `DisposableEffect(Unit)` is used — change key to `lifecycleOwner`, then verify back navigation triggers cleanup. +- `RtcEngine` stored in `rememberSaveable` or state fields in `remember` only — fix to `RtcEngine -> remember`, UI/session state -> `rememberSaveable`, then verify rotation. +- Toast/Dialog shown directly in callback — move UI-thread-only calls into `coroutineScope.launch(Dispatchers.Main)`. +- Permission launcher bypassed before `joinChannel()` — gate join flow behind permission callback and re-test denied/granted paths. + +## NEVER + +- **NEVER** approve a review when `DisposableEffect` key is `Unit` for case teardown logic. +- **NEVER** approve a review when `RtcEngine` uses `rememberSaveable`. +- **NEVER** treat Compose callback state safety as permission to call Toast/Dialog off main thread. +- **NEVER** skip rotation and back-navigation checks for lifecycle-sensitive Compose cases. diff --git a/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md b/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..3faecf254 --- /dev/null +++ b/Android/APIExample-Compose/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,185 @@ +--- +name: upsert-case +description: > + Add a new API example case or modify an existing one in the APIExample-Compose Android demo — + creates or updates a Kotlin Composable file, registers or updates it in Examples.kt, and manages + string resources. Use when: adding a new Agora RTC API demo screen in Jetpack Compose, modifying + an existing case's implementation or registration, porting an existing APIExample case to Compose, + implementing a new feature example in Kotlin + Compose UI, registering a new entry in + BasicExampleList or AdvanceExampleList, or updating an existing case's strings or Examples.kt entry. + Kotlin only — no XML layouts, no Fragments. Keywords: add case, modify case, update case, + new composable, Examples.kt, BasicExampleList, AdvanceExampleList, APIExample-Compose, Compose case, + new screen, Jetpack Compose, RTC API example, upsert case. +--- + +# Upsert Case — APIExample-Compose + +## Adding a New Case + +Touch exactly 3 files (all paths relative to `app/src/main/`): + +| File | What to add | +|---|---| +| `java/.../compose/samples/YourCaseName.kt` | Composable file | +| `java/.../compose/model/Examples.kt` | 1 list entry | +| `res/values/strings.xml` | 1 string | + +No `nav_graph.xml` changes — navigation routes by list position automatically. + +--- + +### Step 1: Clarify before coding + +Before writing a single line, ask: +- **What API am I demonstrating?** — determines which existing case is the closest reference (`JoinChannelVideo.kt` for video, `JoinChannelAudio.kt` for audio) +- **Video or audio-only?** — determines permissions (`CAMERA` + `RECORD_AUDIO` vs `RECORD_AUDIO` only), whether `enableVideo()` and `VideoGrid` are needed +- **BasicExampleList or AdvanceExampleList?** — Basic for fundamental join/leave patterns; Advance for feature-specific APIs +- **List position?** — run `query-cases` skill to see current entries; list order is display order + +--- + +### Step 2: Create the Composable file + +**MANDATORY — READ ENTIRE FILE before writing any code**: +[`references/composable-template.kt`](references/composable-template.kt) + +Do NOT skip — the `SettingPreferences.getArea()`, `DisposableEffect` key, `rememberSaveable` vs `remember` rules, and `@Preview` placement are only fully shown there and are required in every case. + +**Do NOT load** any other reference files for this task. + +Non-obvious points the template highlights: + +- `mAreaCode = SettingPreferences.getArea()` — **required**, do not hardcode or omit +- `DisposableEffect(lifecycleOwner)` — key must be `lifecycleOwner`, not `Unit`; wrong key means cleanup never fires on back navigation +- `rememberSaveable` for channelName, isJoined, uid, videoIdList — survives rotation +- `remember` for RtcEngine — must NOT be `rememberSaveable` (engine is not serializable) +- `IRtcEngineEventHandler` callbacks can mutate Compose state directly — snapshot system is thread-safe, no `runOnUIThread()` needed +- `Toast`/`Dialog`/`AlertDialog` inside callbacks still need main thread — use `coroutineScope.launch(Dispatchers.Main) { }` +- `@Preview` goes on the **private** `*View` function only — never on the public stateful entry + +--- + +### Step 3: Register in Examples.kt + +File: `app/src/main/java/io/agora/api/example/compose/model/Examples.kt` + +```kotlin +val AdvanceExampleList = listOf( + // … existing entries … + Example(R.string.example_your_case_name) { YourCaseName() } +) +``` + +List order is display order — position determines where the case appears in the UI. + +--- + +### Step 4: Add string resource + +File: `app/src/main/res/values/strings.xml` + +```xml +Your Case Name +``` + +String key must use the `example_` prefix. No separate tips string needed (unlike APIExample). + +--- + +### Step 5: Update ARCHITECTURE.md + +Add one line to the case list in `ARCHITECTURE.md` under the correct directory section: + +``` +├── YourCaseName.kt # "Display Name" — key API description +``` + +Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans. + +--- + +## Modifying an Existing Case + +When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating: + +| What changed | Files to touch | +|---|---| +| Implementation logic (API calls, event handling, Compose state) | `java/.../compose/samples/CaseName.kt` | +| Display name | `res/values/strings.xml` | +| List group (Basic ↔ Advance) or position | `java/.../compose/model/Examples.kt` (move entry between lists or reorder) | +| Composable function rename | `CaseName.kt` (file + function name), `Examples.kt` (lambda reference), `ARCHITECTURE.md` | + +After making changes: + +1. **Verify `Examples.kt` entry consistency** — ensure the string resource reference, composable lambda, and list placement (`BasicExampleList` or `AdvanceExampleList`) still match the actual case. A mismatch causes the case to silently disappear from the list or render the wrong screen. +2. **Update `res/values/strings.xml`** if the display name changed. +3. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description. + +--- + +## Verify + +```bash +./gradlew assembleDebug +``` + +- [ ] Case appears in the correct group at the expected list position +- [ ] Tap navigates to the case screen +- [ ] Channel join succeeds and `isJoined` flips to `true` +- [ ] Press back — check Logcat for `RtcEngine.destroy` within ~2 seconds; if missing, `DisposableEffect` key is wrong or `onDispose` is incomplete +- [ ] Rotate screen — `channelName` and `isJoined` survive (`rememberSaveable` working) +- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description +- [ ] `Examples.kt` entry is consistent — string resource, composable lambda, and list placement match the actual case + +--- + +## When to Use a Spec Instead + +If the case meets any of the following criteria, create a Spec rather than using this skill directly: + +1. Involves coordinated calls across two or more Agora API modules +2. Requires a custom Composable layout not covered by the standard template above +3. Manages multiple channels or multiple engine instances +4. Requires a foreground Service or background coroutine coordination +5. Involves developing new shared components (shared Composables / utils) +6. Requires optional module integration (simpleFilter / streamEncrypt) + +If none apply → use this skill directly; no Spec needed. + +### Spec Requirements Document Must Include + +- List of APIs the case demonstrates +- User interaction flow description +- Expected RtcEngine lifecycle behavior +- Required permissions list + +### Spec Design Document Must Include + +- Target project identifier: `APIExample-Compose` +- Composable function structure design +- API call sequence (Mermaid sequence diagram recommended) +- State management plan (`remember` vs `rememberSaveable` boundaries) +- UI layout plan +- Integration points with existing shared components +- Case registration info: `Examples.kt` list entry, `strings.xml` key (`example_` prefix) — finalize during design to avoid conflicts +- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing entries +- Compose-specific checks: `DisposableEffect(lifecycleOwner)`, `rememberSaveable` vs `remember`, main-thread dispatch for Toast/Dialog +- Risk identification and mitigation (API compatibility, performance, permissions, thread safety, rotation/config changes) + +### Spec Task List Integration + +- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters +- Mark which sub-tasks require manual coding, and provide target file paths and change summaries +- Tasks for creating new shared Composables must come before case implementation tasks + +--- + +## NEVER + +- **NEVER** use XML layouts, `Fragment`, or `ViewBinding` — Compose only. +- **NEVER** use `remember` for channelName, isJoined, or uid — they must be `rememberSaveable` to survive rotation. +- **NEVER** use `rememberSaveable` for `RtcEngine` — it is not serializable and will crash on rotation. +- **NEVER** use `Unit` as the `DisposableEffect` key — it fires only once and won't clean up on back navigation. Always use `lifecycleOwner`. +- **NEVER** put `@Preview` on the public stateful function — it will crash because `LocalContext` and `LocalLifecycleOwner` are unavailable in preview. Only preview the private `*View` function. +- **NEVER** call `Toast`/`Dialog`/`AlertDialog` directly inside `IRtcEngineEventHandler` callbacks — they require the main thread. Use `coroutineScope.launch(Dispatchers.Main) { }`. +- **NEVER** hardcode `mAreaCode` — always use `SettingPreferences.getArea()`. diff --git a/Android/APIExample-Compose/AGENTS.md b/Android/APIExample-Compose/AGENTS.md new file mode 100644 index 000000000..3e63c9283 --- /dev/null +++ b/Android/APIExample-Compose/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md — APIExample-Compose + +Jetpack Compose version of the API demo. Mirrors cases from `APIExample/` but uses +`@Composable` functions instead of Fragments + XML layouts. Kotlin only. + +## Build Commands + +```bash +./gradlew assembleDebug # build debug APK +./gradlew installDebug # build + install to connected device +./gradlew test # unit tests +./gradlew connectedAndroidTest # instrumented tests (device required) +``` + +## App ID Configuration + +See [README.md — Obtain an App Id](README.md#obtain-an-app-id). + +## Architecture Red Lines + +- Do NOT use XML layouts, `Fragment`, or `ViewBinding` — Compose only. +- Do NOT use `View`-based widgets directly in Compose UI — wrap with `AndroidView` if unavoidable. +- `RtcEngine` must be created inside `remember { }` and destroyed inside `DisposableEffect(lifecycleOwner) { onDispose { } }` — key must be `lifecycleOwner`, not `Unit`; wrong key means cleanup never fires on back navigation. +- Always call `rtcEngine.leaveChannel()` before `RtcEngine.destroy()` in `onDispose`. +- Permissions use `rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions())`. +- `IRtcEngineEventHandler` callbacks are safe to mutate Compose state directly (snapshot system is thread-safe). + +## Skills + +| Skill | Path | Description | +|-------|------|-------------| +| upsert-case | `.agent/skills/upsert-case/` | Add a new Compose case or modify an existing one | +| query-cases | `.agent/skills/query-cases/` | Query and browse existing Compose cases | +| review-case | `.agent/skills/review-case/` | Review a case against project red lines | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, Composable case pattern, registration details diff --git a/Android/APIExample-Compose/ARCHITECTURE.md b/Android/APIExample-Compose/ARCHITECTURE.md new file mode 100644 index 000000000..3b70c3991 --- /dev/null +++ b/Android/APIExample-Compose/ARCHITECTURE.md @@ -0,0 +1,197 @@ +# ARCHITECTURE.md — APIExample-Compose + +## Directory Layout + +``` +APIExample-Compose/ +├── gradle.properties # rtc_sdk_version +├── AGENTS.md # Agent entry point — build commands, red lines, skill index +├── ARCHITECTURE.md # This file — directory layout, patterns, registration +├── .kiro/ +│ ├── hooks/ +│ │ └── build-on-task-complete.json # Runs assembleDebug after each spec task completes +│ ├── skills/ +│ │ ├── add-new-case/SKILL.md # Step-by-step guide for adding a new Compose case +│ │ └── query-cases/SKILL.md # Query existing cases by API, group, or list position +│ └── steering/ +│ ├── project-routing.md # Which sub-project to use; hard constraints (always included) +│ ├── coding-standards.md # RtcEngine lifecycle, Kotlin/Compose rules (always included) +│ └── complex-case-spec.md # Spec workflow for complex cases (manual inclusion) +└── app/src/main/ + ├── AndroidManifest.xml + ├── assets/ # Audio/video sample files + ├── res/ + │ └── values/strings.xml # Display name strings (prefix: example_*) + └── java/io/agora/api/example/compose/ + ├── APIExampleApp.kt # Application class + ├── MainActivity.kt # Single-Activity, sets content to NavGraph() + ├── NavGraph.kt # Compose Navigation host — home / settings / example + │ + ├── model/ + │ ├── Example.kt # data class: name: Int, content: @Composable + │ ├── Examples.kt # Hardcoded lists: BasicExampleList, AdvanceExampleList + │ └── Components.kt # Groups the two lists into Components for the home screen + │ + ├── samples/ # One .kt file per case — all @Composable + │ ├── JoinChannelVideoToken.kt # Basic: "Join Video Channel (With Token)" + │ ├── JoinChannelVideo.kt # Basic: "Join Video Channel" — canonical reference + │ ├── JoinChannelAudio.kt # Basic: "Join Audio Channel" + │ ├── LiveStreaming.kt # Advanced: "Live Streaming" — setClientRole + │ ├── RTMPStreaming.kt # Advanced: "RTMP Streaming" — push to CDN + │ ├── MediaMetadata.kt # Advanced: "Media Metadata" — send/receive metadata + │ ├── VoiceEffects.kt # Advanced: "Voice Effects" — voice beautifier/effects + │ ├── OriginAudioData.kt # Advanced: "Origin Audio Data" — raw audio processing + │ ├── CustomAudioSource.kt # Advanced: "Custom Audio Source" — push external audio + │ ├── CustomAudioRender.kt # Advanced: "Custom Audio Render" — pull audio rendering + │ ├── OriginVideoData.kt # Advanced: "Origin Video Data" — raw video processing + │ ├── CustomVideoSource.kt # Advanced: "Custom Video Source" — push external video + │ ├── CustomVideoRender.kt # Advanced: "Custom Video Render" — custom video rendering + │ ├── PictureInPicture.kt # Advanced: "Picture In Picture" — PiP mode + │ ├── JoinMultiChannel.kt # Advanced: "Join Multi Channel" — multi-channel join + │ ├── ChannelEncryption.kt # Advanced: "Channel Encryption" — built-in encryption + │ ├── PlayAudioFiles.kt # Advanced: "Play Audio Files" — audio mixing + │ ├── PreCallTest.kt # Advanced: "Pre Call Test" — network/device test + │ ├── MediaRecorder.kt # Advanced: "Media Recorder" — record media streams + │ ├── MediaPlayer.kt # Advanced: "Media Player" — play media files + │ ├── ScreenSharing.kt # Advanced: "Screen Sharing" — screen capture & share + │ ├── VideoProcessExtension.kt # Advanced: "Video Process Extension" — video filter + │ ├── RhythmPlayer.kt # Advanced: "Rhythm Player" — metronome playback + │ ├── LocalVideoTranscoding.kt # Advanced: "Local Video Transcoding" — local compositing + │ ├── SendDataStream.kt # Advanced: "Send Data Stream" — data channel messaging + │ ├── HostAcrossChannel.kt # Advanced: "Host Across Channel" — cross-channel relay + │ ├── SpatialSound.kt # Advanced: "Spatial Sound" — 3D spatial audio + │ + ├── ui/ + │ ├── home/ + │ │ └── Home.kt # Home screen — renders grouped example list + │ ├── example/ + │ │ ├── Example.kt # Wrapper screen: calls example.content(back) + │ │ └── ExampleItem.kt # Single row in the example list + │ ├── settings/ + │ │ └── Settings.kt # Settings screen (area, resolution, frame rate) + │ ├── common/ + │ │ ├── APIExampleScaffold.kt # Shared scaffold with top bar + │ │ ├── APIExampleTopAppBar.kt + │ │ └── Widgets.kt # ChannelNameInput, VideoGrid, VideoStatsInfo, etc. + │ └── theme/ + │ └── Theme.kt + │ + ├── data/ + │ └── SettingPreferences.kt # DataStore-backed settings (area, resolution, frame rate) + │ + └── utils/ + ├── TokenUtils.java # Fetches RTC tokens from Agora token server + ├── AudioFileReader.java + ├── AudioPlayer.java + ├── VideoFileReader.java + ├── FileUtils.java + ├── YUVUtils.java + ├── YuvFboProgram.java + ├── YuvUploader.java + └── GLTextureView.java +``` + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| Join Video Channel (With Token) | `JoinChannelVideoToken.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()` | Joins a video channel using a manually provided token instead of fetching one automatically | +| Join Video Channel | `JoinChannelVideo.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `setVideoEncoderConfiguration()` | Canonical reference for joining a video channel with token generation and basic video rendering | +| Join Audio Channel | `JoinChannelAudio.kt` | `joinChannel()`, `enableAudio()`, `setChannelProfile()`, `setAudioScenario()`, `setAudioProfile()`, `enableInEarMonitoring()` | Joins an audio-only channel with audio route, in-ear monitoring, and volume controls | +| Live Streaming | `LiveStreaming.kt` | `joinChannel()`, `enableVideo()`, `setClientRole()`, `setDualStreamMode()`, `setVideoScenario()`, `addVideoWatermark()`, `setVideoEncoderConfiguration()` | Demonstrates live streaming with client role switching, dual stream, watermark, and encoder options | +| RTMP Streaming | `RTMPStreaming.kt` | `joinChannel()`, `enableVideo()`, `startRtmpStreamWithTranscoding()`, `startRtmpStreamWithoutTranscoding()`, `stopRtmpStream()`, `updateRtmpTranscoding()` | Pushes a live stream to a CDN via RTMP with optional transcoding | +| Media Metadata | `MediaMetadata.kt` | `joinChannel()`, `enableVideo()`, `registerMediaMetadataObserver()` | Sends and receives video metadata through the IMetadataObserver interface | +| Voice Effects | `VoiceEffects.kt` | `joinChannel()`, `enableAudio()`, `setVoiceBeautifierPreset()`, `setVoiceConversionPreset()`, `setAudioEffectPreset()`, `setAudioEffectParameters()`, `setAINSMode()` | Applies voice beautifier, voice changer, style transformation, and noise suppression presets | +| Origin Audio Data | `OriginAudioData.kt` | `joinChannel()`, `enableAudio()`, `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Accesses and rewrites raw audio frames via the IAudioFrameObserver interface | +| Custom Audio Source | `CustomAudioSource.kt` | `joinChannel()`, `enableAudio()`, `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `destroyCustomAudioTrack()`, `enableCustomAudioLocalPlayback()` | Pushes external audio from a file into a custom audio track | +| Custom Audio Render | `CustomAudioRender.kt` | `joinChannel()`, `enableAudio()`, `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Pulls remote audio frames and renders them through a custom AudioTrack player | +| Origin Video Data | `OriginVideoData.kt` | `joinChannel()`, `enableVideo()`, `registerVideoFrameObserver()` | Captures raw video frames via IVideoFrameObserver for screenshot functionality | +| Custom Video Source | `CustomVideoSource.kt` | `joinChannel()`, `enableVideo()`, `pushExternalVideoFrameById()` | Pushes external video frames in I420, NV21, NV12, or Texture2D format | +| Custom Video Render | `CustomVideoRender.kt` | `joinChannel()`, `enableVideo()`, `registerVideoFrameObserver()` | Renders remote video frames using a custom OpenGL renderer via IVideoFrameObserver | +| Picture In Picture | `PictureInPicture.kt` | `joinChannel()`, `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `enterPictureInPictureMode()` | Demonstrates Android Picture-in-Picture mode during a video call | +| Join Multi Channel | `JoinMultiChannel.kt` | `joinChannel()`, `joinChannelEx()`, `leaveChannelEx()`, `enableVideo()`, `setupRemoteVideoEx()`, `takeSnapshotEx()` | Joins two channels simultaneously using RtcEngineEx multi-channel APIs | +| Channel Encryption | `ChannelEncryption.kt` | `joinChannel()`, `enableVideo()`, `enableEncryption()` | Enables built-in media encryption before joining a channel | +| Play Audio Files | `PlayAudioFiles.kt` | `joinChannel()`, `enableAudio()`, `startAudioMixing()`, `stopAudioMixing()`, `playEffect()`, `preloadEffect()`, `setAudioProfile()` | Plays audio mixing and sound effect files with volume controls | +| Pre Call Test | `PreCallTest.kt` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()`, `enableVideo()` | Runs network quality probe and audio/video echo tests before joining a channel | +| Media Recorder | `MediaRecorder.kt` | `joinChannel()`, `enableVideo()`, `createMediaRecorder()`, `startRecording()`, `stopRecording()` | Records local or remote media streams to MP4 files using AgoraMediaRecorder | +| Media Player | `MediaPlayer.kt` | `joinChannel()`, `enableVideo()`, `createMediaPlayer()`, `open()`, `play()`, `stop()`, `updateChannelMediaOptions()` | Plays media files and publishes the player track to the channel | +| Screen Sharing | `ScreenSharing.kt` | `joinChannel()`, `enableVideo()`, `startScreenCapture()`, `stopScreenCapture()`, `updateScreenCaptureParameters()`, `setScreenCaptureScenario()` | Captures and shares the device screen with scenario and audio options | +| Video Process Extension | `VideoProcessExtension.kt` | `joinChannel()`, `enableVideo()`, `setBeautyEffectOptions()`, `setLowlightEnhanceOptions()`, `setColorEnhanceOptions()`, `setVideoDenoiserOptions()`, `enableVirtualBackground()`, `enableExtension()` | Applies beauty filters, low-light enhancement, color enhancement, denoiser, and virtual background | +| Rhythm Player | `RhythmPlayer.kt` | `joinChannel()`, `startRhythmPlayer()`, `stopRhythmPlayer()`, `updateChannelMediaOptions()` | Plays a metronome beat track and publishes it to the channel | +| Local Video Transcoding | `LocalVideoTranscoding.kt` | `joinChannel()`, `enableVideo()`, `startLocalVideoTranscoder()`, `stopLocalVideoTranscoder()`, `startCameraCapture()`, `stopCameraCapture()` | Composites camera and media player streams into a single transcoded video | +| Send Data Stream | `SendDataStream.kt` | `joinChannel()`, `enableVideo()`, `createDataStream()`, `sendStreamMessage()` | Sends and receives real-time data messages through a data channel | +| Host Across Channel | `HostAcrossChannel.kt` | `joinChannel()`, `enableVideo()`, `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Relays media streams from one channel to another for cross-channel hosting | +| Spatial Sound | `SpatialSound.kt` | `joinChannel()`, `enableAudio()`, `ILocalSpatialAudioEngine.initialize()`, `updateSelfPosition()`, `updateRemotePosition()`, `updatePlayerPositionInfo()` | Demonstrates 3D spatial audio with draggable sound source positioning | + +## Case Registration Mechanism + +Registration is **manual** — no reflection, no annotation scanning. + +**To add a case, edit exactly two files:** + +**1. `model/Examples.kt`** — append to `BasicExampleList` or `AdvanceExampleList`: +```kotlin +val AdvanceExampleList = listOf( + // … existing entries … + Example(R.string.example_my_new_case) { MyNewCase() } +) +``` + +**2. `samples/MyNewCase.kt`** — create the Composable: +```kotlin +@Composable +fun MyNewCase() { … } +``` + +No `nav_graph.xml`, no `@Example` annotation, no action ID. `NavGraph.kt` routes to cases by their +index in the list — the order in `Examples.kt` is the display order. + +## Composable Case Pattern + +Every case follows a two-function structure. `JoinChannelVideo.kt` is the canonical reference. + +``` +MyNewCase() ← public, stateful: owns RtcEngine, state, permissions + └── MyNewCaseView(...) ← private, stateless: receives data + lambdas, pure UI +``` + +**Engine creation and cleanup:** +```kotlin +val rtcEngine = remember { + RtcEngine.create(RtcEngineConfig().apply { + mContext = context + mAppId = AgoraConfig.getAppId() + mEventHandler = object : IRtcEngineEventHandler() { … } + }) +} +DisposableEffect(lifecycleOwner) { // key must be lifecycleOwner, not Unit + onDispose { + if (isJoined) rtcEngine.leaveChannel() + RtcEngine.destroy() + } +} +``` + +**Permissions:** +```kotlin +val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() +) { grantedMap -> + if (grantedMap.values.all { it }) { /* join channel */ } +} +// trigger: +permissionLauncher.launch(arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)) +``` + +**State rules:** +- `rememberSaveable` — values that must survive rotation (channelName, isJoined, uid) +- `remember` — objects that must not be recreated (RtcEngine, collections) +- `IRtcEngineEventHandler` callbacks can mutate Compose state directly — the snapshot system is thread-safe + +## Token Flow + +```kotlin +TokenUtils.gen(channelName, uid) { token -> + rtcEngine.joinChannel(token, channelName, uid, options) +} +``` diff --git a/Android/APIExample-Compose/CLAUDE.md b/Android/APIExample-Compose/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/Android/APIExample-Compose/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/Android/APIExample-Compose/README.md b/Android/APIExample-Compose/README.md index e2ee7b506..e5941d0a1 100644 --- a/Android/APIExample-Compose/README.md +++ b/Android/APIExample-Compose/README.md @@ -23,15 +23,16 @@ To build and run the sample application, get an App Id: 3. Save the **App Id** from the Dashboard for later use. 4. Save the **App Certificate** from the Dashboard for later use. -5. Open `Android/APIExample-Compose` and edit the `local.properties` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard. Note you can leave the certificate variable `null` if your project has not turned on security token. +5. Open `Android/APIExample-Compose` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well. ``` - // Agora APP ID. + sdk.dir=/path/to/Android/sdk AGORA_APP_ID=YOUR APP ID - // Agora APP Certificate. If the project does not have certificates enabled, leave this field blank. AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the token-based examples to paste the token at runtime. + You are all set. Now connect your Android device and run the project. diff --git a/Android/APIExample-Compose/README.zh.md b/Android/APIExample-Compose/README.zh.md index 21769f0e2..36e8db7d0 100644 --- a/Android/APIExample-Compose/README.zh.md +++ b/Android/APIExample-Compose/README.zh.md @@ -23,15 +23,16 @@ 3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它 4. 复制后台的 **App 证书** 并备注,稍后启动应用时会用到它 -5. 打开 `Android/APIExample` 并编辑 `local.properties`,将你的 AppID 、App主证书 分别替换到 `Your App Id` 和 `YOUR APP CERTIFICATE` +5. 打开 `Android/APIExample-Compose` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE` ``` - // 声网APP ID。 + sdk.dir=/path/to/Android/sdk AGORA_APP_ID=YOUR APP ID - // 声网APP证书。如果项目没有开启证书鉴权,这个字段留空。 AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 token 方式的示例在运行时粘贴 token。 + 然后你就可以编译并运行项目了。 ## 联系我们 diff --git a/Android/APIExample/.agent/skills/query-cases/SKILL.md b/Android/APIExample/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..ee1944957 --- /dev/null +++ b/Android/APIExample/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,110 @@ +--- +name: query-cases +description: > + Query and browse existing API example cases in the APIExample Android demo — lists + cases by group, finds which case demonstrates a specific Agora API, checks sort + index availability, and resolves display names from string resources. Use when: + someone asks what cases exist, which APIs are demonstrated, wants to find a case + by name or API (e.g. takeSnapshot, setClientRole), needs a free sort index before + adding a new case, or wants to know if a feature is already implemented. + Keywords: list cases, find case, query cases, @Example, sort index, BASIC, ADVANCED, + available cases, existing cases, which case, is there a case. +--- + +# Query Cases — APIExample + +## How cases are registered + +Every case is a Fragment under `app/src/main/java/io/agora/api/example/examples/{basic|advanced|audio}/` with an `@Example` annotation: + +```java +@Example( + index = 10, // unique within the group; BASIC: 0–9, ADVANCED: 10+ + group = ADVANCED, + name = R.string.item_xxx, + actionId = R.id.action_mainFragment_to_xxx, + tipsId = R.string.xxx_tips +) +``` + +A commented-out `@Example` (`//@Example`) means the case is disabled and won't appear in the app. + +--- + +## Query procedure + +### Step 1: Decide scope before scanning + +Before listing files, ask: +- **Looking for a specific API?** — scan Javadoc comments for the API name; no need to read all files +- **Need a free sort index?** — collect all `index` values for the target group, then find the gap +- **Listing all cases?** — scan all three directories and collect annotations + +### Step 2: Read ARCHITECTURE.md first + +Read `ARCHITECTURE.md` (the `examples/` section of the Directory Layout). It contains a pre-built index of all cases with group, index, display name, and key API — no file scanning needed for most queries. + +Use ARCHITECTURE.md as the primary source. Fall back to scanning the source directories only when: +- The query requires data not in ARCHITECTURE.md (e.g. full `@Example` field values, `tipsId`) +- ARCHITECTURE.md appears stale (a case exists in source but not in the doc) +- The output involves free-index claims, index collisions, or "is index X available?" decisions — these must be validated from source immediately before final output + +### Step 3: Scan case directories (fallback only) + +| Directory | Group | Contents | +|-----------|-------|----------| +| `examples/basic/` | BASIC | Core join/leave patterns | +| `examples/advanced/` | ADVANCED | Feature-specific APIs | +| `examples/audio/` | ADVANCED | Audio-specific cases (still grouped ADVANCED) | + +Each `.java` file is a case. Subdirectories (e.g. `customaudio/`) contain multi-file cases — the main class is the file whose name matches the directory name (e.g. `customaudio/CustomAudioSource.java`). If no name match, look for the file containing `@Example`. + +### Step 4: Extract `@Example` fields + +For each file, read the annotation for `group`, `index`, `name` (string resource ID), and `tipsId`. If the annotation is commented out, the case is disabled. + +Resolve display names from `app/src/main/res/values/strings.xml`: +`R.string.item_video_snapshot` → `Video Snapshot` + +### Step 5: Read class Javadoc for API mapping + +The Javadoc above each class lists the key APIs demonstrated: + +```java +/** + * This demo demonstrates how to take a snapshot of the local video stream. + * + * Key APIs used: + * - RtcEngine.takeSnapshot() + */ +``` + +Use this to answer "which case uses X?" queries without reading the full implementation. + +If no Javadoc is present, scan the method body for the API name as a method call. If still not found, note "API mapping unavailable" in the results table. + +### Step 6: Present results + +Full listing — table format: + +| Group | Index | Case Name | File | Key APIs | +|-------|-------|-----------|------|----------| +| BASIC | 0 | Join Channel Video | JoinChannelVideo.java | joinChannel(), setupLocalVideo() | +| ADVANCED | 10 | Video Snapshot | VideoSnapshot.java | takeSnapshot() | + +For a specific query (e.g. "which case uses takeSnapshot?"), return only matching rows. + +For a free-index query, list all used indices in the target group and identify the next available slot: +> BASIC range: 0–9. ADVANCED range: 10+. +> ADVANCED indices in use: 10, 11, 12, 15, 20 → next free: 13 + +Before returning any free-index/collision result, re-scan source registration points (`@Example` across `basic/`, `advanced/`, `audio/`) and recompute once from source-of-truth data. + +--- + +## NEVER + +- **NEVER** count a commented-out `@Example` (`//@Example`) as an active case — it is disabled and won't appear in the app. +- **NEVER** mix index spaces across groups — `audio/` cases use `group=ADVANCED` but share the same index namespace as `advanced/`; always scan both directories together when finding a free index. +- **NEVER** use filename alone to identify a subdirectory case — the main class is the file whose name matches the directory name; if no match, look for the file with `@Example`. +- **NEVER** report a free index without scanning all three directories (`basic/`, `advanced/`, `audio/`) for the target group — missing one causes index collisions. diff --git a/Android/APIExample/.agent/skills/review-case/SKILL.md b/Android/APIExample/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..6caabc6ce --- /dev/null +++ b/Android/APIExample/.agent/skills/review-case/SKILL.md @@ -0,0 +1,52 @@ +--- +name: review-case +description: > + Review an existing case implementation against project-specific red lines + and coding standards. Use after implementing or modifying a case. + Use when: reviewing a case for correctness, checking red-line compliance, + verifying lifecycle and threading patterns, auditing an existing Fragment. + Keywords: review, audit, check, red lines, lifecycle, threading, compliance. +--- + +# Review Case — APIExample + +Run through every item below before considering a case implementation complete. +Open the case's Fragment source file and verify each point against the actual code. + +## Checklist + +### Teardown & Lifecycle + +- [ ] **leaveChannel before destroy** — `engine.leaveChannel()` is called before `RtcEngine.destroy()` in the teardown path (typically `onDestroy()`). Destroying without leaving first leaks the channel session on the server side. + +- [ ] **handler.post for destroy** — `RtcEngine.destroy()` is invoked via `handler.post(RtcEngine::destroy)` and **not** called directly on the main thread. A direct call blocks the UI thread and causes ANR. + +### Threading + +- [ ] **runOnUIThread for callbacks** — All `IRtcEngineEventHandler` callbacks that update UI are wrapped with `runOnUIThread()`. SDK callbacks arrive on a background thread; touching Views without dispatching to the main thread causes crashes or silent rendering corruption. + +### Permissions + +- [ ] **Permission check before join** — `checkOrRequestPermission()` is called before `joinChannel()`. Joining without the required permissions (RECORD_AUDIO, and CAMERA for video cases) causes a silent failure — no error callback, just no audio/video. + +### Backend Reporting + +- [ ] **setParameters present** — `setParameters(...)` is called during engine initialisation. This is required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally. + +### Private Cloud + +- [ ] **getPrivateCloudConfig null-check** — `getPrivateCloudConfig()` is null-checked before `setLocalAccessPoint()` is called. The method returns `null` on standard (non-private-cloud) builds, so calling `setLocalAccessPoint()` without the guard causes a NullPointerException. + +## If a Check Fails + +- Teardown order wrong (`destroy` before `leaveChannel`) — fix teardown to `leaveChannel()` first, then `handler.post(RtcEngine::destroy)`, and re-test back navigation. +- UI touched in SDK callback without main-thread dispatch — wrap UI updates in `runOnUIThread()` and re-run the case to verify no thread exceptions. +- Permission flow missing before `joinChannel()` — add `checkOrRequestPermission()` gate and verify join succeeds only after permission is granted. +- Missing `setParameters(...)` or private-cloud null-check — add both safeguards in engine init and re-run the init path once. + +## NEVER + +- **NEVER** approve a case review with direct `RtcEngine.destroy()` on main thread. +- **NEVER** approve a case review when `leaveChannel()` is missing before destroy. +- **NEVER** ignore background-thread UI updates inside `IRtcEngineEventHandler` callbacks. +- **NEVER** assume runtime behavior is correct without at least one back-navigation teardown check in Logcat. diff --git a/Android/APIExample/.agent/skills/upsert-case/SKILL.md b/Android/APIExample/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..25211787d --- /dev/null +++ b/Android/APIExample/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,342 @@ +--- +name: upsert-case +description: > + Add a new API example case or modify an existing one in the APIExample Android demo — + creates or updates Fragment class, XML layout, string resources, and nav_graph registration. + Use when: adding a new Agora RTC API demo screen, modifying an existing case's implementation + or registration, implementing a new feature example in Java + XML layouts, registering a new + case via @Example annotation, subclassing BaseFragment for a new demo screen, or updating + an existing case's strings, layout, or nav entry. Keywords: add case, modify case, update case, + new fragment, nav_graph, @Example, BaseFragment, APIExample, new screen, demo case, RTC API example. +--- + +# Upsert Case — APIExample + +## Adding a New Case + +Touch exactly 4 files (all paths relative to `app/src/main/`): + +| File | What to add | +|---|---| +| `java/.../examples/{basic\|advanced\|audio}/YourCaseName.java` | Fragment class | +| `res/layout/fragment_your_case_name.xml` | XML layout | +| `res/values/strings.xml` | 2 strings | +| `res/navigation/nav_graph.xml` | 1 action + 1 destination | + +Registration is automatic via reflection — no other files needed. + +--- + +### Step 1: Clarify before coding + +Before writing a single line, ask: +- **What API am I demonstrating?** — determines which existing case is the closest reference to copy patterns from +- **Video or audio-only?** — determines permissions (`CAMERA` + `RECORD_AUDIO` vs `RECORD_AUDIO` only), layout complexity, and whether `VideoReportLayout` is needed +- **BASIC or ADVANCED group?** — BASIC for fundamental channel join/leave patterns; ADVANCED for feature-specific APIs +- **What's the sort index?** — index must be unique within the group. BASIC uses 0–9, ADVANCED starts from 10. Run `query-cases` skill first; a collision causes silent ordering bugs at runtime + +--- + +### Step 2: Create the Fragment + +**MANDATORY — READ ENTIRE FILE before writing any code**: +[`references/fragment-template.java`](references/fragment-template.java) + +Do NOT skip — the `setParameters`, `handler.post`, and `getPrivateCloudConfig()` null-check patterns are only fully shown there and are required in every case. + +**Do NOT load** any other reference files for this task. + +Non-obvious points the template highlights: + +- `setParameters(...)` for app scenario reporting — **required in every case**, do not remove +- `handler.post(RtcEngine::destroy)` — NOT `RtcEngine.destroy()` directly; direct call blocks UI thread (ANR) +- `getPrivateCloudConfig()` null-check before `setLocalAccessPoint()` — returns null on non-private-cloud builds (NPE) +- All `IRtcEngineEventHandler` callbacks run on a **background thread** — always `runOnUIThread()` for UI +- `onActivityCreated` → create engine; `onDestroy` → `leaveChannel()` then `handler.post(RtcEngine::destroy)` + +For video cases, add `VideoReportLayout` fields and wire `setupRemoteVideo` in `onUserJoined`/`onUserOffline`. + +--- + +### Step 3: Create the XML layout + +Minimum structure — channel input + join button at bottom: + +```xml + + + + + + + + + + + + +``` + +For video cases, use `VideoReportLayout` for each video slot. Pick one of the four standard layouts below — they cover the vast majority of cases. + +**General rules (apply to all layouts):** +- Video containers must sit **above** the bottom control bar. In `RelativeLayout` use `android:layout_above="@id/ll_join"`; in `ConstraintLayout` use `app:layout_constraintBottom_toTopOf="@id/ll_join"`. +- Each `VideoReportLayout` needs a unique `android:id` (`fl_local`, `fl_remote`, `fl_remote2`, …). + +--- + +**Layout A — Single broadcaster (local fullscreen)** +Use when: broadcaster-only demo, no remote video needed. + +```xml + + +``` + +--- + +**Layout B — 1v1 (local left, remote right, side by side)** +Use when: two-party call, equal-weight split. + +```xml + + + + + + + +``` + +--- + +**Layout C — Audience co-hosting (remote fullscreen background + local PiP top-right)** +Use when: live streaming where audience co-hosts; remote/host fills screen, local is a small overlay. + +```xml + + + + +``` + +--- + +**Layout D — 2×2 grid (up to 4 participants)** +Use when: multi-party call with up to 4 streams. + +```xml + + + + + + + + + + + + + +``` + +--- + +### Step 4: Add nav entries + +File: `res/navigation/nav_graph.xml` + +**Action** — inside `` (NOT mainFragment — mainFragment only has one action, to Ready): + +```xml + +``` + +**Destination** — at root `` level: + +```xml + +``` + +`action android:id` must exactly match `actionId` in `@Example`. + +--- + +### Step 5: Update ARCHITECTURE.md + +Add one line to the case list in `ARCHITECTURE.md` under the correct directory section (`basic/`, `advanced/`, or `audio/`): + +``` +├── YourCaseName.java # [index] "Display Name" — key API description +``` + +Keep the format consistent with existing entries. This file is the fast-lookup index used by `query-cases` — keeping it current avoids full directory scans. + +--- + + +## Modifying an Existing Case + +When modifying an existing case rather than creating a new one, identify which files need changes based on what you are updating: + +| What changed | Files to touch | +|---|---| +| Implementation logic (API calls, event handling) | `java/.../examples/{basic\|advanced\|audio}/CaseName.java` | +| UI layout (views, controls, video containers) | `res/layout/fragment_case_name.xml` | +| Display name or tips text | `res/values/strings.xml` | +| Sort index or group (BASIC ↔ ADVANCED) | `@Example` annotation in the Fragment class | +| Navigation label | `res/navigation/nav_graph.xml` (fragment label attribute) | +| Class rename or package move | Fragment class, `nav_graph.xml` (android:name + destination id), `@Example` annotation (actionId), layout file name, `ARCHITECTURE.md` | + +After making changes: + +1. **Verify `@Example` annotation consistency** — ensure `index`, `group`, `name`, `actionId`, and `tipsId` still match the actual string resources, nav action ID, and intended group/position. A mismatch causes the case to silently disappear from the list or navigate to the wrong screen. +2. **Update `res/values/strings.xml`** if the display name or tips text changed. +3. **Update `res/navigation/nav_graph.xml`** if the class name, package, or label changed. +4. **Update `ARCHITECTURE.md`** — update the Directory Layout entry and the Case Index table row to reflect any changes to the case name, path, Key APIs, or description. + +--- + +## Verify + +```bash +./gradlew assembleDebug +``` + +- [ ] Case appears in correct group at expected sort position +- [ ] Tap navigates to the case screen (silent failure = nav action in wrong fragment) +- [ ] `onJoinChannelSuccess` fires in Logcat +- [ ] After pressing back, check Logcat for `RtcEngine.destroy` within ~2 seconds — if missing, there is a lifecycle bug in `onDestroy` +- [ ] `ARCHITECTURE.md` Case Index table is updated — row added (new case) or row updated (modified case) with correct Case, Path, Key APIs, and Description +- [ ] `@Example` annotation fields (`index`, `group`, `name`, `actionId`, `tipsId`) are consistent with string resources and nav_graph entries + +--- + +## When to Use a Spec Instead + +If the case meets any of the following criteria, create a Spec rather than using this skill directly: + +1. Involves coordinated calls across two or more Agora API modules +2. Requires a custom UI layout (not one of the standard Layout A/B/C/D templates above) +3. Involves multi-channel or multi-engine instance management +4. Requires a foreground Service or background thread coordination +5. Involves developing new shared components (widgets/utils, etc.) +6. Requires optional module integration (simpleFilter/streamEncrypt) + +If none apply → use this skill directly; no Spec needed. + +### Spec Requirements Document Must Include + +- List of APIs the case demonstrates +- User interaction flow description +- Expected RtcEngine lifecycle behavior +- Required permissions list + +### Spec Design Document Must Include + +- Target project identifier: `APIExample` +- Class/file structure design +- API call sequence (Mermaid sequence diagram recommended) +- State management approach +- UI layout plan +- Integration points with existing shared components +- Case registration info: class name, display name, group (BASIC/ADVANCED), sort index — finalize during design to avoid conflicts +- Generate `@Example` annotation parameters, `nav_graph.xml` action + destination, `strings.xml` key names (`item_` prefix) +- Read `ARCHITECTURE.md` or use the `query-cases` skill to check existing indices +- Risk identification and mitigation (API compatibility, performance, permissions, thread safety) + +### Spec Task List Integration + +- Mark which sub-tasks can be executed with this `upsert-case` skill, and provide skill input parameters +- Mark which sub-tasks require manual coding, and provide target file paths and change summaries +- Tasks for creating new shared components must come before case implementation tasks + +--- + +## NEVER + +- **NEVER** put the nav action inside `` — it belongs in ``. mainFragment only routes to Ready; all case actions live in Ready. Wrong placement causes silent navigation failure at runtime. +- **NEVER** call `RtcEngine.destroy()` directly on the main thread — always `handler.post(RtcEngine::destroy)`. Direct call blocks the UI thread and causes ANR. +- **NEVER** call `setLocalAccessPoint()` without null-checking `getPrivateCloudConfig()` first — it returns null on standard builds, causing NPE. +- **NEVER** update UI directly inside `IRtcEngineEventHandler` callbacks — they run on a background thread. Always wrap with `runOnUIThread()`. +- **NEVER** omit `setParameters(...)` — it's required for Agora backend usage reporting in every case; omitting it causes silent reporting failure even though the app appears to work normally. diff --git a/Android/APIExample/AGENTS.md b/Android/APIExample/AGENTS.md new file mode 100644 index 000000000..b5c9e8bd7 --- /dev/null +++ b/Android/APIExample/AGENTS.md @@ -0,0 +1,49 @@ +# AGENTS.md — APIExample + +Full demo project. Covers all Agora RTC APIs using Java/Kotlin + XML layouts. +Default project for video, screen sharing, beauty, or extension demos. + +## Build Commands + +```bash +./gradlew assembleDebug # build debug APK +./gradlew installDebug # build + install to connected device +./gradlew test # unit tests +./gradlew connectedAndroidTest # instrumented tests (device required) +``` + +## App ID Configuration + +See [README.md — Obtain an App Id](README.md#obtain-an-app-id). + +## Optional Modules + +Controlled via `gradle.properties`: +- `simpleFilter = true` — enables the C++ video extension module (`agora-simple-filter`). Requires OpenCV and Agora C++ SDK headers. See README for setup. +- `streamEncrypt = true` — enables the custom stream encryption module (`agora-stream-encrypt`). Requires Agora C++ SDK headers. See README for setup. + +Both are `false` by default. Do not enable unless the feature explicitly requires it. + +## Architecture Red Lines + +- Do NOT add audio-only cases that require `voice-sdk` exclusivity — use `APIExample-Audio/` instead. +- Do NOT use Jetpack Compose — this project is XML + ViewBinding only. +- Each case Fragment must create and destroy its own `RtcEngine` instance. +- Always call `engine.leaveChannel()` before `RtcEngine.destroy()` in `onDestroy()`. +- Call `RtcEngine.destroy()` via `handler.post(RtcEngine::destroy)` — direct call blocks the main thread (ANR). +- All `IRtcEngineEventHandler` callbacks run on a background thread — use `runOnUIThread()` for UI updates. +- Always call `checkOrRequestPermission()` before `joinChannel()`. +- `setParameters(...)` is required in every case for backend reporting — do not omit it. +- Always null-check `getPrivateCloudConfig()` before calling `setLocalAccessPoint()` — returns null on non-private-cloud builds. + +## Skills + +| Skill | Path | Description | +|-------|------|-------------| +| upsert-case | `.agent/skills/upsert-case/` | Add a new case or modify an existing one | +| query-cases | `.agent/skills/query-cases/` | Query and browse existing cases | +| review-case | `.agent/skills/review-case/` | Review a case against project red lines | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, case registration internals, navigation details diff --git a/Android/APIExample/ARCHITECTURE.md b/Android/APIExample/ARCHITECTURE.md new file mode 100644 index 000000000..e541dfee6 --- /dev/null +++ b/Android/APIExample/ARCHITECTURE.md @@ -0,0 +1,215 @@ +# ARCHITECTURE.md — APIExample + +## Directory Layout + +``` +APIExample/ +├── gradle.properties # rtc_sdk_version, simpleFilter, streamEncrypt flags +├── agora-simple-filter/ # Optional C++ video extension module +├── agora-stream-encrypt/ # Optional custom stream encryption module +└── app/src/main/ + ├── AndroidManifest.xml + ├── assets/ # Audio/video sample files, beauty resources + ├── res/ + │ ├── navigation/nav_graph.xml # Single nav graph — all case destinations live here + │ ├── values/strings.xml # All display names and tips strings + │ └── layout/ # XML layouts for each case Fragment + └── java/io/agora/api/example/ + ├── MainApplication.java # Scans DEX and registers all @Example cases at startup + ├── MainActivity.java # Single-Activity host, owns NavController + ├── MainFragment.java # Home screen — renders BASIC / ADVANCED section list + ├── ReadyFragment.java # Splash / config check screen + ├── SettingActivity.java # Global settings (resolution, frame rate, area code) + │ + ├── annotation/ + │ └── Example.java # @Example annotation — the case registration contract + │ + ├── common/ + │ ├── BaseFragment.java # Base class ALL case Fragments must extend + │ ├── BaseVbFragment.java # ViewBinding variant of BaseFragment + │ ├── Constant.java # App-wide constants + │ ├── adapter/ + │ │ └── SectionAdapter.java # RecyclerView adapter for the grouped case list + │ ├── model/ + │ │ ├── Examples.java # Static registry: ITEM_MAP keyed by group name + │ │ ├── GlobalSettings.java # Video/audio config shared across cases + │ │ ├── ExampleBean.java + │ │ └── StatisticsInfo.java + │ ├── widget/ + │ │ ├── VideoReportLayout.java # Video container with stats overlay + │ │ ├── AudioOnlyLayout.java # Audio-only seat layout + │ │ ├── AudioSeatManager.java + │ │ └── WaveformView.java + │ ├── floatwindow/ # Floating window helper for in-call overlay + │ └── gles/ # OpenGL ES helpers for custom video rendering + │ + ├── examples/ # All cases live here — ClassUtils scans this package + │ ├── basic/ # group = "BASIC" (index 0–9) + │ │ ├── JoinChannelVideoByToken.java # [0] "Live Interactive Video Streaming(Token Verify)" + │ │ ├── JoinChannelVideo.java # [1] "Live Interactive Video Streaming" + │ │ └── JoinChannelAudio.java # [2] "Live Interactive Audio Streaming" + │ ├── advanced/ # group = "ADVANCED" (index 10+) + │ │ ├── LiveStreaming.java # [10] "RTC Live Streaming" — setClientRole, broadcaster/audience + │ │ ├── RTMPStreaming.java # [11] "Push Streams to CDN" — RTMP push streaming + │ │ ├── MediaMetadata.java # [12] "Media Metadata" — send/receive metadata in video stream + │ │ ├── VoiceEffects.java # [13] "Set the Voice Beautifier and Effects" — setVoiceBeautifierPreset + │ │ ├── customaudio/CustomAudioSource.java # [14] "Custom Audio Sources" — push external audio + │ │ ├── customaudio/CustomAudioRender.java # [15] "Custom Audio Render" — pull audio for custom rendering + │ │ ├── PushExternalVideoYUV.java # [16] "Custom Video Source" — push YUV external video + │ │ ├── CustomRemoteVideoRender.java # [17] "Custom Video Renderer" — custom remote video rendering + │ │ ├── ProcessAudioRawData.java # [18] "Raw Audio Data" — audio raw data processing + │ │ ├── MultiVideoSourceTracks.java # [19] "Multi Video Source Tracks" — multiple video sources + │ │ ├── ProcessRawData.java # [20] "Raw Video Data" — video raw data processing + │ │ ├── SimpleExtension.java # [21] "Simple Extension" — custom video extension + │ │ ├── PictureInPicture.java # [22] "Picture In Picture" — PiP mode + │ │ ├── FaceCapture.java # [23] "Face Capture" — face detection + │ │ ├── VideoQuickSwitch.java # [24] "Quick Switch Channel" — fast channel switching + │ │ ├── JoinMultipleChannel.java # [25] "Join Multiple Channel" — multi-channel join + │ │ ├── ChannelEncryption.java # [26] "Media Stream Encryption" — built-in encryption + │ │ ├── PlayAudioFiles.java # [27] "Play Audio Files" — audio mixing + │ │ ├── PreCallTest.java # [28] "Pre-call Tests" — network/device test before joining + │ │ ├── MediaPlayer.java # [29] "MediaPlayer" — play media files + │ │ ├── MediaRecorder.java # [30] "Local/Remote MediaRecorder" — record media streams + │ │ ├── ScreenSharing.java # [31] "Screen Sharing" — screen capture & share + │ │ ├── VideoProcessExtension.java # [32] "Video Process Extension" — video filter extension + │ │ ├── LocalVideoTranscoding.java # [33] "LocalVideoTranscoding" — local video compositing + │ │ ├── RhythmPlayer.java # [34] "Rhythm Player" — metronome/rhythm playback + │ │ ├── SendDataStream.java # [35] "Send Data Stream" — data channel messaging + │ │ ├── HostAcrossChannel.java # [36] "Relay Streams across Channels" — cross-channel relay + │ │ ├── SpatialSound.java # [37] "Spatial Audio" — 3D spatial audio + │ │ ├── ContentInspect.java # [38] "Content Inspect" — content moderation + │ │ ├── ThirdPartyBeauty.java # [39] "Third-party beauty" — third-party beauty SDK + │ │ ├── KtvCopyrightMusic.java # [40] "KTV Copyright Music" — licensed music + │ │ ├── TransparentRendering.java # [41] "TransparentRendering" — alpha channel rendering + │ │ ├── UrlLiveStream.java # [42] "Ultra Live Streaming with Url" — URL-based live stream + │ │ ├── AgoraBeauty.java # [43] "Agora beauty 2.0" — built-in beauty effects + │ │ ├── Simulcast.java # [44] "Simulcast" — multi-quality stream publishing + │ │ ├── Multipath.java # [45] "Multipath" — multi-path transmission + │ │ ├── beauty/ # Third-party beauty integrations + │ │ └── videoRender/ # Custom video rendering helpers + │ └── audio/ # Audio-specific cases (grouped as ADVANCED) + │ ├── AudioWaveform.java # [5] "Audio Waveform" — audio visualization + │ ├── AudioRouterPlayer.java # [6] "AudioRouter(Third Party Player)" — third-party audio routing + │ └── AudioRouterPlayer*.java # Exo / Ijk / Native variants + │ + ├── service/ + │ └── MediaProjectionService.java # Foreground service required for screen sharing + │ + └── utils/ + ├── ClassUtils.java # DEX scanner — auto-discovers @Example classes + ├── TokenUtils.java # Fetches RTC tokens from Agora token server + ├── PermissonUtils.java # Permission check/request helpers + ├── CommonUtil.java + ├── ErrorUtil.java + ├── FileUtils.java + ├── FileKtUtils.kt + ├── AudioFileReader.java + ├── VideoFileReader.java + └── YUVUtils.java +``` + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| Live Interactive Video Streaming(Token Verify) | `basic/JoinChannelVideoByToken.java` | `RtcEngine.create()`, `joinChannel()`, `setupLocalVideo()`, `enableVideo()`, `setVideoEncoderConfiguration()` | Demonstrates one-to-one video calling with manual App ID and token input | +| Live Interactive Video Streaming | `basic/JoinChannelVideo.java` | `RtcEngine.create()`, `joinChannel()`, `setupLocalVideo()`, `enableVideo()`, `setVideoEncoderConfiguration()` | Demonstrates basic one-to-one video calling with auto-generated token | +| Live Interactive Audio Streaming | `basic/JoinChannelAudio.java` | `RtcEngine.create()`, `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `muteLocalAudioStream()`, `enableInEarMonitoring()`, `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()` | Demonstrates audio-only calling with volume controls, in-ear monitoring, and audio routing | +| RTC Live Streaming | `advanced/LiveStreaming.java` | `setClientRole()`, `enableDualStreamMode()`, `startPreview()`, `preloadChannel()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()`, `addVideoWatermark()`, `setRemoteDefaultVideoStreamType()`, `takeSnapshot()`, `enableVideoImageSource()` | Demonstrates broadcaster/audience role switching with dual-stream, watermark, and snapshot features | +| Streaming from RTC to CDN | `advanced/RTMPStreaming.java` | `startRtmpStreamWithTranscoding()`, `startRtmpStreamWithoutTranscoding()`, `stopRtmpStream()`, `updateRtmpTranscoding()` | Demonstrates pushing media streams from RTC to a CDN via RTMP | +| Media Metadata | `advanced/MediaMetadata.java` | `registerMediaMetadataObserver()`, `sendAudioMetadata()` | Demonstrates sending and receiving metadata alongside video streams | +| Set the Voice Beautifier and Effects | `advanced/VoiceEffects.java` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAudioEffectParameters()`, `setLocalVoicePitch()`, `setLocalVoiceEqualization()`, `setLocalVoiceReverb()`, `setLocalVoiceFormant()`, `setAINSMode()`, `enableVoiceAITuner()` | Demonstrates voice beautifier presets, audio effects, voice conversion, and AI noise suppression | +| Custom Audio Sources | `advanced/customaudio/CustomAudioSource.java` | `createCustomAudioTrack()`, `pushExternalAudioFrame()`, `enableCustomAudioLocalPlayback()`, `destroyCustomAudioTrack()` | Demonstrates pushing external audio frames via a custom audio track | +| Custom Audio Render | `advanced/customaudio/CustomAudioRender.java` | `setExternalAudioSink()`, `pullPlaybackAudioFrame()` | Demonstrates pulling audio frames for custom audio rendering | +| Custom Video Source | `advanced/PushExternalVideoYUV.java` | `setExternalVideoSource()`, `pushExternalVideoFrame()`, `setExternalRemoteEglContext()` | Demonstrates pushing external YUV video frames as a custom video source | +| Custom Video Renderer | `advanced/CustomRemoteVideoRender.java` | `registerVideoFrameObserver()`, `setExternalRemoteEglContext()` | Demonstrates custom rendering of remote video streams via video frame observer | +| Raw Audio Data | `advanced/ProcessAudioRawData.java` | `registerAudioFrameObserver()`, `setRecordingAudioFrameParameters()`, `setPlaybackAudioFrameParameters()` | Demonstrates processing raw audio data through the audio frame observer | +| Multi Video Source Tracks | `advanced/MultiVideoSourceTracks.java` | `createCustomVideoTrack()`, `pushExternalVideoFrameById()`, `joinChannelEx()`, `destroyCustomVideoTrack()`, `createCustomEncodedVideoTrack()`, `pushExternalEncodedVideoFrameById()` | Demonstrates publishing multiple custom video tracks simultaneously | +| Raw Video Data | `advanced/ProcessRawData.java` | `registerVideoFrameObserver()`, `startPreview()` | Demonstrates processing raw video data through the video frame observer | +| Simple Extension | `advanced/SimpleExtension.java` | `enableExtension()`, `setExtensionProperty()`, `enableAudioVolumeIndication()` | Demonstrates loading and configuring a custom audio/video extension | +| Picture In Picture | `advanced/PictureInPicture.java` | `joinChannel()`, `setupLocalVideo()`, `enableVideo()` | Demonstrates Android Picture-in-Picture mode during a video call | +| Face Capture | `advanced/FaceCapture.java` | `enableExtension()`, `setExtensionProperty()`, `registerVideoFrameObserver()`, `registerFaceInfoObserver()` | Demonstrates face capture and lip-sync driven video using extensions | +| Quick Switch Channel | `advanced/VideoQuickSwitch.java` | `joinChannel()`, `leaveChannel()`, `startPreview()`, `setClientRole()` | Demonstrates fast channel switching for audience members | +| Join Multiple Channel | `advanced/JoinMultipleChannel.java` | `joinChannel()`, `joinChannelEx()`, `leaveChannelEx()`, `startPreview()`, `takeSnapshotEx()` | Demonstrates joining two channels simultaneously using RtcEngineEx | +| Media Stream Encryption | `advanced/ChannelEncryption.java` | `enableEncryption()` | Demonstrates built-in media stream encryption | +| Play Audio Files | `advanced/PlayAudioFiles.java` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()`, `getAudioEffectManager()`, `adjustAudioMixingVolume()` | Demonstrates audio mixing and sound effect playback | +| Pre-call Tests | `advanced/PreCallTest.java` | `startLastmileProbeTest()`, `stopLastmileProbeTest()`, `startEchoTest()`, `stopEchoTest()` | Demonstrates network quality probing and echo testing before joining a channel | +| MediaPlayer | `advanced/MediaPlayer.java` | `createMediaPlayer()`, `mediaPlayer.open()`, `mediaPlayer.play()`, `mediaPlayer.stop()`, `mediaPlayer.pause()`, `mediaPlayer.seek()`, `updateChannelMediaOptions()` | Demonstrates playing media files with the built-in media player | +| Local/Remote MediaRecorder | `advanced/MediaRecorder.java` | `createMediaRecorder()`, `destroyMediaRecorder()`, `startRecordingDeviceTest()` | Demonstrates recording local and remote media streams | +| Scree Sharing | `advanced/ScreenSharing.java` | `startScreenCapture()`, `stopScreenCapture()`, `updateScreenCaptureParameters()`, `setScreenCaptureScenario()` | Demonstrates screen capture and sharing during a video call | +| Video Enhancement | `advanced/VideoProcessExtension.java` | `setBeautyEffectOptions()`, `setFilterEffectOptions()`, `setLowlightEnhanceOptions()`, `setVideoDenoiserOptions()`, `setColorEnhanceOptions()`, `enableVirtualBackground()`, `setFaceShapeBeautyOptions()`, `setFaceShapeAreaOptions()` | Demonstrates built-in video enhancement including beauty, filter, denoising, and virtual background | +| LocalVideoTranscoding | `advanced/LocalVideoTranscoding.java` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()`, `stopScreenCapture()`, `enableVirtualBackground()` | Demonstrates compositing multiple local video sources into a single stream | +| Rhythm Player | `advanced/RhythmPlayer.java` | `startRhythmPlayer()`, `stopRhythmPlayer()`, `enableAudioVolumeIndication()` | Demonstrates metronome/rhythm playback synchronized with audio streaming | +| Send Data Stream | `advanced/SendDataStream.java` | `createDataStream()`, `sendStreamMessage()` | Demonstrates sending and receiving data channel messages | +| Relay Streams across Channels | `advanced/HostAcrossChannel.java` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Demonstrates relaying media streams from one channel to another | +| Spatial Audio | `advanced/SpatialSound.java` | `enableAudio()`, `setRemoteUserSpatialAudioParams()`, `createMediaPlayer()` | Demonstrates 3D spatial audio positioning for remote users | +| Content Inspect | `advanced/ContentInspect.java` | `enableContentInspect()` | Demonstrates real-time content moderation on video streams | +| Third-party beauty | `advanced/ThirdPartyBeauty.java` | `registerVideoFrameObserver()` | Demonstrates integration with third-party beauty SDKs (e.g. FaceUnity) | +| KTV Copyright Music | `advanced/KtvCopyrightMusic.java` | N/A (browser-based documentation link) | Demonstrates the KTV copyright music feature via documentation reference | +| TransparentRendering | `advanced/TransparentRendering.java` | `setExternalVideoSource()`, `pushExternalVideoFrame()`, `createMediaPlayer()`, `startPreview()` | Demonstrates alpha-channel transparent video rendering | +| Ultra Live Streaming with Url | `advanced/UrlLiveStream.java` | `Rte()`, `Player()`, `Canvas()`, `player.openWithUrl()`, `player.stop()` | Demonstrates ultra-low-latency live streaming playback via URL using the RTE SDK | +| Agora beauty 2.0 | `advanced/AgoraBeauty.java` | `enableVirtualBackground()`, `setFaceShapeAreaOptions()` | Demonstrates built-in Agora beauty effects with face shaping and virtual background | +| Simulcast | `advanced/Simulcast.java` | `setSimulcastConfig()`, `setRemoteVideoStreamType()` | Demonstrates publishing multiple quality streams with simulcast | +| Multipath | `advanced/Multipath.java` | `joinChannel()`, `updateChannelMediaOptions()` | Demonstrates multi-path transmission for improved network reliability | +| Audio Waveform | `audio/AudioWaveform.java` | `enableAudio()`, `enableAudioVolumeIndication()` | Demonstrates real-time audio waveform visualization | +| AudioRouter(Third Party Player) | `audio/AudioRouterPlayer.java` | `setEnableSpeakerphone()`, `joinChannel()` | Demonstrates audio routing with third-party media players (ExoPlayer, IjkPlayer, Native) | + +## Case Registration Mechanism + +Registration is **automatic via reflection**. No manual list to maintain. + +**Startup flow:** +1. `MainApplication.onCreate()` calls `ClassUtils.getFileNameByPackageName(context, "io.agora.api.example.examples")`. +2. `ClassUtils` scans all DEX entries whose class name starts with that prefix. +3. For each class, it checks for `@Example` annotation and calls `Examples.addItem(annotation)`. +4. `Examples.sortItem()` sorts each group by `index`. +5. `MainFragment` reads `Examples.ITEM_MAP` and renders the list. + +**`@Example` annotation — all four fields are required:** +```java +@Example( + index = 2, // sort order within the group; BASIC: 0–9, ADVANCED: 10+ + group = BASIC, // "BASIC" or "ADVANCED" + name = R.string.item_my_case, // display name string resource + actionId = R.id.action_mainFragment_to_myCase, // nav action ID in nav_graph.xml + tipsId = R.string.my_case_tips // description string resource +) +public class MyCase extends BaseFragment { … } +``` + +A missing or malformed annotation causes the case to silently not appear — no crash. + +## Navigation + +Single `nav_graph.xml` with Jetpack Navigation Component. + +Every case needs: +- A `` destination entry under the root `` in `nav_graph.xml` +- An `` inside `` — **not** `mainFragment`; `mainFragment` has only one action pointing to `Ready`, all case actions live in `Ready` +- The action `id` must exactly match `actionId` in `@Example` + +`MainActivity` calls `Navigation.findNavController(...).navigate(example.actionId())` on list item tap. + +## RtcEngine Lifecycle + +``` +onActivityCreated → RtcEngine.create() + → engine.setParameters / setVideoEncoderConfiguration + → joinChannel() (after permission granted) + ↓ + [IRtcEngineEventHandler callbacks — background thread] + ↓ +onDestroy → engine.leaveChannel() + → RtcEngine.destroy() + → engine = null +``` + +## Token Flow + +```java +TokenUtils.gen(requireContext(), channelId, uid, token -> { + engine.joinChannel(token, channelId, uid, options); +}); +``` + +`TokenUtils` reads `AGORA_APP_ID` and `AGORA_APP_CERT` from `local.properties` via `BuildConfig`. If `AGORA_APP_CERT` is empty, token generation is skipped — valid for projects without certificate. diff --git a/Android/APIExample/CLAUDE.md b/Android/APIExample/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/Android/APIExample/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/Android/APIExample/README.md b/Android/APIExample/README.md index ee36e5f0c..fb949216c 100644 --- a/Android/APIExample/README.md +++ b/Android/APIExample/README.md @@ -23,16 +23,16 @@ To build and run the sample application, get an App Id: 3. Save the **App Id** from the Dashboard for later use. 4. Save the **App Certificate** from the Dashboard for later use. -5. Open `Android/APIExample` and edit the `app/src/main/res/values/string-configs.xml` file. Update `YOUR APP ID` with your App Id, update `YOUR APP CERTIFICATE` with the main app certificate from dashboard. Note you can leave the certificate variable `null` if your project has not turned on security token. +5. Open `Android/APIExample` and edit the `local.properties` file in the project root. Update `YOUR APP ID` with your App Id. If your Agora project has App Certificate enabled and you want to use the sample's built-in token generation flow, update `YOUR APP CERTIFICATE` as well. ``` - // Agora APP ID. - YOUR APP ID - // Agora APP Certificate. If the project does not have certificates enabled, leave this field blank. - // PS:It is unsafe to place the App Certificate on the client side, it is recommended to place it on the server side to ensure that the App Certificate is not leaked. - YOUR APP CERTIFICATE + sdk.dir=/path/to/Android/sdk + AGORA_APP_ID=YOUR APP ID + AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` is required. If your project does not enable App Certificate, leave `AGORA_APP_CERT` blank. If you generate tokens on your own server, keep `AGORA_APP_CERT` empty on the client side and use the `ByToken` examples to paste the token at runtime. + You are all set. Now connect your Android device and run the project. ### Beauty Configuration @@ -68,22 +68,6 @@ follows: | sticker resource(e.g. fashi.bundle) | app/src/main/assets/beauty_faceunity/sticker | | authpack.java | app/src/main/java/io/agora/api/example/examples/advanced/beauty/authpack.java | -#### ByteDance - -1. Contact ByteDance customer service to obtain the download link and certificate of the beauty sdk -2. Unzip the ByteDance beauty resource and copy the following files/directories to the corresponding path - -| ByteDance Beauty Resources | Location | -|---------------------------------|--------------------------------------| -| resource/LicenseBag.bundle | app/src/main/assets/beauty_bytedance | -| resource/ModelResource.bundle | app/src/main/assets/beauty_bytedance | -| resource/ComposeMakeup.bundle | app/src/main/assets/beauty_bytedance | -| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance | -| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance | - -3. Modify the LICENSE_NAME in the app/src/main/java/io/agora/api/example/examples/advanced/beauty/ByteDanceBeauty.java file to the name of the applied certificate file. - - ### For Agora Extension Developers Since version 4.0.0, Agora SDK provides an Extension Interface Framework. Developers could publish their own video/audio extension to Agora Extension Market. In this project includes a sample SimpleFilter example, by default it is disabled. diff --git a/Android/APIExample/README.zh.md b/Android/APIExample/README.zh.md index 17d1953b2..1f804ea25 100644 --- a/Android/APIExample/README.zh.md +++ b/Android/APIExample/README.zh.md @@ -23,16 +23,16 @@ 3. 复制后台的 **App Id** 并备注,稍后启动应用时会用到它 4. 复制后台的 **App 证书** 并备注,稍后启动应用时会用到它 -5. 打开 `Android/APIExample` 并编辑 `app/src/main/res/values/string-configs.xml`,将你的 AppID 、App主证书 分别替换到 `Your App Id` 和 `YOUR APP CERTIFICATE` +5. 打开 `Android/APIExample` 并编辑项目根目录下的 `local.properties`,填入你的 App ID。如果你的 Agora 项目开启了 App Certificate,并且你希望使用示例内置的 token 生成功能,再填入 `YOUR APP CERTIFICATE` ``` - // 声网APP ID。 - YOUR APP ID - // 声网APP证书。如果项目没有开启证书鉴权,这个字段留空。 - // 注意:App证书放在客户端不安全,推荐放在服务端以确保 App 证书不会泄露。 - YOUR APP CERTIFICATE + sdk.dir=/path/to/Android/sdk + AGORA_APP_ID=YOUR APP ID + AGORA_APP_CERT=YOUR APP CERTIFICATE ``` +`AGORA_APP_ID` 为必填项。如果你的项目没有开启 App Certificate,`AGORA_APP_CERT` 留空即可。如果你使用自己的服务端生成 token,建议不要在客户端填写 `AGORA_APP_CERT`,直接使用 `ByToken` 系列示例在运行时粘贴 token。 + 然后你就可以编译并运行项目了。 ### 美颜配置 @@ -65,22 +65,6 @@ | 贴纸资源(如fashi.bundle) | app/src/main/assets/beauty_faceunity/sticker | | 证书authpack.java | app/src/main/java/io/agora/api/example/examples/advanced/beauty/authpack.java | -#### 字节美颜 - -1. 联系字节客服获取美颜sdk下载链接以及证书 -2. 解压字节/火山美颜资源并复制以下文件/目录到对应路径下 - -| 字节SDK文件/目录 | 项目路径 | -|--------------------------------------------------|-------------------------------------------------------| -| resource/LicenseBag.bundle | app/src/main/assets/beauty_bytedance | -| resource/ModelResource.bundle | app/src/main/assets/beauty_bytedance | -| resource/ComposeMakeup.bundle | app/src/main/assets/beauty_bytedance | -| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance | -| resource/StickerResource.bundle | app/src/main/assets/beauty_bytedance | - -3. -修改app/src/main/java/io/agora/api/example/examples/advanced/beauty/ByteDanceBeauty.java文件里LICENSE_NAME为申请到的证书文件名 - ### 对于Agora Extension开发者 从4.0.0SDK开始,Agora SDK支持插件系统和开放的云市场帮助开发者发布自己的音视频插件,本项目包含了一个SimpleFilter示例,默认是禁用的状态,如果需要开启编译和使用需要完成以下步骤: diff --git a/Android/ARCHITECTURE.md b/Android/ARCHITECTURE.md new file mode 100644 index 000000000..c709c9421 --- /dev/null +++ b/Android/ARCHITECTURE.md @@ -0,0 +1,38 @@ +# ARCHITECTURE.md + +Three independent Android projects, each with its own Gradle root and APK output. +For internal details of each project, see the project-level `ARCHITECTURE.md`. + +--- + +## APIExample — Full Demo + +- Package: `io.agora.api.example` +- SDK: `cn.shengwang.rtc:full-sdk` + `full-screen-sharing` +- Language: Java + Kotlin mixed +- UI: XML layouts + ViewBinding, Jetpack Navigation +- Case registration: reflection-based via `@Example` annotation + `ClassUtils` DEX scan +- Optional modules: `agora-simple-filter` (C++ extension), `agora-stream-encrypt` +- Details: `APIExample/ARCHITECTURE.md` + +--- + +## APIExample-Audio — Audio-Only Demo + +- Package: `io.agora.api.example.audio` +- SDK: `cn.shengwang.rtc:voice-sdk` (no video module) +- Language: Java + Kotlin mixed +- UI: XML layouts + ViewBinding, Jetpack Navigation +- Case registration: identical to APIExample — `@Example` annotation + `ClassUtils` DEX scan +- Details: `APIExample-Audio/ARCHITECTURE.md` + +--- + +## APIExample-Compose — Jetpack Compose Demo + +- Package: `io.agora.api.example.compose` +- SDK: `cn.shengwang.rtc:full-sdk` + `full-screen-sharing` +- Language: Kotlin only +- UI: Jetpack Compose + Compose Navigation, no XML layouts +- Case registration: manual — add entry to `model/Examples.kt` + create `samples/MyCase.kt` +- Details: `APIExample-Compose/ARCHITECTURE.md` diff --git a/Android/CLAUDE.md b/Android/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/Android/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..50fc67993 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` as the primary reference for AI agents. + +Please see @AGENTS.md in this same directory and treat its content as the authoritative guide for navigating and working within this repository. diff --git a/iOS/AGENTS.md b/iOS/AGENTS.md new file mode 100644 index 000000000..cca86ab28 --- /dev/null +++ b/iOS/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md + +Entry point for AI agents working on iOS examples. Read this first, then go to the relevant project's own `AGENTS.md`. + +## Projects + +| Project | SDK | Purpose | +|---------|-----|---------| +| `APIExample/` | `AgoraRtcEngine_iOS` | Full demo — all APIs, UIKit + Swift, default choice | +| `APIExample-SwiftUI/` | `AgoraRtcEngine_iOS` | SwiftUI variant, mirrors APIExample cases | +| `APIExample-OC/` | `AgoraRtcEngine_iOS` | Objective-C variant, mirrors APIExample cases | +| `APIExample-Audio/` | `AgoraAudio_iOS` | Audio-only — no video APIs available | + +SDK version: each project's `Podfile` specifies the version. + +## Which Project to Use + +- Need video call, screen sharing, beauty filters, or extensions → `APIExample/` +- Audio-only features (voice call, audio effects, spatial audio) → `APIExample-Audio/` +- Building with SwiftUI, or porting an existing case to SwiftUI → `APIExample-SwiftUI/` +- Need Objective-C implementation → `APIExample-OC/` +- Not sure → default to `APIExample/` + +## Architecture Red Lines + +- Do NOT share source files, storyboards, or SDK dependencies between projects +- Do NOT add video rendering APIs (`enableVideo`, `setupLocalVideo`) to `APIExample-Audio/` +- Do NOT call SDK APIs on a background thread without dispatching UI updates to the main thread +- Do NOT commit `KeyCenter.swift` / `KeyCenter.m` with real App IDs or certificates +- Always call `leaveChannel()` and `AgoraRtcEngineKit.destroy()` when an example screen is closed + +## Further Reading + +- `ARCHITECTURE.md` — four-project structure overview +- `APIExample/AGENTS.md` — build commands, config, Skills for the full demo +- `APIExample-SwiftUI/AGENTS.md` — same for the SwiftUI demo +- `APIExample-OC/AGENTS.md` — same for the Objective-C demo +- `APIExample-Audio/AGENTS.md` — same for the audio demo diff --git a/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..6b6676b67 --- /dev/null +++ b/iOS/APIExample-Audio/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,47 @@ +--- +name: query-cases +description: > + Find existing audio API demo cases in the APIExample-Audio project by feature name, API name, or keyword. + Use this before creating a new case to avoid duplication. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# query-cases — APIExample-Audio + +## When to Use + +- User asks "where is the voice changer example?" +- User wants to find code for a specific Agora audio API +- Before creating a new case, to confirm it does not already exist + +## Quick Search (try this first) + +Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. This project has 11 cases total, so the Case Index is the fastest lookup. + +## Deep Search (for complex queries) + +1. Check `APIExample-Audio/ViewController.swift` — the `menus` array lists all registered cases +2. Source files are at: + - `APIExample-Audio/Examples/Basic//.swift` + - `APIExample-Audio/Examples/Advanced//.swift` + +## Common Query Patterns + +| Query | Where to look | +|-------|--------------| +| Feature by name (e.g. "spatial audio") | Case Index — search Description column | +| API by method name (e.g. `setAudioProfile`) | Case Index — search Key APIs column | +| All basic cases | Case Index — filter by Path prefix `Basic/` | +| Cases using custom audio | Case Index — search for `CustomAudio` or `pushExternal` | + +## Output Format + +Report results as: +- Case name and file path +- Key APIs demonstrated +- One-line description diff --git a/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md b/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..c1cb428f8 --- /dev/null +++ b/iOS/APIExample-Audio/.agent/skills/review-case/SKILL.md @@ -0,0 +1,131 @@ +--- +name: review-case +description: > + Structured code review for a case in the APIExample-Audio project. + Checks engine lifecycle, audio-only constraints, thread safety, permissions, and API correctness. + This project uses AgoraAudio_iOS — video APIs must not appear. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# review-case — APIExample-Audio + +## Review Dimensions (in priority order) + +### 1. Audio-Only Constraint (highest priority for this project) + +**Check:** +- No calls to `enableVideo()`, `disableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, `startPreview()`, `stopPreview()` +- No `AgoraRtcVideoCanvas` instantiation +- No `VideoView` or video rendering views in storyboard or code +- No camera permission requests + +Any video API call in this project is a critical error — the SDK will crash or silently fail. + +--- + +### 2. Engine Lifecycle + +**Check:** +- `AgoraRtcEngineKit.sharedEngine(with:delegate:)` called in `viewDidLoad` (not in Entry VC) +- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil` +- No engine instance stored beyond the Main VC's lifetime + +**Correct:** +```swift +override func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) + if parent == nil { + agoraKit?.leaveChannel() + AgoraRtcEngineKit.destroy() + } +} +``` + +--- + +### 3. Thread Safety + +All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread. + +**Check:** +- Every UI update inside a delegate callback is wrapped in `DispatchQueue.main.async { }` +- No UIKit objects mutated directly in callbacks + +--- + +### 4. Permissions + +**Check:** +- Microphone permission requested before `joinChannel()` +- `joinChannel()` called only inside the permission grant callback +- No camera permission requests (audio-only project) + +--- + +### 5. Error Handling + +**Check:** +- Return value of `joinChannel()` checked +- `rtcEngine(_:didOccurError:)` implemented and logged +- Token expiry handled if token is used + +--- + +### 6. Code Conventions + +**Check:** +- Entry class inherits `UIViewController`, Main class inherits `BaseViewController` +- Class names follow `Entry` / `Main` pattern +- `configs` dictionary used to pass data from Entry to Main +- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section +- Storyboard contains only audio controls (labels, sliders, buttons) — no video views + +--- + +### 7. Audio API Usage + +**Check:** +- `setAudioProfile(_:)` called before `joinChannel()` if non-default profile needed +- `setAudioScenario(_:)` called before `joinChannel()` if non-default scenario needed +- `enableAudioVolumeIndication(_:smooth:reportVad:)` called if volume callbacks are needed +- Custom audio tracks stopped and released on exit +- External audio sinks disabled on exit if `enableExternalAudioSink` was called + +--- + +### 8. Resource Cleanup + +**Check:** +- Audio mixing stopped (`stopAudioMixing()`) if started +- Rhythm player stopped (`stopRhythmPlayer()`) if started +- Echo test stopped (`stopEchoTest()`) if started +- Last-mile probe stopped (`stopLastmileProbeTest()`) if started +- Custom audio tracks destroyed on exit + +--- + +## Review Output Format + +``` +[SEVERITY] file/line — issue description +Suggestion: how to fix +``` + +Severity levels: +- `[CRITICAL]` — crash, leak, video API in audio-only project, or incorrect behavior +- `[WARNING]` — convention violation or subtle bug risk +- `[INFO]` — style or minor improvement + +--- + +## Audio-Specific iOS Checks + +- `AVAudioSession` category should be `.playAndRecord` with `.defaultToSpeaker` option for most audio cases +- Background audio: verify `UIBackgroundModes` includes `audio` in `Info.plist` if background playback is needed +- In-ear monitoring (`enable(inEarMonitoring:)`) only works with wired headphones — document this limitation in the case if relevant +- `[weak self]` required in all closures capturing `self` to avoid retain cycles diff --git a/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..3e59cbd14 --- /dev/null +++ b/iOS/APIExample-Audio/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,158 @@ +--- +name: upsert-case +description: > + Add a new audio API demo case or modify an existing one in the APIExample-Audio project. + Uses AgoraAudio_iOS SDK — no video APIs available. Covers folder creation, Entry/Main Swift file, + storyboard, MenuItem registration, and Case Index update. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# upsert-case — APIExample-Audio + +## When to Use + +- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/` +- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+ + +Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist. + +> **Audio-only constraint**: this project uses `AgoraAudio_iOS` SDK. The video module is not available. +> Do NOT add any video API calls. See the NEVER list below. + +## Files to Touch + +| Scenario | Files | +|----------|-------| +| Add new case | New folder + `.swift` file + `.storyboard`, `ViewController.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) | +| Modify existing case | Existing `.swift` file(s), optionally `.storyboard`, `ARCHITECTURE.md` (Case Index) | + +--- + +## Step 1 — Create the Example Folder + +``` +APIExample-Audio/Examples/[Basic|Advanced]// +``` + +## Step 2 — Create the Swift File + +Create `.swift` with Entry and Main classes: + +```swift +import UIKit +import AgoraRtcKit + +class Entry: UIViewController { + @IBOutlet weak var channelTextField: UITextField! + + @IBAction func onJoinPressed(_ sender: UIButton) { + guard let channelName = channelTextField.text, !channelName.isEmpty else { return } + let storyboard = UIStoryboard(name: "", bundle: nil) + guard let mainVC = storyboard.instantiateViewController( + withIdentifier: "") as? Main else { return } + mainVC.configs = ["channelName": channelName] + navigationController?.pushViewController(mainVC, animated: true) + } +} + +class Main: BaseViewController { + var agoraKit: AgoraRtcEngineKit? + + override func viewDidLoad() { + super.viewDidLoad() + guard let channelName = configs["channelName"] as? String else { return } + let config = AgoraRtcEngineConfig() + config.appId = KeyCenter.AppId + agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self) + agoraKit?.setAudioProfile(.default) + // request microphone permission, then join + NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in + let option = AgoraRtcChannelMediaOptions() + option.publishMicrophoneTrack = true + self?.agoraKit?.joinChannel(byToken: token, channelId: channelName, + uid: 0, mediaOptions: option) + } + } + + override func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) + if parent == nil { + agoraKit?.leaveChannel() + AgoraRtcEngineKit.destroy() + } + } +} + +extension Main: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, + withUid uid: UInt, elapsed: Int) { + LogUtils.log(message: "Joined: \(channel) uid: \(uid)", level: .info) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + LogUtils.log(message: "Error: \(errorCode.rawValue)", level: .error) + } +} +``` + +## Step 3 — Create the Storyboard + +Create `APIExample-Audio/Base.lproj/.storyboard` with two scenes: + +| Scene | Storyboard ID | Class | +|-------|--------------|-------| +| Entry | `EntryViewController` | `Entry` | +| Main | `` | `Main` | + +UI should contain only audio controls — no video rendering views. + +## Step 4 — Register the MenuItem + +Add to the `menus` array in `APIExample-Audio/ViewController.swift`: + +```swift +MenuItem(name: "".localized, + storyboard: "", + controller: "") +``` + +## Step 5 — Update the Case Index + +Add a row to the `## Case Index` table in `ARCHITECTURE.md`: + +```markdown +| | `Examples/[Basic|Advanced]//.swift` | `keyApi1()`, `keyApi2()` | One-line description | +``` + +--- + +## Verification Checklist + +- [ ] Folder created under correct category (Basic / Advanced) +- [ ] Both Entry and Main classes exist in the Swift file +- [ ] Main inherits `BaseViewController` +- [ ] Storyboard has correct scene IDs +- [ ] No video rendering views in the storyboard +- [ ] MenuItem added to `ViewController.swift` +- [ ] `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil` +- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main` +- [ ] Microphone permission requested before `joinChannel()` +- [ ] Case Index row added/updated in `ARCHITECTURE.md` +- [ ] Project builds without errors + +--- + +## NEVER + +- NEVER call `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()`, or `startPreview()` — the SDK has no video module +- NEVER add `AgoraRtcVideoCanvas` or `VideoView` to any storyboard or code in this project +- NEVER create `AgoraRtcEngineKit` in the Entry VC +- NEVER call `leaveChannel` or `destroy` in `viewDidDisappear` — use `willMove(toParent:)` with `parent == nil` +- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }` +- NEVER share an `AgoraRtcEngineKit` instance between cases +- NEVER skip updating the Case Index in `ARCHITECTURE.md` diff --git a/iOS/APIExample-Audio/AGENTS.md b/iOS/APIExample-Audio/AGENTS.md new file mode 100644 index 000000000..f42c6f8a8 --- /dev/null +++ b/iOS/APIExample-Audio/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md — APIExample-Audio + +Audio-only demo project. Uses `AgoraAudio_iOS` SDK — the video module is not included. + +## Build Commands + +```bash +pod install +# Then open APIExample-Audio.xcworkspace in Xcode and build (Cmd+B) +``` + +## App ID Configuration + +Edit `APIExample-Audio/Common/KeyCenter.swift`: +```swift +static let AppId: String = "YOUR_APP_ID" +static let Certificate: String? = nil // leave nil if App Certificate is not enabled +``` + +To obtain an App ID, see [README.md](README.md#obtain-an-app-id). + +## Skills + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new audio API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and audio-only convention compliance | +| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle diff --git a/iOS/APIExample-Audio/ARCHITECTURE.md b/iOS/APIExample-Audio/ARCHITECTURE.md new file mode 100644 index 000000000..823be6545 --- /dev/null +++ b/iOS/APIExample-Audio/ARCHITECTURE.md @@ -0,0 +1,131 @@ +# ARCHITECTURE.md — APIExample-Audio + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.swift` | `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()`, `enable(inEarMonitoring:)` | Basic audio call with profile, scenario, volume, and in-ear monitoring controls | +| JoinChannelAudio(Token) | `Examples/Basic/JoinChannelAudio(Token)/JoinChannelAudioToken.swift` | `joinChannel(byToken:)`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()` | Audio call with token authentication | +| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.swift` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setLocalVoiceEqualizationOf()` | Voice beautifier, effects, conversion presets, and equalizer | +| CustomAudioSource | `Examples/Advanced/CustomAudioSource/CustomAudioSource.swift` | `setExternalAudioSource()` | Push custom audio via external audio source API | +| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.swift` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as mixable audio track | +| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.swift` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering | +| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.swift` | `setAudioFrameDelegate()` | Capture raw audio PCM data via delegate | +| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.swift` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input | +| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.swift` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio | +| PrecallTest | `Examples/Advanced/PrecallTest/PrecallTest.swift` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()` | Pre-call echo test and last-mile network probe | +| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.swift` | `createMediaPlayer()`, `updateChannel()`, `setEnableSpeakerphone()` | 3D spatial audio with media player integration | + +## Directory Layout + +``` +APIExample-Audio/ +├── Podfile # CocoaPods dependencies (AgoraAudio_iOS, Floaty, AGEVideoLayout) +└── APIExample-Audio/ + ├── AppDelegate.swift + ├── ViewController.swift # Root menu controller — MenuItem registration lives here + ├── Info.plist + ├── APIExample.entitlements + ├── APIExample-Bridging-Header.h + │ + ├── Common/ + │ ├── KeyCenter.swift # App ID and Certificate + │ ├── GlobalSettings.swift # Shared runtime config + │ ├── BaseViewController.swift # Base class all Main VCs must extend + │ ├── EntryViewController.swift # Generic Entry VC for storyboard == "Main" cases + │ ├── LogViewController.swift # Log viewer + │ ├── AlertManager.swift + │ ├── AgoraExtension.swift + │ ├── StatisticsInfo.swift + │ ├── UITypeAlias.swift + │ ├── VideoView.swift / .xib # Audio seat view (no video rendering) + │ ├── Settings/ # Settings UI components + │ ├── Utils/ # LogUtils, Util (privatization config) + │ ├── NetworkManager/ # Token request helper + │ ├── ExternalAudio/ # External audio source helpers + │ └── ExternalVideo/ # (unused in audio project) + │ + ├── Examples/ + │ ├── Basic/ + │ │ ├── JoinChannelAudio/ # "Join a channel (Audio)" + │ │ └── JoinChannelAudio(Token)/ # "Join a channel (Token)" + │ └── Advanced/ + │ ├── VoiceChanger/ # "Voice Changer" — voice beautifier/effects + │ ├── CustomAudioSource/ # "Custom Audio Source" + │ ├── CustomPcmAudioSource/ # "Custom Audio Source (PCM)" + │ ├── CustomAudioRender/ # "Custom Audio Render" + │ ├── RawAudioData/ # "Raw Audio Data" + │ ├── AudioMixing/ # "Audio Mixing" + │ ├── RhythmPlayer/ # "Rhythm Player" + │ ├── PrecallTest/ # "Precall Test" + │ └── SpatialAudio/ # "Spatial Audio" + │ + ├── Resources/ # Audio sample files + ├── Assets.xcassets/ + ├── Base.lproj/ # Main.storyboard, LaunchScreen.storyboard + └── zh-Hans.lproj/ # Chinese localization +``` + +## Case Registration Mechanism + +Registration is **manual** via the `menus` array in `ViewController.swift`. Identical to `APIExample`. + +**`MenuItem` struct:** +```swift +struct MenuItem { + var name: String // display name in the list + var entry: String // storyboard ID of the entry VC (default: "EntryViewController") + var storyboard: String // storyboard file name (default: "Main") + var controller: String // storyboard ID of the main VC + var note: String // optional description +} +``` + +**To add a case, edit exactly two things:** +1. Add a `MenuItem` to the `menus` array in `ViewController.swift` +2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift file(s) and storyboard + +## Entry/Main ViewController Pattern + +Identical to `APIExample`: + +**Entry** (`Entry : UIViewController`) +- Collects user configuration before entering the example +- Passes configuration to Main via a `configs` dictionary + +**Main** (`Main : BaseViewController`) +- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example +- Implements `AgoraRtcEngineDelegate` +- Receives configuration exclusively through `configs` +- UI contains only audio controls — no video rendering views + +## Audio-Only Constraint + +This project uses `AgoraAudio_iOS` SDK which has no video module. Main view controllers must NOT include: +- Video rendering views or video canvas setup +- Calls to `enableVideo()`, `setupLocalVideo()`, `setupRemoteVideo()` +- Camera-related APIs + +All UI is limited to audio controls, status indicators, and effect parameter inputs. + +## AgoraRtcEngineKit Lifecycle + +``` +viewDidLoad → AgoraRtcEngineKit.sharedEngine(withAppId:delegate:) + → engine.setAudioProfile / setAudioScenario + → engine.joinChannel() (after RECORD_AUDIO permission granted) + ↓ + [AgoraRtcEngineDelegate callbacks — may be on background thread] + ↓ +viewDidDisappear / willMove(toParent:) + → engine.leaveChannel() + → AgoraRtcEngineKit.destroy() +``` + +## Token Flow + +```swift +NetworkManager.shared.generateToken(channelName: channelId, uid: uid) { token in + self.agoraKit?.joinChannel(byToken: token, channelId: channelId, uid: uid, mediaOptions: options) +} +``` diff --git a/iOS/APIExample-Audio/CLAUDE.md b/iOS/APIExample-Audio/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/iOS/APIExample-Audio/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..8817e67f1 --- /dev/null +++ b/iOS/APIExample-OC/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,50 @@ +--- +name: query-cases +description: > + Find existing API demo cases in the APIExample-OC project by feature name, API name, or keyword. + Use this before creating a new case to avoid duplication. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# query-cases — APIExample-OC + +## When to Use + +- User asks "where is the screen sharing example?" +- User wants to find code for a specific Agora SDK API +- Before creating a new case, to confirm it does not already exist + +## Quick Search (try this first) + +Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file. + +## Deep Search (for complex queries) + +1. Check `APIExample-OC/ViewController.m` — the `+[MenuSection menus]` method lists all registered cases +2. Source files are at: + - `APIExample-OC/Examples/Basic//.m` + - `APIExample-OC/Examples/Advanced//.m` +3. Each case folder contains: + - `.h` — class declarations + - `.m` — Entry and Main implementations + +## Common Query Patterns + +| Query | Where to look | +|-------|--------------| +| Feature by name | Case Index — search Description column | +| API by method name (OC syntax) | Case Index — search Key APIs column; or grep `.m` files | +| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` | +| Cases using a specific pattern | Grep `.m` files under `Examples/` | + +## Output Format + +Report results as: +- Case name and file path +- Key APIs demonstrated (OC method syntax) +- One-line description diff --git a/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md b/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..2c9d57145 --- /dev/null +++ b/iOS/APIExample-OC/.agent/skills/review-case/SKILL.md @@ -0,0 +1,158 @@ +--- +name: review-case +description: > + Structured code review for a case in the APIExample-OC (Objective-C + UIKit) project. + Checks engine lifecycle, thread safety, memory management, permissions, and OC conventions. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# review-case — APIExample-OC + +## Review Dimensions (in priority order) + +### 1. Engine Lifecycle + +**Check:** +- `[AgoraRtcEngineKit sharedEngineWithConfig:delegate:]` called in `viewDidLoad` (not in Entry VC) +- `[self.agoraKit leaveChannel:]` + `[AgoraRtcEngineKit destroy]` called when leaving +- Cleanup triggered by `isMovingFromParentViewController` in `viewDidDisappear:`, or in `dealloc` + +**Correct:** +```objc +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + if (self.isMovingFromParentViewController) { + [self.agoraKit leaveChannel:nil]; + [AgoraRtcEngineKit destroy]; + } +} +``` + +**Wrong:** +```objc +// Missing destroy — engine leaks +- (void)viewDidDisappear:(BOOL)animated { + [self.agoraKit leaveChannel:nil]; +} +``` + +--- + +### 2. Thread Safety + +All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread. + +**Check:** +- Every UI update inside a delegate callback is wrapped in `dispatch_async(dispatch_get_main_queue(), ^{ })` +- No `UIView` or other UIKit objects mutated directly in callbacks + +**Correct:** +```objc +- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinedOfUid:(NSUInteger)uid elapsed:(NSInteger)elapsed { + dispatch_async(dispatch_get_main_queue(), ^{ + [self setupRemoteVideoWithUid:uid]; + }); +} +``` + +**Wrong:** +```objc +- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinedOfUid:(NSUInteger)uid elapsed:(NSInteger)elapsed { + [self setupRemoteVideoWithUid:uid]; // UI update on background thread +} +``` + +--- + +### 3. Memory Management + +**Check:** +- `__weak typeof(self) weakSelf = self` used in all blocks that capture `self` +- Delegate property on `AgoraRtcEngineKit` is `weak` (it is by SDK design, but verify no strong cycle) +- No `__unsafe_unretained` used for delegate or view references + +**Correct:** +```objc +__weak typeof(self) weakSelf = self; +[[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) { + [weakSelf.agoraKit joinChannelByToken:token ...]; +}]; +``` + +--- + +### 4. Permissions + +**Check:** +- Camera permission requested before `joinChannelByToken:` for video cases +- Microphone permission requested before `joinChannelByToken:` for all cases +- `joinChannelByToken:` called only inside the permission grant callback + +--- + +### 5. Error Handling + +**Check:** +- Return value of `joinChannelByToken:` checked (non-zero = error) +- `rtcEngine:didOccurError:` delegate method implemented and logged +- Token expiry handled via `rtcEngine:tokenPrivilegeWillExpire:` if token is used + +--- + +### 6. Code Conventions + +**Check:** +- Entry class inherits `UIViewController`, Main class inherits `BaseViewController` +- Class names follow `Entry` / `Main` pattern +- `configs` dictionary (`NSDictionary`) used to pass data from Entry to Main +- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section +- Both `.h` and `.m` files present; public interface minimal in `.h` + +--- + +### 7. API Usage Correctness + +**Check:** +- `setVideoEncoderConfiguration:` called before `joinChannelByToken:` +- `setupLocalVideo:` called before `startPreview` and `joinChannelByToken:` +- `enableVideo` called before `setupLocalVideo:` for video cases +- `setClientRole:` called before `joinChannelByToken:` for live streaming cases + +--- + +### 8. Resource Cleanup + +**Check:** +- Audio files / custom audio tracks stopped and released on exit +- External video sources unregistered on exit +- Media player destroyed if created (`[self.agoraKit destroyMediaPlayer:player]`) +- Screen capture stopped if started +- Multi-camera capture stopped if started + +--- + +## Review Output Format + +``` +[SEVERITY] file/line — issue description +Suggestion: how to fix +``` + +Severity levels: +- `[CRITICAL]` — crash, leak, or incorrect behavior +- `[WARNING]` — convention violation or subtle bug risk +- `[INFO]` — style or minor improvement + +--- + +## OC-Specific Checks + +- Verify `NS_ASSUME_NONNULL_BEGIN/END` wraps the header to reduce nullability warnings +- Verify `IBOutlet` properties are `weak` (Xcode default, but worth confirming) +- `isMovingFromParentViewController` is the correct guard in `viewDidDisappear:` for navigation-based cleanup — do NOT use `isBeingDismissed` (that's for modal presentation) +- ARC is enabled — no manual `retain`/`release` calls should appear diff --git a/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..92a9c1d7c --- /dev/null +++ b/iOS/APIExample-OC/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,173 @@ +--- +name: upsert-case +description: > + Add a new API demo case or modify an existing one in the APIExample-OC (Objective-C + UIKit) project. + Covers folder creation, Entry/Main OC files, storyboard, MenuItem registration, and Case Index update. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# upsert-case — APIExample-OC + +## When to Use + +- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/` +- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+ + +Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist. + +## Files to Touch + +| Scenario | Files | +|----------|-------| +| Add new case | New folder + `.h/.m` files + `.storyboard`, `ViewController.m` (MenuItem), `ARCHITECTURE.md` (Case Index) | +| Modify existing case | Existing `.h/.m` files, optionally `.storyboard`, `ARCHITECTURE.md` (Case Index) | + +--- + +## Step 1 — Create the Example Folder + +``` +APIExample-OC/Examples/[Basic|Advanced]// +``` + +## Step 2 — Create the Header File + +Create `.h`: + +```objc +#import "BaseViewController.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface Entry : UIViewController +@end + +@interface Main : BaseViewController +@end + +NS_ASSUME_NONNULL_END +``` + +## Step 3 — Create the Implementation File + +Create `.m`: + +```objc +#import ".h" +#import +#import "KeyCenter.h" +#import "NetworkManager.h" + +@interface Entry () +@property (weak, nonatomic) IBOutlet UITextField *channelTextField; +@end + +@implementation Entry +- (IBAction)onJoinPressed:(UIButton *)sender { + NSString *channelName = self.channelTextField.text; + if (channelName.length == 0) return; + UIStoryboard *sb = [UIStoryboard storyboardWithName:@"" bundle:nil]; + Main *mainVC = [sb instantiateViewControllerWithIdentifier:@""]; + mainVC.configs = @{@"channelName": channelName}; + [self.navigationController pushViewController:mainVC animated:YES]; +} +@end + +@interface Main () +@property (nonatomic, strong) AgoraRtcEngineKit *agoraKit; +@end + +@implementation Main +- (void)viewDidLoad { + [super viewDidLoad]; + NSString *channelName = self.configs[@"channelName"]; + AgoraRtcEngineConfig *config = [AgoraRtcEngineConfig new]; + config.appId = [KeyCenter AppId]; + self.agoraKit = [AgoraRtcEngineKit sharedEngineWithConfig:config delegate:self]; + // configure engine, request permissions, then join + [[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) { + AgoraRtcChannelMediaOptions *option = [AgoraRtcChannelMediaOptions new]; + [self.agoraKit joinChannelByToken:token channelId:channelName + uid:0 mediaOptions:option joinSuccess:nil]; + }]; +} + +- (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + if (self.isMovingFromParentViewController) { + [self.agoraKit leaveChannel:nil]; + [AgoraRtcEngineKit destroy]; + } +} +@end + +@implementation Main (AgoraRtcEngineDelegate) +- (void)rtcEngine:(AgoraRtcEngineKit *)engine didJoinChannel:(NSString *)channel + withUid:(NSUInteger)uid elapsed:(NSInteger)elapsed { + NSLog(@"Joined: %@ uid: %lu", channel, (unsigned long)uid); +} +- (void)rtcEngine:(AgoraRtcEngineKit *)engine didOccurError:(AgoraErrorCode)errorCode { + NSLog(@"Error: %ld", (long)errorCode); +} +@end +``` + +## Step 4 — Create the Storyboard + +Create `APIExample-OC/.storyboard` with two scenes: + +| Scene | Storyboard ID | Class | +|-------|--------------|-------| +| Entry | `EntryViewController` | `Entry` | +| Main | `` | `Main` | + +## Step 5 — Register the MenuItem + +Add to `+[MenuSection menus]` in `ViewController.m`: + +```objc +[[MenuItem alloc] initWithName:NSLocalizedString(@"", nil) + storyboard:@"" + controller:@""] +``` + +## Step 6 — Update the Case Index + +Add a row to the `## Case Index` table in `ARCHITECTURE.md`: + +```markdown +| | `Examples/[Basic|Advanced]//.m` | `keyApi1:`, `keyApi2:` | One-line description | +``` + +--- + +## Verification Checklist + +- [ ] Folder created under correct category (Basic / Advanced) +- [ ] Both `.h` and `.m` files created with Entry and Main classes +- [ ] Main inherits `BaseViewController` and conforms to `AgoraRtcEngineDelegate` +- [ ] Storyboard has correct scene IDs +- [ ] MenuItem added to `ViewController.m` +- [ ] `leaveChannel:` + `[AgoraRtcEngineKit destroy]` called when leaving +- [ ] UI updates inside delegate callbacks dispatched via `dispatch_async(dispatch_get_main_queue(), ^{ })` +- [ ] `__weak typeof(self) weakSelf = self` used in blocks that capture `self` +- [ ] Camera/microphone permissions requested before `joinChannelByToken:` +- [ ] Case Index row added/updated in `ARCHITECTURE.md` +- [ ] Project builds without errors + +--- + +## NEVER + +- NEVER create `AgoraRtcEngineKit` in the Entry VC +- NEVER use `__unsafe_unretained` for delegate references — use `__weak` +- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `dispatch_async(dispatch_get_main_queue(), ^{ })` +- NEVER add a new scene to `Main.storyboard` — each case must have its own `.storyboard` file +- NEVER share an `AgoraRtcEngineKit` instance between cases +- NEVER call `joinChannelByToken:` before requesting camera/microphone permissions +- NEVER skip updating the Case Index in `ARCHITECTURE.md` diff --git a/iOS/APIExample-OC/AGENTS.md b/iOS/APIExample-OC/AGENTS.md new file mode 100644 index 000000000..5bf171d06 --- /dev/null +++ b/iOS/APIExample-OC/AGENTS.md @@ -0,0 +1,37 @@ +# AGENTS.md — APIExample-OC + +Objective-C variant of the API demo. Mirrors cases from `APIExample/` using Objective-C instead of Swift. + +## Build Commands + +```bash +pod install +# Then open APIExample-OC.xcworkspace in Xcode and build (Cmd+B) +``` + +## App ID Configuration + +Edit `APIExample-OC/Common/KeyCenter.m`: +```objc ++ (NSString *)AppId { + return @"YOUR_APP_ID"; +} + ++ (NSString *)Certificate { + return nil; // leave nil if App Certificate is not enabled +} +``` + +To obtain an App ID, see [README.md](README.md#obtain-an-app-id). + +## Skills + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and OC convention compliance | +| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle diff --git a/iOS/APIExample-OC/ARCHITECTURE.md b/iOS/APIExample-OC/ARCHITECTURE.md new file mode 100644 index 000000000..e7091a64d --- /dev/null +++ b/iOS/APIExample-OC/ARCHITECTURE.md @@ -0,0 +1,164 @@ +# ARCHITECTURE.md — APIExample-OC + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/JoinChannelVideo.m` | `joinChannelByToken:`, `setupLocalVideo:`, `setupRemoteVideo:` | Basic video call — join channel and render local/remote video | +| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/JoinChannelVideoToken.m` | `joinChannelByToken:`, `setupLocalVideo:`, `setupRemoteVideo:` | Video call with token authentication | +| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/JoinChannelVideoRecorder.m` | `createMediaRecorder:`, `joinChannelByToken:`, `setupLocalVideo:` | Local and remote stream recording | +| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.m` | `joinChannelByToken:`, `setAudioProfile:`, `enableAudioVolumeIndication:` | Basic audio call | +| LiveStreaming | `Examples/Advanced/LiveStreaming/LiveStreaming.m` | `setClientRole:`, `setVideoScenario:`, `preloadChannelByToken:`, `enableInstantMediaRendering` | Interactive live streaming with role switching | +| RTMPStreaming | `Examples/Advanced/RTMPStreaming/RTMPStreaming.m` | `startRtmpStreamWithoutTranscoding:`, `startRtmpStreamWithTranscoding:`, `updateRtmpTranscoding:`, `stopRtmpStream:` | Push stream to CDN with optional transcoding | +| VideoMetadata | `Examples/Advanced/VideoMetadata/VideoMetadata.m` | `setMediaMetadataDataSource:withType:`, `setMediaMetadataDelegate:withType:` | Send and receive metadata attached to video stream | +| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.m` | `setVoiceBeautifierPreset:`, `setAudioEffectPreset:`, `setVoiceConversionPreset:` | Voice beautifier, effects, and conversion presets | +| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.m` | `createCustomAudioTrack:config:`, `enableCustomAudioLocalPlayback:enabled:`, `pushExternalAudioFrameRawData:` | Push custom PCM audio frames as external audio source | +| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.m` | `enableExternalAudioSink:sampleRate:channels:`, `pullPlaybackAudioFrameRawData:lengthInByte:` | Pull audio frames for custom rendering | +| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/CustomVideoSourcePush.m` | `setExternalVideoSource:useTexture:sourceType:`, `pushExternalVideoFrame:videoTrackId:` | Push external video frames as custom video source | +| CustomVideoRender | `Examples/Advanced/CustomVideoRender/CustomVideoRender.m` | `setVideoFrameDelegate:` | Custom rendering of remote video frames via delegate | +| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.m` | `setAudioFrameDelegate:` | Capture raw audio PCM data via delegate | +| RawVideoData | `Examples/Advanced/RawVideoData/RawVideoData.m` | `setVideoFrameDelegate:` | Capture raw video frames via delegate | +| SimpleFilter | `Examples/Advanced/SimpleFilter/SimpleFilter.m` | `enableExtensionWithVendor:extension:enabled:`, `setExtensionPropertyWithVendor:extension:key:value:` | Apply audio/video filter via Agora Extension API | +| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/JoinMultiChannel.m` | `joinChannelExByToken:connection:delegate:mediaOptions:` | Join multiple channels simultaneously via ex connection | +| StreamEncryption | `Examples/Advanced/StreamEncryption/StreamEncryption.m` | `enableEncryption:encryptionConfig:` | Built-in and custom stream encryption | +| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.m` | `startAudioMixing:loopback:cycle:`, `adjustAudioMixingVolume:`, `setEffectsVolume:` | Mix local audio file with microphone input | +| MediaPlayer | `Examples/Advanced/MediaPlayer/MediaPlayer.m` | `createMediaPlayerWithDelegate:`, `updateChannelExWithMediaOptions:connection:` | Play media files and publish to channel via media player | +| ScreenShare | `Examples/Advanced/ScreenShare/ScreenShare.m` | `startScreenCapture:`, `updateScreenCapture:`, `stopScreenCapture` | Screen capture and sharing via ReplayKit extension | +| LocalCompositeGraph | `Examples/Advanced/LocalCompositeGraph/LocalCompositeGraph.m` | `startLocalVideoTranscoder:`, `startCameraCapture:config:`, `enableVirtualBackground:backData:segData:` | Composite multiple video sources locally before publishing | +| VideoProcess | `Examples/Advanced/VideoProcess/VideoProcess.m` | `setBeautyEffectOptions:options:`, `enableVirtualBackground:backData:segData:`, `enableExtensionWithVendor:` | Built-in beauty, virtual background, and video enhancement | +| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.m` | `startRhythmPlayer:sound2:config:`, `stopRhythmPlayer` | Play metronome-style rhythm audio | +| CreateDataStream | `Examples/Advanced/CreateDataStream/CreateDataStream.m` | `createDataStream:config:`, `sendStreamMessage:data:` | Create and send data stream messages between users | +| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/MediaChannelRelay.m` | `startOrUpdateChannelMediaRelay:`, `stopChannelMediaRelay`, `pauseAllChannelMediaRelay`, `resumeAllChannelMediaRelay` | Relay media stream to multiple destination channels | +| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.m` | `createMediaPlayerWithDelegate:`, `updateChannelWithMediaOptions:` | 3D spatial audio with media player integration | +| ContentInspect | `Examples/Advanced/ContentInspect/ContentInspect.m` | `enableContentInspect:config:`, `switchCamera` | Moderate content in video stream | +| MutliCamera | `Examples/Advanced/MutliCamera/MutliCamera.m` | `enableMultiCamera:config:`, `startCameraCapture:config:`, `stopCameraCapture:` | Capture from front and back cameras simultaneously (iOS 13+) | +| PictureInPicture | `Examples/Advanced/PictureInPicture/PictureInPicture.m` | `setVideoFrameDelegate:`, `AVPictureInPictureController` | Picture-in-Picture using AVKit (iOS 15+) | +| Simulcast | `Examples/Advanced/Simulcast/Simulcast.m` | `setSimulcastConfig:`, `setRemoteVideoStream:type:` | Publish multiple video quality layers simultaneously | +| Multipath | `Examples/Advanced/Multipath/Multipath.m` | `updateChannelWithMediaOptions:` | Multi-path network transmission configuration | + +## Directory Layout + +``` +APIExample-OC/ +├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS) +├── SimpleFilter/ # Optional C++ audio/video extension module +├── Agora-ScreenShare-Extension-OC/ # ReplayKit broadcast extension for screen sharing +├── libs/ # Local SDK frameworks (when not using CocoaPods) +├── zh-Hans.lproj/ # Chinese localization (project level) +└── APIExample-OC/ + ├── main.m + ├── AppDelegate.h / .m + ├── ViewController.h / .m # Root menu controller — MenuItem registration lives here + ├── Info.plist + ├── APIExample-Bridging-Header.h + │ + ├── Common/ + │ ├── KeyCenter.h / .m # App ID and Certificate + │ ├── BaseViewController.h / .m # Base class all Main VCs must extend + │ ├── VideoView.h / .m / .xib # Reusable video rendering view + │ ├── Views/ # Reusable UI components + │ ├── Utils/ # LogUtils, GlobalSettings, Util (privatization config) + │ ├── NetworkManager/ # Token request helper + │ ├── ExternalAudio/ # External audio source helpers + │ ├── ExternalVideo/ # External video source helpers + │ └── CustomEncryption/ # Custom stream encryption helpers + │ + ├── Examples/ + │ ├── Basic/ + │ │ ├── JoinChannelVideo/ # "Join a channel (Video)" + │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)" + │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording" + │ │ └── JoinChannelAudio/ # "Join a channel (Audio)" + │ └── Advanced/ + │ ├── LiveStreaming/ # "Live Streaming" + │ ├── RTMPStreaming/ # "RTMP Streaming" + │ ├── VideoMetadata/ # "Video Metadata" + │ ├── VoiceChanger/ # "Voice Changer" + │ ├── CustomPcmAudioSource/ # "Custom Audio Source" + │ ├── CustomAudioRender/ # "Custom Audio Render" + │ ├── CustomVideoSourcePush/ # "Custom Video Source (Push)" + │ ├── CustomVideoRender/ # "Custom Video Render" + │ ├── RawAudioData/ # "Raw Audio Data" + │ ├── RawVideoData/ # "Raw Video Data" + │ ├── PictureInPicture/ # "Picture In Picture (iOS15+)" + │ ├── SimpleFilter/ # "Simple Filter Extension" + │ ├── JoinMultiChannel/ # "Join Multiple Channels" + │ ├── StreamEncryption/ # "Stream Encryption" + │ ├── AudioMixing/ # "Audio Mixing" + │ ├── MediaPlayer/ # "Media Player" + │ ├── ScreenShare/ # "Screen Share" + │ ├── VideoProcess/ # "Video Process" + │ ├── RhythmPlayer/ # "Rhythm Player" + │ ├── CreateDataStream/ # "Create Data Stream" + │ ├── MediaChannelRelay/ # "Media Channel Relay" + │ ├── SpatialAudio/ # "Spatial Audio" + │ ├── ContentInspect/ # "Content Inspect" + │ ├── MutliCamera/ # "Multi Camera (iOS13+)" + │ ├── Simulcast/ # "Simulcast" + │ ├── Multipath/ # "Multipath" + │ └── LocalCompositeGraph/ # "Local Composite Graph" + │ + ├── Resources/ # Audio/video sample files + ├── Assets.xcassets/ + ├── en.lproj/ # English localization + └── zh-Hans.lproj/ # Chinese localization +``` + +## Case Registration Mechanism + +Registration is **manual** via the `+[MenuSection menus]` method in `ViewController.m`. No reflection or annotation scanning. + +**`MenuItem` class:** +```objc +@interface MenuItem : NSObject +@property(nonatomic, copy) NSString *name; // display name in the list +@property(nonatomic, copy) NSString *entry; // storyboard ID of the entry VC (default: "EntryViewController") +@property(nonatomic, copy) NSString *storyboard; // storyboard file name +@property(nonatomic, copy) NSString *controller; // (unused in current implementation) +@property(nonatomic, copy) NSString *note; // optional description +@end +``` + +Each example has its own `.storyboard` file. The VC with identifier `entry` (default `"EntryViewController"`) is instantiated directly from that storyboard. + +**To add a case, edit exactly two things:** +1. Add a `MenuItem` to the `+[MenuSection menus]` method in `ViewController.m`: + ```objc + [[MenuItem alloc] initWithName:@"My New Case".localized storyboard:@"MyNewCase" controller:@""] + ``` +2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the `.h/.m` files and storyboard + +## Entry/Main ViewController Pattern + +Every example is split into two view controller roles: + +**Entry** (`Entry : UIViewController`) +- Collects user configuration before entering the example +- Passes configuration to Main via a `configs` dictionary (`NSDictionary`) + +**Main** (`Main : BaseViewController`) +- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example +- Conforms to `AgoraRtcEngineDelegate` +- Receives configuration exclusively through `configs` + +## AgoraRtcEngineKit Lifecycle + +``` +viewDidLoad → [AgoraRtcEngineKit sharedEngineWithAppId:delegate:] + → [engine setVideoEncoderConfiguration:] / [engine setChannelProfile:] + → [engine joinChannelByToken:...] (after permission granted) + ↓ + [AgoraRtcEngineDelegate callbacks — may be on background thread] + ↓ +viewDidDisappear / dealloc + → [engine leaveChannel:] + → [AgoraRtcEngineKit destroy] +``` + +## Token Flow + +```objc +[[NetworkManager shared] generateTokenWithChannelName:channelName success:^(NSString *token) { + [self.agoraKit joinChannelByToken:token channelId:channelName uid:0 mediaOptions:options]; +}]; +``` diff --git a/iOS/APIExample-OC/CLAUDE.md b/iOS/APIExample-OC/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/iOS/APIExample-OC/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..03f4a9dda --- /dev/null +++ b/iOS/APIExample-SwiftUI/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,50 @@ +--- +name: query-cases +description: > + Find existing API demo cases in the APIExample-SwiftUI project by feature name, API name, or keyword. + Use this before creating a new case to avoid duplication. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# query-cases — APIExample-SwiftUI + +## When to Use + +- User asks "where is the screen sharing example?" +- User wants to find code for a specific Agora SDK API +- Before creating a new case, to confirm it does not already exist + +## Quick Search (try this first) + +Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file. + +## Deep Search (for complex queries) + +1. Check `APIExample-SwiftUI/ContentView.swift` — the `menus` array lists all registered cases +2. Source files are at: + - `APIExample-SwiftUI/Examples/Basic//` + - `APIExample-SwiftUI/Examples/Advanced//` +3. Each case folder contains: + - `.swift` — Entry and Main SwiftUI views + - `RTC.swift` — engine lifecycle and delegate callbacks + +## Common Query Patterns + +| Query | Where to look | +|-------|--------------| +| Feature by name | Case Index — search Description column | +| API by method name | Case Index — search Key APIs column; or grep `*RTC.swift` files | +| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` | +| Cases using a specific pattern | Grep `*RTC.swift` files under `Examples/` | + +## Output Format + +Report results as: +- Case name and folder path +- Key APIs demonstrated +- One-line description diff --git a/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..04c4437b9 --- /dev/null +++ b/iOS/APIExample-SwiftUI/.agent/skills/review-case/SKILL.md @@ -0,0 +1,140 @@ +--- +name: review-case +description: > + Structured code review for a case in the APIExample-SwiftUI project. + Checks engine lifecycle, SwiftUI state ownership, thread safety, permissions, and API correctness. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# review-case — APIExample-SwiftUI + +## Review Dimensions (in priority order) + +### 1. Engine Lifecycle + +**Check:** +- `AgoraRtcEngineKit` created inside `setupRTC()` of the RTC class, not in the Entry view +- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called inside `onDestroy()` +- `setupRTC()` called from `.onAppear`, `onDestroy()` called from `.onDisappear` +- No engine instance retained beyond the RTC object's lifetime + +**Correct:** +```swift +.onAppear { rtc.setupRTC(configs: configs) } +.onDisappear { rtc.onDestroy() } +``` + +**Wrong:** +```swift +// Missing onDisappear — engine never destroyed +.onAppear { rtc.setupRTC(configs: configs) } +``` + +--- + +### 2. SwiftUI State Ownership + +**Check:** +- Main view declares RTC object as `@ObservedObject`, not `@StateObject` +- Entry view does not hold a reference to the RTC object +- `@Published` properties used for state that drives UI updates + +**Correct:** +```swift +struct MyCase: View { + @ObservedObject private var rtc = MyCaseRTC() // correct +} +``` + +**Wrong:** +```swift +struct MyCase: View { + @StateObject private var rtc = MyCaseRTC() // wrong — SwiftUI owns lifetime, may outlive view +} +``` + +--- + +### 3. Thread Safety + +All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread. + +**Check:** +- Every `@Published` property mutation inside a delegate callback is dispatched to `DispatchQueue.main` +- No direct SwiftUI state mutation on background thread + +**Correct:** +```swift +func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + DispatchQueue.main.async { + self.remoteUid = uid + } +} +``` + +--- + +### 4. Permissions + +**Check:** +- Camera/microphone permissions requested before `joinChannel()` +- `joinChannel()` called only inside the permission grant callback + +--- + +### 5. Error Handling + +**Check:** +- Return value of `joinChannel()` checked +- `rtcEngine(_:didOccurError:)` implemented and logged +- Token expiry handled if token is used + +--- + +### 6. Code Conventions + +**Check:** +- RTC class inherits `NSObject`, conforms to `ObservableObject` and `AgoraRtcEngineDelegate` +- Entry view named `Entry`, main view named `` +- RTC class named `RTC` +- `configs` dictionary used to pass data from Entry to Main +- No SDK calls inside SwiftUI `body` computed property + +--- + +### 7. Resource Cleanup + +**Check:** +- Audio files / custom audio tracks stopped in `onDestroy()` +- External video sources unregistered in `onDestroy()` +- Media player destroyed if created +- Screen capture stopped if started +- Multi-camera capture stopped if started + +--- + +## Review Output Format + +``` +[SEVERITY] file/line — issue description +Suggestion: how to fix +``` + +Severity levels: +- `[CRITICAL]` — crash, leak, or incorrect behavior +- `[WARNING]` — convention violation or subtle bug risk +- `[INFO]` — style or minor improvement + +--- + +## SwiftUI-Specific Checks + +- `onAppear` can fire multiple times (e.g., sheet dismiss, navigation pop/push) — verify `setupRTC` is idempotent or guarded +- `onDisappear` fires when the view is covered by another view in a `TabView` — verify this does not prematurely destroy the engine +- Video rendering views (`VideoView` / `VideoUIView`) must be created before `setupRTC` is called so the canvas can be set up correctly +- `[weak self]` required in all closures capturing `self` inside the RTC class to avoid retain cycles diff --git a/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..821cff8eb --- /dev/null +++ b/iOS/APIExample-SwiftUI/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,163 @@ +--- +name: upsert-case +description: > + Add a new API demo case or modify an existing one in the APIExample-SwiftUI project. + Covers folder creation, Entry view, RTC class, MenuItem registration, and Case Index update. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# upsert-case — APIExample-SwiftUI + +## When to Use + +- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/` +- **Modify**: the case already exists — skip Steps 1–3, go directly to Step 4+ + +Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist. + +## Files to Touch + +| Scenario | Files | +|----------|-------| +| Add new case | New folder + `RTC.swift` + `.swift`, `ContentView.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) | +| Modify existing case | Existing `*RTC.swift` and/or `*.swift` view files, `ARCHITECTURE.md` (Case Index) | + +--- + +## Step 1 — Create the Example Folder + +``` +APIExample-SwiftUI/Examples/[Basic|Advanced]// +``` + +## Step 2 — Create the RTC Class + +Create `RTC.swift` — owns the engine lifecycle: + +```swift +import AgoraRtcKit +import SwiftUI + +class RTC: NSObject, ObservableObject { + var agoraKit: AgoraRtcEngineKit! + private var isJoined = false + + func setupRTC(configs: [String: Any]) { + let config = AgoraRtcEngineConfig() + config.appId = KeyCenter.AppId + agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self) + + guard let channelName = configs["channelName"] as? String else { return } + let option = AgoraRtcChannelMediaOptions() + option.clientRoleType = .broadcaster + + NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in + self?.agoraKit.joinChannel(byToken: token, channelId: channelName, + uid: 0, mediaOptions: option) + } + } + + func onDestroy() { + if isJoined { agoraKit.leaveChannel(nil) } + AgoraRtcEngineKit.destroy() + } +} + +extension RTC: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, + withUid uid: UInt, elapsed: Int) { + isJoined = true + LogUtils.log(message: "Joined: \(channel)", level: .info) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + LogUtils.log(message: "Error: \(errorCode)", level: .error) + } +} +``` + +## Step 3 — Create the SwiftUI Views + +Create `.swift` with Entry and Main views: + +```swift +import SwiftUI + +struct Entry: View { + @State private var channelName = "" + @State private var isActive = false + @State private var configs: [String: Any] = [:] + + var body: some View { + VStack { + TextField("Enter channel name".localized, text: $channelName) + .textFieldStyle(.roundedBorder).padding() + Button("Join".localized) { + configs = ["channelName": channelName] + isActive = true + }.disabled(channelName.isEmpty) + NavigationLink(destination: (configs: configs), + isActive: $isActive) { EmptyView() } + } + } +} + +struct : View { + @State var configs: [String: Any] = [:] + @ObservedObject private var rtc = RTC() + + var body: some View { + VStack { /* UI here */ } + .onAppear { rtc.setupRTC(configs: configs) } + .onDisappear { rtc.onDestroy() } + } +} +``` + +## Step 4 — Register the MenuItem + +Add to the `menus` array in `APIExample-SwiftUI/ContentView.swift`: + +```swift +MenuItem(name: "".localized, view: AnyView(Entry())) +``` + +## Step 5 — Update the Case Index + +Add a row to the `## Case Index` table in `ARCHITECTURE.md`: + +```markdown +| | `Examples/[Basic|Advanced]//` | `keyApi1()`, `keyApi2()` | One-line description | +``` + +--- + +## Verification Checklist + +- [ ] Folder created under correct category (Basic / Advanced) +- [ ] RTC class inherits `NSObject`, conforms to `ObservableObject` and `AgoraRtcEngineDelegate` +- [ ] Engine created in `setupRTC`, destroyed in `onDestroy` +- [ ] Main view uses `@ObservedObject` (not `@StateObject`) for the RTC object +- [ ] `setupRTC` called in `.onAppear`, `onDestroy` called in `.onDisappear` +- [ ] `leaveChannel` + `AgoraRtcEngineKit.destroy()` called in `onDestroy` +- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main` +- [ ] MenuItem added to `ContentView.swift` +- [ ] Case Index row added/updated in `ARCHITECTURE.md` +- [ ] Project builds without errors + +--- + +## NEVER + +- NEVER create `AgoraRtcEngineKit` in the Entry view +- NEVER use `@StateObject` for the RTC object in the Main view — the Main view does not own its lifetime +- NEVER call SDK APIs inside SwiftUI `body` — only in `.onAppear`, `.onDisappear`, or explicit user action handlers +- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }` +- NEVER share an `AgoraRtcEngineKit` instance between cases +- NEVER call `joinChannel` before requesting camera/microphone permissions +- NEVER skip updating the Case Index in `ARCHITECTURE.md` diff --git a/iOS/APIExample-SwiftUI/AGENTS.md b/iOS/APIExample-SwiftUI/AGENTS.md new file mode 100644 index 000000000..1ca46420c --- /dev/null +++ b/iOS/APIExample-SwiftUI/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md — APIExample-SwiftUI + +SwiftUI variant of the API demo. Mirrors cases from `APIExample/` using SwiftUI views + MVVM pattern instead of UIKit + Storyboards. + +## Build Commands + +```bash +pod install +# Then open APIExample-SwiftUI.xcworkspace in Xcode and build (Cmd+B) +``` + +## App ID Configuration + +Edit `APIExample-SwiftUI/Common/KeyCenter.swift`: +```swift +static let AppId: String = "YOUR_APP_ID" +static let Certificate: String? = nil // leave nil if App Certificate is not enabled +``` + +To obtain an App ID, see [README.md](README.md#obtain-an-app-id). + +## Skills + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and SwiftUI convention compliance | +| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, case registration, Entry/RTC pattern, engine lifecycle diff --git a/iOS/APIExample-SwiftUI/ARCHITECTURE.md b/iOS/APIExample-SwiftUI/ARCHITECTURE.md new file mode 100644 index 000000000..b864ed27b --- /dev/null +++ b/iOS/APIExample-SwiftUI/ARCHITECTURE.md @@ -0,0 +1,185 @@ +# ARCHITECTURE.md — APIExample-SwiftUI + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/` | `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call — join channel and render local/remote video | +| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/` | `joinChannel(byToken:)`, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication | +| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/` | `createMediaRecorder()`, `joinChannel()`, `setupLocalVideo()` | Local and remote stream recording | +| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/` | `joinChannel()`, `setAudioProfile()`, `setAudioScenario()`, `adjustRecordingSignalVolume()`, `enable(inEarMonitoring:)` | Basic audio call with profile, scenario, and volume controls | +| LiveStreaming | `Examples/Advanced/LiveStreaming/` | `setClientRole()`, `setVideoScenario()`, `preloadChannel()`, `enableInstantMediaRendering()` | Interactive live streaming with role switching | +| RTMPStream | `Examples/Advanced/RTMPStream/` | `startRtmpStreamWithoutTranscoding()`, `startRtmpStream(withTranscoding:)`, `updateRtmpTranscoding()`, `stopRtmpStream()` | Push stream to CDN with optional transcoding | +| VideoMetadata | `Examples/Advanced/VideoMetadata/` | `setMediaMetadataDataSource()`, `setMediaMetadataDelegate()` | Send and receive metadata attached to video stream | +| VoiceChanger | `Examples/Advanced/VoiceChanger/` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setLocalVoiceFormant()` | Voice beautifier, effects, and conversion presets | +| CustomPCMAudioSource | `Examples/Advanced/CustomPCMAudioSource/` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as external audio source | +| CustomAudioRender | `Examples/Advanced/CustomAudioRender/` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering | +| RawAudioData | `Examples/Advanced/RawAudioData/` | `setAudioFrameDelegate()`, `sendAudioMetadata()` | Capture raw audio PCM data via delegate | +| RawVideoData | `Examples/Advanced/RawVideoData/` | `setVideoFrameDelegate()` | Capture raw video frames via delegate | +| PictureInPicture | `Examples/Advanced/PictureInPicture/` | `AVPictureInPictureController`, `joinChannel()`, `setVideoFrameDelegate()` | Picture-in-Picture using AVKit (iOS 15+) | +| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/` | `joinChannel()`, `leaveChannel()` | Quickly switch between channels as audience | +| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/` | `joinChannelEx()`, `takeSnapshotEx()` | Join multiple channels simultaneously via ex connection | +| StreamEncryption | `Examples/Advanced/StreamEncryption/` | `enableEncryption()` | Built-in and custom stream encryption | +| AudioMixing | `Examples/Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input | +| PrecallTest | `Examples/Advanced/PrecallTest/` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()` | Pre-call echo test and last-mile network probe | +| MediaPlayer | `Examples/Advanced/MediaPlayer/` | `createMediaPlayer()`, `updateChannelEx()` | Play media files and publish to channel via media player | +| ScreenShare | `Examples/Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCapture()`, `stopScreenCapture()` | Screen capture and sharing via ReplayKit extension | +| LocalVideoTranscoding | `Examples/Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `createMediaPlayer()` | Transcode multiple video sources locally before publishing | +| LocalVideoComposition | `Examples/Advanced/LocalVideoComposition/` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()` | Composite camera and screen capture into one stream | +| VideoProcess | `Examples/Advanced/VideoProcess/` | `setBeautyEffectOptions()`, `enableVirtualBackground()`, `enableExtension()` | Built-in beauty, virtual background, and video enhancement | +| AgoraBeauty | `Examples/Advanced/AgoraBeauty/` | `enableExtension()`, `createVideoEffectObject()`, `setFilterEffectOptions()` | Agora beauty extension with makeup and virtual background | +| RhythmPlayer | `Examples/Advanced/RhythmPlayer/` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio | +| CreateDataStream | `Examples/Advanced/CreateDataStream/` | `createDataStream()`, `sendStreamMessage()` | Create and send data stream messages between users | +| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()` | Relay media stream to multiple destination channels | +| SpatialAudio | `Examples/Advanced/SpatialAudio/` | `createMediaPlayer()`, `updateChannel()` | 3D spatial audio with media player integration | +| ContentInspect | `Examples/Advanced/ContentInspect/` | `enableContentInspect()`, `switchCamera()` | Moderate content in video stream | +| MutliCamera | `Examples/Advanced/MutliCamera/` | `enableMultiCamera()`, `startCameraCapture()`, `stopCameraCapture()` | Capture from front and back cameras simultaneously (iOS 13+) | +| KtvCopyrightMusic | `Examples/Advanced/KtvCopyrightMusic/` | — | Links to KTV copyright music documentation | +| ARKit | `Examples/Advanced/ARKit/` | `setVideoFrameDelegate()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()` | Push ARKit face tracking frames as custom video source | +| AudioWaveform | `Examples/Advanced/AudioWaveform/` | `setAudioProfile()`, `enableAudioVolumeIndication()` | Visualize audio waveform from volume callbacks | +| FaceCapture | `Examples/Advanced/FaceCapture/` | `enableExtension()`, `setExtensionPropertyWithVendor()`, `setFaceInfoDelegate()` | Face capture and lip sync via Agora extension | +| Simulcast | `Examples/Advanced/Simulcast/` | `setSimulcastConfig()`, `setRemoteVideoStream()` | Publish multiple video quality layers simultaneously | +| Multipath | `Examples/Advanced/Multipath/` | `updateChannel()` | Multi-path network transmission configuration | + +## Directory Layout + +``` +APIExample-SwiftUI/ +├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS) +├── Agora-ScreenShare-Extension/ # ReplayKit broadcast extension for screen sharing +├── libs/ # Local SDK frameworks (when not using CocoaPods) +└── APIExample-SwiftUI/ + ├── APIExample_SwiftUIApp.swift # App entry point (@main) + ├── ContentView.swift # Root navigation — MenuItem registration lives here + ├── Info.plist + ├── APIExample-Bridging-Header.h + │ + ├── Common/ + │ ├── KeyCenter.swift # App ID and Certificate + │ ├── AgoraExtension.swift + │ ├── PickerView.swift + │ ├── StatisticsInfo.swift + │ ├── VideoView.swift # SwiftUI wrapper for video rendering + │ ├── VideoUIView.swift # UIKit video view + │ ├── ViewExtensions.swift + │ ├── View/ # Reusable SwiftUI components + │ ├── Settings/ # GlobalSettings + │ ├── Utils/ # LogUtils, Util (privatization config) + │ ├── NetworkManager/ # Token request helper + │ ├── ExternalAudio/ # External audio source helpers + │ ├── ExternalVideo/ # External video source helpers + │ ├── CustomEncryption/ # Custom stream encryption helpers + │ └── ARKit/ # ARKit integration helpers + │ + ├── Examples/ + │ ├── Basic/ + │ │ ├── JoinChannelVideo/ # "Join a channel (Video)" + │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)" + │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording" + │ │ └── JoinChannelAudio/ # "Join a channel (Audio)" + │ └── Advanced/ + │ ├── LiveStreaming/ # "Live Streaming" + │ ├── RTMPStream/ # "RTMP Streaming" + │ ├── VideoMetadata/ # "Video Metadata" + │ ├── VoiceChanger/ # "Voice Changer" + │ ├── CustomPCMAudioSource/ # "Custom Audio Source (PCM)" + │ ├── CustomAudioRender/ # "Custom Audio Render" + │ ├── RawAudioData/ # "Raw Audio Data" + │ ├── RawVideoData/ # "Raw Video Data" + │ ├── PictureInPicture/ # "Picture In Picture" + │ ├── QuickSwitchChannel/ # "Quick Switch Channel" + │ ├── JoinMultiChannel/ # "Join Multiple Channels" + │ ├── StreamEncryption/ # "Stream Encryption" + │ ├── AudioMixing/ # "Audio Mixing" + │ ├── PrecallTest/ # "Precall Test" + │ ├── MediaPlayer/ # "Media Player" + │ ├── ScreenShare/ # "Screen Share" + │ ├── LocalVideoTranscoding/ # "Local Video Transcoding" + │ ├── LocalVideoComposition/ # "Local Composite Graph" + │ ├── VideoProcess/ # "Video Process" + │ ├── AgoraBeauty/ # "Agora Beauty" + │ ├── RhythmPlayer/ # "Rhythm Player" + │ ├── CreateDataStream/ # "Create Data Stream" + │ ├── MediaChannelRelay/ # "Media Channel Relay" + │ ├── SpatialAudio/ # "Spatial Audio" + │ ├── ContentInspect/ # "Content Inspect" + │ ├── MutliCamera/ # "Multi Camera (iOS13+)" + │ ├── KtvCopyrightMusic/ # "KTV Copyright Music" + │ ├── ARKit/ # "ARKit" + │ ├── AudioWaveform/ # "Audio Waveform" + │ ├── FaceCapture/ # "Face Capture" + │ ├── Simulcast/ # "Simulcast" + │ └── Multipath/ # "Multipath" + │ + ├── Resources/ # Audio/video sample files + ├── Assets.xcassets/ + └── Preview Content/ +``` + +## Case Registration Mechanism + +Registration is **manual** via the `menus` array in `ContentView.swift`. No reflection or annotation scanning. + +**`MenuItem` struct:** +```swift +struct MenuItem: Identifiable { + let id = UUID() + var name: String + var view: AnyView // the Entry view wrapped in AnyView +} +``` + +Navigation uses SwiftUI `NavigationLink`. Each `MenuItem` holds an `AnyView` wrapping the Entry view. + +**To add a case, edit exactly two things:** +1. Add a `MenuItem` to the `menus` array in `ContentView.swift`: + ```swift + MenuItem(name: "My New Case".localized, view: AnyView(MyNewCaseEntry())) + ``` +2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift files + +## Entry/RTC Pattern + +Every example is split into two parts: + +**Entry** (`Entry : View`) +- A SwiftUI View that collects user configuration (channel name, etc.) +- Uses `NavigationLink` to navigate to the main view +- Passes configuration via a `configs` dictionary + +**RTC** (`RTC : NSObject, ObservableObject, AgoraRtcEngineDelegate`) +- Owns the `AgoraRtcEngineKit` lifecycle +- Exposes state to the View via `@Published` properties +- Implements all delegate callbacks + +**Main View** (` : View`) +- Holds the RTC object as `@ObservedObject` +- Calls `setupRTC()` in `.onAppear` +- Calls `onDestroy()` in `.onDisappear` + +## Video Rendering + +UIKit video views (`VideoUIView`) are bridged into SwiftUI via `UIViewRepresentable` (`VideoView`). The RTC class owns the `UIView` instances; the SwiftUI View wraps them for display. + +## AgoraRtcEngineKit Lifecycle + +``` +.onAppear → setupRTC() + → AgoraRtcEngineKit.sharedEngine(with:delegate:) + → engine.setVideoEncoderConfiguration / setClientRole + → engine.joinChannel() (after token generation) + ↓ + [AgoraRtcEngineDelegate callbacks] + ↓ +.onDisappear → onDestroy() + → engine.leaveChannel() + → AgoraRtcEngineKit.destroy() +``` + +## Token Flow + +```swift +NetworkManager.shared.generateToken(channelName: channelName) { token in + self.agoraKit.joinChannel(byToken: token, channelId: channelName, uid: 0, mediaOptions: option) +} +``` diff --git a/iOS/APIExample-SwiftUI/CLAUDE.md b/iOS/APIExample-SwiftUI/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/iOS/APIExample-SwiftUI/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/iOS/APIExample/.agent/skills/query-cases/SKILL.md b/iOS/APIExample/.agent/skills/query-cases/SKILL.md new file mode 100644 index 000000000..f35362ee4 --- /dev/null +++ b/iOS/APIExample/.agent/skills/query-cases/SKILL.md @@ -0,0 +1,51 @@ +--- +name: query-cases +description: > + Find existing API demo cases in the APIExample project by feature name, API name, or keyword. + Use this before creating a new case to avoid duplication. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# query-cases — APIExample + +## When to Use + +- User asks "where is the screen sharing example?" +- User wants to find code for a specific Agora SDK API +- Before creating a new case, to confirm it does not already exist + +## Quick Search (try this first) + +Search the `## Case Index` table in `ARCHITECTURE.md` — it lists every case with its path, key APIs, and description. Most queries can be answered without opening any source file. + +Example: searching `screenCapture` in the Case Index immediately returns the `ScreenShare` row. + +## Deep Search (for complex queries) + +For queries like "which cases use multi-channel" or "which cases call joinChannelEx", scan source files: + +1. Check `APIExample/ViewController.swift` — the `menus` array lists all registered cases +2. Source files are at: + - `APIExample/Examples/Basic//.swift` + - `APIExample/Examples/Advanced//.swift` + +## Common Query Patterns + +| Query | Where to look | +|-------|--------------| +| Feature by name (e.g. "screen share") | Case Index — search Description column | +| API by method name (e.g. `startScreenCapture`) | Case Index — search Key APIs column | +| All cases in a category | Case Index — filter by Path prefix `Basic/` or `Advanced/` | +| Cases using a specific pattern (e.g. `joinChannelEx`) | Grep source files under `Examples/` | + +## Output Format + +Report results as: +- Case name and file path +- Key APIs demonstrated +- One-line description diff --git a/iOS/APIExample/.agent/skills/review-case/SKILL.md b/iOS/APIExample/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..70e70d6eb --- /dev/null +++ b/iOS/APIExample/.agent/skills/review-case/SKILL.md @@ -0,0 +1,154 @@ +--- +name: review-case +description: > + Structured code review for a case in the APIExample (UIKit + Swift) project. + Checks engine lifecycle, thread safety, permissions, error handling, API correctness, and code conventions. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# review-case — APIExample + +## Review Dimensions (in priority order) + +### 1. Engine Lifecycle + +The most critical dimension. Leaks here cause crashes in subsequent examples. + +**Check:** +- `AgoraRtcEngineKit.sharedEngine(with:delegate:)` called in `viewDidLoad` (not in Entry VC) +- `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil` +- No engine instance stored beyond the Main VC's lifetime + +**Correct:** +```swift +override func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) + if parent == nil { + agoraKit?.leaveChannel() + AgoraRtcEngineKit.destroy() + } +} +``` + +**Wrong:** +```swift +// Missing destroy — engine leaks +override func viewDidDisappear(_ animated: Bool) { + agoraKit?.leaveChannel() +} +``` + +--- + +### 2. Thread Safety + +All `AgoraRtcEngineDelegate` callbacks may arrive on a background thread. + +**Check:** +- Every UI update inside a delegate callback is wrapped in `DispatchQueue.main.async { }` +- No `UIView`, `UILabel`, or other UIKit objects mutated directly in callbacks + +**Correct:** +```swift +func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + DispatchQueue.main.async { + self.remoteView.isHidden = false + self.setupRemoteVideo(uid: uid) + } +} +``` + +**Wrong:** +```swift +func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + remoteView.isHidden = false // UI update on background thread +} +``` + +--- + +### 3. Permissions + +**Check:** +- Camera permission requested before `joinChannel()` for video cases +- Microphone permission requested before `joinChannel()` for all cases +- `joinChannel()` called only inside the permission grant callback, not before + +**Correct:** +```swift +AgoraAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in + guard granted else { return } + self?.agoraKit?.joinChannel(...) +} +``` + +--- + +### 4. Error Handling + +**Check:** +- Return value of `joinChannel()` checked (non-zero = error) +- `rtcEngine(_:didOccurError:)` delegate method implemented and logged +- Token expiry handled via `rtcEngine(_:tokenPrivilegeWillExpire:)` if token is used + +--- + +### 5. Code Conventions + +**Check:** +- Entry class inherits `UIViewController`, Main class inherits `BaseViewController` +- Entry class usually follows `Entry`; the main controller may be `Main` or an existing project-specific `*ViewController` name as long as storyboard wiring is correct +- `configs` dictionary used to pass data from Entry to Main (no direct property injection) +- File placed under `Examples/Basic/` or `Examples/Advanced/` matching the MenuItem section +- Storyboard ID of Main scene matches the `controller` field in `MenuItem` + +--- + +### 6. API Usage Correctness + +**Check:** +- `setVideoEncoderConfiguration` called before `joinChannel`, not after +- `setupLocalVideo` called before `startPreview` and `joinChannel` +- `enableVideo()` called before `setupLocalVideo` for video cases +- `setClientRole` called before `joinChannel` for live streaming cases +- No deprecated API variants used (check SDK release notes if unsure) + +--- + +### 7. Resource Cleanup + +**Check:** +- Audio files / custom audio tracks stopped and released on exit +- External video sources unregistered (`setExternalVideoSource(false, ...)`) +- Media player destroyed if created (`agoraKit.destroy(mediaPlayer)`) +- Screen capture stopped if started (`stopScreenCapture()`) +- Multi-camera capture stopped if started (`stopCameraCapture(.cameraSecondary)`) + +--- + +## Review Output Format + +For each issue found, report: + +``` +[SEVERITY] file/line — issue description +Suggestion: how to fix +``` + +Severity levels: +- `[CRITICAL]` — will cause crash, leak, or incorrect behavior +- `[WARNING]` — violates convention or may cause subtle bugs +- `[INFO]` — style or minor improvement suggestion + +--- + +## iOS-Specific Checks + +- Background audio: if the case uses audio, verify `AVAudioSession` category is set appropriately and `UIBackgroundModes` includes `audio` if background playback is needed +- `willMove(toParent:)` is the correct hook — do NOT use `viewWillDisappear` or `deinit` for engine cleanup in navigation-based flows +- `[weak self]` must be used in all closures that capture `self` to avoid retain cycles with the engine delegate diff --git a/iOS/APIExample/.agent/skills/upsert-case/SKILL.md b/iOS/APIExample/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..9e1c531d2 --- /dev/null +++ b/iOS/APIExample/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,185 @@ +--- +name: upsert-case +description: > + Add a new API demo case or modify an existing one in the APIExample (UIKit + Swift) project. + Covers folder creation, Entry/Main Swift file, storyboard, MenuItem registration, and Case Index update. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: iOS +--- + +# upsert-case — APIExample + +## When to Use + +- **Add**: the feature has no existing case in `Examples/Basic/` or `Examples/Advanced/` +- **Modify**: the case already exists — update the existing `.swift` and storyboard first, then check registration and docs + +Before adding, search the Case Index in `ARCHITECTURE.md` to confirm the case does not already exist. + +## Files to Touch + +| Scenario | Files | +|----------|-------| +| Add new case | New folder + `.swift` file + `Base.lproj/.storyboard`, `ViewController.swift` (MenuItem), `ARCHITECTURE.md` (Case Index) | +| Modify existing case | Existing `.swift` file(s), optionally `Base.lproj/.storyboard`, `ViewController.swift` if registration/wiring changed, `ARCHITECTURE.md` (Case Index) | + +--- + +## Modify Existing Case + +When repairing or rebuilding an existing case, use this order instead of the new-case flow: + +1. Locate the existing `.swift` implementation and update the actual runtime logic first +2. Update the existing storyboard if scene wiring, outlets, actions, or controller identifiers changed +3. Check `APIExample/ViewController.swift` and fix the `MenuItem` only if registration or `controller` / `storyboard` wiring is wrong +4. Update `ARCHITECTURE.md` last if the case path, APIs, or description changed + +Do not skip implementation edits just because the case folder already exists. + +## Step 1 — Create the Example Folder + +``` +APIExample/Examples/[Basic|Advanced]// +``` + +Use `Basic/` for fundamental channel join demos, `Advanced/` for everything else. + +## Step 2 — Create the Swift File + +Create `.swift` containing both Entry and Main classes: + +```swift +import UIKit +import AgoraRtcKit + +class Entry: UIViewController { + @IBOutlet weak var channelTextField: UITextField! + + @IBAction func onJoinPressed(_ sender: UIButton) { + guard let channelName = channelTextField.text, !channelName.isEmpty else { return } + let storyboard = UIStoryboard(name: "", bundle: nil) + guard let mainVC = storyboard.instantiateViewController( + withIdentifier: "") as? Main else { return } + mainVC.configs = ["channelName": channelName] + navigationController?.pushViewController(mainVC, animated: true) + } +} + +class Main: BaseViewController { + var agoraKit: AgoraRtcEngineKit? + + override func viewDidLoad() { + super.viewDidLoad() + guard let channelName = configs["channelName"] as? String else { return } + let config = AgoraRtcEngineConfig() + config.appId = KeyCenter.AppId + agoraKit = AgoraRtcEngineKit.sharedEngine(with: config, delegate: self) + // configure engine, request permissions, then join + NetworkManager.shared.generateToken(channelName: channelName) { [weak self] token in + let option = AgoraRtcChannelMediaOptions() + self?.agoraKit?.joinChannel(byToken: token, channelId: channelName, + uid: 0, mediaOptions: option) + } + } + + override func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) + if parent == nil { + agoraKit?.leaveChannel() + AgoraRtcEngineKit.destroy() + } + } +} + +extension Main: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, + withUid uid: UInt, elapsed: Int) { + LogUtils.log(message: "Joined: \(channel) uid: \(uid)", level: .info) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + LogUtils.log(message: "Error: \(errorCode.rawValue)", level: .error) + } +} +``` + +## Step 3 — Create the Storyboard + +Create `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard` with two scenes: + +| Scene | Storyboard ID | Class | +|-------|--------------|-------| +| Entry | `EntryViewController` | `Entry` | +| Main | `` | `Main` | + +Connect a `Show` segue or use the manual push in `onJoinPressed`. + +Keep the storyboard inside the example folder. Do not place new case storyboards in the shared `APIExample/Base.lproj/` directory. + +## Default Entry UI Convention + +Unless the user explicitly asks for a different flow, use this default Entry layout and interaction: + +- One channel input field (`UITextField`) with placeholder `"Enter channel name".localized` +- One join button (`UIButton`) with title `"Join".localized` +- Join action validates non-empty channel name, dismisses keyboard, and pushes Main VC +- Pass config via `configs = ["channelName": channelName]` + +Rationale: +- This matches the dominant pattern used by existing APIExample cases +- Keeps new cases consistent with existing user interaction and navigation +- Minimizes refactor cost when RTC join logic is added later + +## Step 4 — Register the MenuItem + +Add to the `menus` array in `APIExample/ViewController.swift`: + +```swift +MenuItem(name: "".localized, + storyboard: "", + controller: "") +``` + +Place it in the correct section (Basic / Advanced). + +## Step 5 — Update the Case Index + +Add a row to the `## Case Index` table in `ARCHITECTURE.md`: + +```markdown +| | `Examples/[Basic|Advanced]//.swift` | `keyApi1()`, `keyApi2()` | One-line description | +``` + +Key APIs: list 2–5 core SDK methods the case demonstrates. Do not list `joinChannel`, `leaveChannel`, `destroy`, or `sharedEngine` unless they are the primary focus. + +--- + +## Verification Checklist + +- [ ] Folder created under correct category (Basic / Advanced) +- [ ] Both Entry and Main classes exist in the Swift file +- [ ] Main inherits `BaseViewController` +- [ ] Storyboard has correct scene IDs +- [ ] Entry scene follows default UI convention (channel input + Join button), unless the user requested otherwise +- [ ] MenuItem added to `ViewController.swift` +- [ ] `leaveChannel()` + `AgoraRtcEngineKit.destroy()` called in `willMove(toParent:)` when `parent == nil` +- [ ] UI updates inside delegate callbacks dispatched to `DispatchQueue.main` +- [ ] Camera/microphone permissions requested before `joinChannel()` +- [ ] Case Index row added/updated in `ARCHITECTURE.md` +- [ ] Project builds without errors + +--- + +## NEVER + +- NEVER create `AgoraRtcEngineKit` in the Entry VC +- NEVER call `leaveChannel` or `destroy` in `viewDidDisappear` — use `willMove(toParent:)` with `parent == nil` +- NEVER update UI directly inside `AgoraRtcEngineDelegate` callbacks — always `DispatchQueue.main.async { }` +- NEVER add a new scene to `Main.storyboard` — each case must have its own `.storyboard` file +- NEVER share an `AgoraRtcEngineKit` instance between cases +- NEVER call `joinChannel` before requesting camera/microphone permissions +- NEVER skip updating the Case Index in `ARCHITECTURE.md` diff --git a/iOS/APIExample/AGENTS.md b/iOS/APIExample/AGENTS.md new file mode 100644 index 000000000..2401e937b --- /dev/null +++ b/iOS/APIExample/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md — APIExample + +Full demo project. Covers all Agora RTC APIs using UIKit + Swift. Default choice when no specific variant is required. + +## Build Commands + +```bash +pod install +# Then open APIExample.xcworkspace in Xcode and build (Cmd+B) +``` + +## App ID Configuration + +Edit `APIExample/Common/KeyCenter.swift`: +```swift +static let AppId: String = "YOUR_APP_ID" +static let Certificate: String? = nil // leave nil if App Certificate is not enabled +``` + +To obtain an App ID, see [README.md](README.md#obtain-an-app-id). + +## Skills + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify a case | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review case code for lifecycle, thread safety, and convention compliance | +| Find an existing case | `.agent/skills/query-cases/` | Locate which file demonstrates a specific API or feature | + +## Further Reading + +- `ARCHITECTURE.md` — full directory layout, case registration, Entry/Main pattern, engine lifecycle diff --git a/iOS/APIExample/ARCHITECTURE.md b/iOS/APIExample/ARCHITECTURE.md new file mode 100644 index 000000000..8dd1b081c --- /dev/null +++ b/iOS/APIExample/ARCHITECTURE.md @@ -0,0 +1,209 @@ +# ARCHITECTURE.md — APIExample + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/JoinChannelVideo.swift` | `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call — join channel and render local/remote video | +| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/JoinChannelVideoToken.swift` | `joinChannel(byToken:)`, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication | +| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/JoinChannelVideoRecorder.swift` | `createMediaRecorder()`, `joinChannel()`, `setupLocalVideo()` | Local and remote stream recording | +| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/JoinChannelAudio.swift` | `joinChannel()`, `setAudioProfile()`, `enableAudioVolumeIndication()`, `adjustRecordingSignalVolume()` | Basic audio call with volume and in-ear monitoring controls | +| LiveStreaming | `Examples/Advanced/LiveStreaming/LiveStreaming.swift` | `setClientRole()`, `setVideoScenario()`, `preloadChannel()`, `enableCameraCenterStage()` | Interactive live streaming with role switching and camera features | +| RTMPStreaming | `Examples/Advanced/RTMPStreaming/RTMPStreaming.swift` | `startRtmpStreamWithoutTranscoding()`, `startRtmpStream(withTranscoding:)`, `updateRtmpTranscoding()`, `stopRtmpStream()` | Push stream to CDN with optional transcoding | +| VideoMetadata | `Examples/Advanced/VideoMetadata/VideoMetadata.swift` | `setMediaMetadataDataSource()`, `setMediaMetadataDelegate()` | Send and receive metadata attached to video stream | +| VoiceChanger | `Examples/Advanced/VoiceChanger/VoiceChanger.swift` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()`, `setVoiceConversionPreset()`, `setAINSMode()` | Voice beautifier, effects, conversion presets, and AI noise suppression | +| CustomPcmAudioSource | `Examples/Advanced/CustomPcmAudioSource/CustomPcmAudioSource.swift` | `createCustomAudioTrack()`, `enableCustomAudioLocalPlayback()`, `pushExternalAudioFrameRawData()` | Push custom PCM audio frames as external audio source | +| CustomAudioRender | `Examples/Advanced/CustomAudioRender/CustomAudioRender.swift` | `enableExternalAudioSink()`, `pullPlaybackAudioFrameRawData()` | Pull audio frames for custom rendering | +| CustomAudioSource | `Examples/Advanced/CustomAudioSource/CustomAudioSource.swift` | `createCustomAudioTrack()` | Push custom audio via mixable audio track | +| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/CustomVideoSourcePush.swift` | `setExternalVideoSource()`, `pushExternalVideoFrame()` | Push external video frames as custom video source | +| CustomVideoSourcePushMulti | `Examples/Advanced/CustomVideoSourcePushMulti/CustomVideoSourcePushMulti.swift` | `createCustomVideoTrack()`, `createCustomEncodedVideoTrack()`, `pushExternalEncodedVideoFrame()` | Multi-track custom video source with encoded frame push | +| CustomVideoRender | `Examples/Advanced/CustomVideoRender/CustomVideoRender.swift` | `setVideoFrameDelegate()` | Custom rendering of remote video frames via delegate | +| RawAudioData | `Examples/Advanced/RawAudioData/RawAudioData.swift` | `setAudioFrameDelegate()`, `sendAudioMetadata()` | Capture raw audio PCM data via delegate | +| RawVideoData | `Examples/Advanced/RawVideoData/RawVideoData.swift` | `setVideoFrameDelegate()` | Capture raw video frames via delegate | +| RawMediaData | `Examples/Advanced/RawMediaData/RawMediaData.swift` | `setVideoFrameDelegate()`, `setAudioFrameDelegate()`, `setRecordingAudioFrameParametersWithSampleRate()`, `startAudioRecording()` | Capture both raw audio and video data simultaneously | +| PictureInPicture | `Examples/Advanced/PictureInPicture/` | `AVPictureInPictureController`, `joinChannel()`, `setVideoFrameDelegate()` | Picture-in-Picture using AVKit (iOS 15+) | +| SimpleFilter | `Examples/Advanced/SimpleFilter/SimpleFilter.swift` | `enableExtension()`, `setExtensionPropertyWithVendor()` | Apply audio/video filter via Agora Extension API | +| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/QuickSwitchChannel.swift` | `joinChannel()`, `leaveChannel()` | Quickly switch between channels as audience | +| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/JoinMultiChannel.swift` | `joinChannelEx()`, `takeSnapshotEx()` | Join multiple channels simultaneously via ex connection | +| StreamEncryption | `Examples/Advanced/StreamEncryption/StreamEncryption.swift` | `enableEncryption()` | Built-in and custom stream encryption | +| AudioMixing | `Examples/Advanced/AudioMixing/AudioMixing.swift` | `startAudioMixing()`, `stopAudioMixing()`, `adjustAudioMixingVolume()`, `setEffectsVolume()` | Mix local audio file with microphone input | +| PrecallTest | `Examples/Advanced/PrecallTest/PrecallTest.swift` | `startEchoTest()`, `stopEchoTest()`, `startLastmileProbeTest()`, `stopLastmileProbeTest()` | Pre-call echo test and last-mile network probe | +| MediaPlayer | `Examples/Advanced/MediaPlayer/MediaPlayer.swift` | `createMediaPlayer()`, `updateChannelEx()` | Play media files and publish to channel via media player | +| ScreenShare | `Examples/Advanced/ScreenShare/ScreenShare.swift` | `startScreenCapture()`, `updateScreenCapture()`, `stopScreenCapture()`, `setScreenCaptureScenario()` | Screen capture and sharing via ReplayKit extension | +| LocalCompositeGraph | `Examples/Advanced/LocalCompositeGraph/LocalCompositeGraph.swift` | `startLocalVideoTranscoder()`, `startCameraCapture()`, `startScreenCapture()`, `enableVirtualBackground()` | Composite multiple video sources locally before publishing | +| VideoProcess | `Examples/Advanced/VideoProcess/VideoProcess.swift` | `setBeautyEffectOptions()`, `enableVirtualBackground()`, `enableExtension()` | Built-in beauty, virtual background, and video enhancement | +| AgoraBeauty | `Examples/Advanced/AgoraBeauty/AgoraBeauty.swift` | `enableExtension()`, `enableVirtualBackground()` | Agora beauty extension with virtual background | +| RhythmPlayer | `Examples/Advanced/RhythmPlayer/RhythmPlayer.swift` | `startRhythmPlayer()`, `stopRhythmPlayer()` | Play metronome-style rhythm audio | +| CreateDataStream | `Examples/Advanced/CreateDataStream/CreateDataStream.swift` | `createDataStream()`, `sendStreamMessage()` | Create and send data stream messages between users | +| MediaChannelRelay | `Examples/Advanced/MediaChannelRelay/MediaChannelRelay.swift` | `startOrUpdateChannelMediaRelay()`, `stopChannelMediaRelay()`, `pauseAllChannelMediaRelay()`, `resumeAllChannelMediaRelay()` | Relay media stream to multiple destination channels | +| SpatialAudio | `Examples/Advanced/SpatialAudio/SpatialAudio.swift` | `createMediaPlayer()`, `updateChannel()` | 3D spatial audio with media player integration | +| ContentInspect | `Examples/Advanced/ContentInspect/ContentInspect.swift` | `enableContentInspect()`, `switchCamera()` | Moderate content in video stream | +| MutliCamera | `Examples/Advanced/MutliCamera/MutliCamera.swift` | `enableMultiCamera()`, `startCameraCapture()`, `stopCameraCapture()` | Capture from front and back cameras simultaneously (iOS 13+) | +| KtvCopyrightMusic | `Examples/Advanced/KtvCopyrightMusic/KtvCopyrightMusic.swift` | — | Links to KTV copyright music documentation | +| ThirdBeautify | `Examples/Advanced/ThirdBeautify/ThirdBeautify.swift` | `enableExtension()` | Third-party beauty SDK integration (ByteDance / FaceUnity / SenseTime) | +| ARKit | `Examples/Advanced/ARKit/ARKit.swift` | `setVideoFrameDelegate()`, `enableInstantMediaRendering()`, `startMediaRenderingTracing()` | Push ARKit face tracking frames as custom video source | +| AudioRouterPlayer | `Examples/Advanced/AudioRouterPlayer/AudioRouterPlayer.swift` | `setEnableSpeakerphone()` | Control audio output routing with third-party player | +| AudioWaveform | `Examples/Advanced/AudioWaveform/AudioWaveform.swift` | `setAudioProfile()`, `enableAudioVolumeIndication()` | Visualize audio waveform from volume callbacks | +| FaceCapture | `Examples/Advanced/FaceCapture/FaceCapture.swift` | `enableExtension()`, `setExtensionPropertyWithVendor()`, `setFaceInfoDelegate()` | Face capture and lip sync via Agora extension | +| TransparentRender | `Examples/Advanced/TransparentRender/TransparentRender.swift` | `createMediaPlayer()`, `setExternalVideoSource()`, `pushExternalVideoFrame()` | Render video with transparent background | +| RtePlayer | `Examples/Advanced/RtePlayer/RtePlayer.swift` | `AgoraRte`, `AgoraRtePlayer`, `AgoraRteCanvas` | URL-based stream playback via RTE Player API | +| Simulcast | `Examples/Advanced/Simulcast/Simulcast.swift` | `setSimulcastConfig()`, `setRemoteVideoStream()` | Publish multiple video quality layers simultaneously | +| Multipath | `Examples/Advanced/Multipath/Multipath.swift` | `updateChannel()` | Multi-path network transmission configuration | + +## Directory Layout + +``` +APIExample/ +├── Podfile # CocoaPods dependencies (AgoraRtcEngine_iOS, Floaty, AGEVideoLayout, etc.) +├── SimpleFilter/ # Optional C++ audio/video extension module +├── Agora-ScreenShare-Extension/ # ReplayKit broadcast extension for screen sharing +├── ByteEffectLib/ # Optional ByteDance beauty SDK resources +├── FULib/ # Optional FaceUnity beauty SDK resources +├── SenseLib/ # Optional SenseTime beauty SDK resources +├── libs/ # Local SDK frameworks (when not using CocoaPods) +└── APIExample/ + ├── AppDelegate.swift + ├── ViewController.swift # Root menu controller — MenuItem registration lives here + ├── Info.plist + ├── APIExample.entitlements + ├── APIExample-Bridging-Header.h + │ + ├── Common/ + │ ├── KeyCenter.swift # App ID and Certificate + │ ├── GlobalSettings.swift # Shared runtime config (resolution, fps, orientation, role) + │ ├── BaseViewController.swift # Base class all Main VCs must extend + │ ├── EntryViewController.swift # Generic Entry VC for storyboard == "Main" cases + │ ├── LogViewController.swift # Log viewer + │ ├── AlertManager.swift + │ ├── AgoraExtension.swift + │ ├── PickerView.swift + │ ├── StatisticsInfo.swift + │ ├── UITypeAlias.swift + │ ├── VideoView.swift / .xib # Reusable video rendering view + │ ├── Settings/ # Settings UI components + │ ├── Utils/ # LogUtils, Util (privatization config) + │ ├── NetworkManager/ # Token request helper + │ ├── ExternalAudio/ # External audio source helpers + │ ├── ExternalVideo/ # External video source helpers + │ ├── CustomEncryption/ # Custom stream encryption helpers + │ └── ARKit/ # ARKit integration helpers + │ + ├── Examples/ + │ ├── Basic/ + │ │ ├── JoinChannelVideo/ # "Join a channel (Video)" + │ │ ├── JoinChannelVideo(Token)/ # "Join a channel (Token)" + │ │ ├── JoinChannelVideo(Recorder)/ # "Local or remote recording" + │ │ └── JoinChannelAudio/ # "Join a channel (Audio)" + │ └── Advanced/ + │ ├── LiveStreaming/ # "Live Streaming" — setClientRole + │ ├── RTMPStreaming/ # "RTMP Streaming" — push to CDN + │ ├── VideoMetadata/ # "Video Metadata" — send/receive metadata + │ ├── VoiceChanger/ # "Voice Changer" — voice beautifier/effects + │ ├── CustomPcmAudioSource/ # "Custom Audio Source" — push PCM audio + │ ├── CustomAudioRender/ # "Custom Audio Render" — pull audio rendering + │ ├── CustomAudioSource/ # (legacy custom audio source) + │ ├── CustomVideoSourcePush/ # "Custom Video Source(Push)" — push external video + │ ├── CustomVideoSourcePushMulti/ # "Custom Video Source(Multi)" — multi-track push + │ ├── CustomVideoRender/ # "Custom Video Render" + │ ├── RawAudioData/ # "Raw Audio Data" + │ ├── RawVideoData/ # "Raw Video Data" + │ ├── RawMediaData/ # (legacy raw media data) + │ ├── PictureInPicture/ # "Picture In Picture (iOS15+)" + │ ├── SimpleFilter/ # "Simple Filter Extension" + │ ├── QuickSwitchChannel/ # "Quick Switch Channel" + │ ├── JoinMultiChannel/ # "Join Multiple Channels" + │ ├── StreamEncryption/ # "Stream Encryption" + │ ├── AudioMixing/ # "Audio Mixing" + │ ├── PrecallTest/ # "Precall Test" + │ ├── MediaPlayer/ # "Media Player" + │ ├── ScreenShare/ # "Screen Share" + │ ├── LocalCompositeGraph/ # "Local Composite Graph" + │ ├── VideoProcess/ # "Video Process" + │ ├── AgoraBeauty/ # "Agora Beauty" + │ ├── RhythmPlayer/ # "Rhythm Player" + │ ├── CreateDataStream/ # "Create Data Stream" + │ ├── MediaChannelRelay/ # "Media Channel Relay" + │ ├── SpatialAudio/ # "Spatial Audio" + │ ├── ContentInspect/ # "Content Inspect" + │ ├── MutliCamera/ # "Multi Camera (iOS13+)" + │ ├── KtvCopyrightMusic/ # "KTV Copyright Music" + │ ├── ThirdBeautify/ # "Third Beautify" — third-party beauty SDK (includes SenseBeautify/ subfolder, domestic) + │ ├── ARKit/ # "ARKit" + │ ├── AudioRouterPlayer/ # "Audio Router (Third Party Player)" + │ ├── AudioWaveform/ # "Audio Waveform" + │ ├── FaceCapture/ # "Face Capture" + │ ├── TransparentRender/ # "Transparent Render" + │ ├── RtePlayer/ # "URL Streaming (RTE Player)" + │ ├── Simulcast/ # "Simulcast" + │ ├── Multipath/ # "Multipath" + │ └── VideoChat/ # (disabled) Group Video Chat + │ + ├── Resources/ # Audio/video sample files; beauty_material.bundle (Agora beauty, domestic) + ├── Assets.xcassets/ + ├── Base.lproj/ # Main.storyboard, LaunchScreen.storyboard + └── zh-Hans.lproj/ # Chinese localization +``` + + +## Case Registration Mechanism + +Registration is **manual** via the `menus` array in `ViewController.swift`. No reflection or annotation scanning. + +**`MenuItem` struct:** +```swift +struct MenuItem { + var name: String // display name in the list + var entry: String // storyboard ID of the entry VC (default: "EntryViewController") + var storyboard: String // storyboard file name (default: "Main") + var controller: String // storyboard ID of the main VC + var note: String // optional description +} +``` + +**Two navigation paths exist depending on `storyboard`:** + +1. `storyboard == "Main"` — uses the shared `Main.storyboard`. The generic `EntryViewController` is instantiated, and `nextVCIdentifier` is set to `controller` to load the Main VC. +2. `storyboard != "Main"` — each example has its own `.storyboard` file. The VC with identifier `entry` (default `"EntryViewController"`) is instantiated directly from that storyboard. + +Most examples use path 2 (their own storyboard). + +**To add a case, edit exactly two things:** +1. Add a `MenuItem` to the `menus` array in `ViewController.swift` +2. Create the example folder under `Examples/Basic/` or `Examples/Advanced/` with the Swift file(s) and storyboard + +## Entry/Main ViewController Pattern + +Every example is split into two view controller roles: + +**Entry** (`Entry : UIViewController`) +- Collects user configuration before entering the example +- Passes configuration to Main via a `configs` dictionary + +**Main** (`Main : BaseViewController`) +- Owns the `AgoraRtcEngineKit` lifecycle for the duration of the example +- Implements `AgoraRtcEngineDelegate` +- Receives configuration exclusively through `configs` + +## AgoraRtcEngineKit Lifecycle + +``` +viewDidLoad → AgoraRtcEngineKit.sharedEngine(withAppId:delegate:) + → engine.setVideoEncoderConfiguration / setChannelProfile + → engine.joinChannel() (after permission granted) + ↓ + [AgoraRtcEngineDelegate callbacks — may be on background thread] + ↓ +viewDidDisappear / willMove(toParent:) + → engine.leaveChannel() + → AgoraRtcEngineKit.destroy() +``` + +## Token Flow + +```swift +NetworkManager.shared.generateToken(channelName: channelId, uid: uid) { token in + self.agoraKit?.joinChannel(byToken: token, channelId: channelId, uid: uid, mediaOptions: options) +} +``` + +If `KeyCenter.Certificate` is nil, token generation is skipped and a nil token is used — valid for projects without App Certificate. diff --git a/iOS/APIExample/CLAUDE.md b/iOS/APIExample/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/iOS/APIExample/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/iOS/ARCHITECTURE.md b/iOS/ARCHITECTURE.md new file mode 100644 index 000000000..6648ac527 --- /dev/null +++ b/iOS/ARCHITECTURE.md @@ -0,0 +1,49 @@ +# ARCHITECTURE.md + +Four independent iOS example projects sharing one Xcode workspace, each managing dependencies via CocoaPods. +For internal details of each project, see the project-level `ARCHITECTURE.md`. + +--- + +## APIExample — Full Demo + +- Language: Swift +- UI Framework: UIKit + Storyboards +- SDK: AgoraRtcEngine_iOS (full-featured) +- Architecture: Entry/Main ViewController pattern +- Case registration: `MenuItem` array in `ViewController.swift` +- Details: [APIExample/ARCHITECTURE.md](APIExample/ARCHITECTURE.md) + +--- + +## APIExample-SwiftUI — SwiftUI Demo + +- Language: Swift +- UI Framework: SwiftUI +- SDK: AgoraRtcEngine_iOS (full-featured) +- Architecture: MVVM (View + ViewModel) +- Case registration: navigation destinations in `ContentView.swift` +- Details: [APIExample-SwiftUI/ARCHITECTURE.md](APIExample-SwiftUI/ARCHITECTURE.md) + +--- + +## APIExample-OC — Objective-C Demo + +- Language: Objective-C +- UI Framework: UIKit + Storyboards +- SDK: AgoraRtcEngine_iOS (full-featured) +- Architecture: Entry/Main ViewController pattern (same as APIExample) +- Case registration: `MenuItem` array in `ViewController.m` +- Details: [APIExample-OC/ARCHITECTURE.md](APIExample-OC/ARCHITECTURE.md) + +--- + +## APIExample-Audio — Audio-Only Demo + +- Language: Swift +- UI Framework: UIKit + Storyboards +- SDK: AgoraAudio_iOS (no video module) +- Architecture: Entry/Main ViewController pattern +- Case registration: `MenuItem` array in `ViewController.swift` +- Constraint: no video rendering views +- Details: [APIExample-Audio/ARCHITECTURE.md](APIExample-Audio/ARCHITECTURE.md) diff --git a/iOS/CLAUDE.md b/iOS/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/iOS/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/macOS/.agent/skills/review-case/SKILL.md b/macOS/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..92d813ec2 --- /dev/null +++ b/macOS/.agent/skills/review-case/SKILL.md @@ -0,0 +1,380 @@ +--- +name: review-case +description: > + Code review for API examples. Ensures examples follow project conventions, + handle lifecycle correctly, manage threads safely, and use APIs properly. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: macOS +--- + +# Review Case Skill — macOS + +## When to Use + +Use this skill when you need to: +- Review a new or modified example for correctness +- Ensure the example follows project conventions +- Verify lifecycle management and thread safety +- Check API usage and error handling + +## Review Dimensions (Priority Order) + +### 1. Engine Lifecycle (CRITICAL) + +**Check:** +- [ ] Engine is created in `initializeAgoraEngine()` or similar +- [ ] Engine is initialized with `AgoraRtcEngineConfig` +- [ ] `leaveChannel()` is called before `destroy()` +- [ ] `destroy()` is called in `viewWillClose()` or cleanup method +- [ ] No engine leaks (engine not recreated on every join) + +**Correct Pattern:** +```swift +override func viewDidLoad() { + super.viewDidLoad() + initializeAgoraEngine() // Create once +} + +override func viewWillClose() { + leaveChannel() + agoraKit.destroy() + super.viewWillClose() +} + +func joinChannel() { + agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0) +} + +func leaveChannel() { + agoraKit.leaveChannel(nil) +} +``` + +**Incorrect Pattern:** +See `references/incorrect-lifecycle.swift` for common mistakes. + +--- + +### 2. Thread Safety (CRITICAL) + +**Check:** +- [ ] All UI updates in delegate callbacks use `DispatchQueue.main.async` +- [ ] No direct UI updates from background threads +- [ ] Video/audio frame callbacks dispatch to main thread before updating UI + +**Correct Pattern:** +```swift +func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) { + // Callback may arrive on background thread + DispatchQueue.main.async { + self.statusLabel.stringValue = "Joined channel" + } +} +``` + +**Incorrect Pattern:** +See `references/incorrect-thread-safety.swift` for common mistakes. + +--- + +### 3. Permission Handling (HIGH) + +**Check:** +- [ ] Microphone permission requested before `enableAudio()` +- [ ] Camera permission requested before `enableVideo()` +- [ ] Permissions checked before accessing devices +- [ ] Review guidance stays macOS-specific and does not suggest iOS-only APIs such as `AVAudioSession.sharedInstance().requestRecordPermission` + +**Correct Pattern:** +```swift +func initializeAgoraEngine() { + // Request permissions first + AVCaptureDevice.requestAccess(for: .video) { granted in + if granted { + self.agoraKit.enableVideo() + } + } + + AVCaptureDevice.requestAccess(for: .audio) { granted in + if granted { + self.agoraKit.enableAudio() + } + } +} +``` + +--- + +### 4. Error Handling (HIGH) + +**Check:** +- [ ] `joinChannel()` failures are handled +- [ ] Token expiration is handled +- [ ] Network errors are logged or displayed +- [ ] Invalid parameters are validated + +**Correct Pattern:** +```swift +func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) { + DispatchQueue.main.async { + self.showError("Error: \(errorCode.rawValue)") + } +} + +func rtcEngine(_ engine: AgoraRtcEngineKit, tokenPrivilegeWillExpire token: String) { + // Refresh token before expiration + let newToken = KeyCenter.Token(channelName: self.channelName) + self.agoraKit.renewToken(newToken) +} +``` + +--- + +### 5. Code Convention (MEDIUM) + +**Check:** +- [ ] Class name follows pattern: `Main` +- [ ] Extends `BaseViewController` +- [ ] File name matches class name (PascalCase) +- [ ] Properties are properly declared with `@IBOutlet` or `var` +- [ ] Methods are organized with `// MARK:` sections +- [ ] Comments explain non-obvious logic + +**Correct Pattern:** +```swift +class ScreenShareMain: BaseViewController { + + var agoraKit: AgoraRtcEngineKit! + var remoteUid: UInt = 0 + + @IBOutlet weak var Container: AGEVideoContainer! + + // MARK: - Lifecycle + override func viewDidLoad() { ... } + + // MARK: - Agora Engine Setup + func initializeAgoraEngine() { ... } + + // MARK: - Actions + @IBAction func joinButtonTapped(_ sender: Any) { ... } +} +``` + +--- + +### 6. API Usage Correctness (MEDIUM) + +**Check:** +- [ ] SDK methods called in correct order +- [ ] Required parameters are provided +- [ ] Optional parameters are used correctly +- [ ] Return values are checked where necessary +- [ ] Deprecated APIs are not used + +**Correct Pattern:** +```swift +// Correct order: enable -> setup -> join +agoraKit.enableVideo() +agoraKit.setupLocalVideo(AgoraRtcVideoCanvas(uid: 0)) +agoraKit.joinChannel(byToken: token, channelName: channel, info: nil, uid: 0) +``` + +**Incorrect Pattern:** +```swift +// ❌ Wrong order +agoraKit.joinChannel(...) // Join first +agoraKit.enableVideo() // Enable after join (too late) +``` + +--- + +### 7. Resource Cleanup (MEDIUM) + +**Check:** +- [ ] Audio files are stopped and released +- [ ] Video captures are stopped +- [ ] Custom audio/video sources are cleaned up +- [ ] Observers are unregistered +- [ ] Timers are invalidated + +**Correct Pattern:** +```swift +func leaveChannel() { + agoraKit.stopAudioMixing() // Stop audio + agoraKit.stopScreenCapture() // Stop screen share + agoraKit.leaveChannel(nil) +} + +override func viewWillClose() { + leaveChannel() + agoraKit.destroy() + super.viewWillClose() +} +``` + +--- + +## Review Output Format + +When reviewing, provide feedback in this format: + +``` +## Review Results + +### ✅ Passed +- Engine lifecycle correctly managed +- Thread safety ensured with DispatchQueue.main.async +- Permissions requested before device access + +### ⚠️ Issues Found + +**[HIGH] Thread Safety Issue** +- File: `ScreenShare.swift` +- Line: 45 +- Issue: UI update in delegate callback without DispatchQueue.main.async +- Suggestion: Wrap UI update with `DispatchQueue.main.async { ... }` + +**[MEDIUM] Missing Error Handling** +- File: `ScreenShare.swift` +- Line: 78 +- Issue: joinChannel() result not checked +- Suggestion: Implement `rtcEngine(_:didOccurError:)` delegate method + +### 🔧 Recommendations +- Add logging for debugging +- Consider adding retry logic for network failures +``` + +--- + +## Platform-Specific Checks + +### macOS-Specific + +**Check:** +- [ ] Using Cocoa (AppKit) — not UIKit or SwiftUI +- [ ] Window/view lifecycle properly handled +- [ ] No Combine or async/await unless already in codebase +- [ ] Storyboard/XIB files properly configured if used + +**Correct Pattern:** +```swift +// macOS: Use Cocoa +import Cocoa +import AgoraRtcKit + +class ExampleMain: BaseViewController { + @IBOutlet weak var Container: AGEVideoContainer! + // Cocoa-based UI +} +``` + +**Incorrect Pattern:** +```swift +// ❌ iOS patterns in macOS +import UIKit // Wrong framework +class ExampleMain: UIViewController { } // Wrong base class +``` + +--- + +## NEVER List + +**Do NOT accept:** +- Engine not destroyed (memory leak) +- UI updates from background threads without DispatchQueue.main.async +- Multiple engine instances in one example +- Hardcoded App ID or token (must use KeyCenter) +- Missing `leaveChannel()` before `destroy()` +- Objective-C files (Swift only) +- UIKit or SwiftUI (Cocoa only) +- Examples outside `APIExample/Examples/[Basic|Advanced]/` structure +- Missing delegate implementation for event handling +- No error handling for joinChannel failures + +--- + +## Review Checklist + +Use this checklist when reviewing an example: + +**Lifecycle:** +- [ ] Engine created once in initialization +- [ ] `leaveChannel()` called before `destroy()` +- [ ] `destroy()` called in cleanup +- [ ] No engine leaks + +**Thread Safety:** +- [ ] All UI updates use `DispatchQueue.main.async` +- [ ] No direct UI updates from callbacks +- [ ] Frame callbacks dispatch to main thread + +**Permissions:** +- [ ] Microphone permission requested +- [ ] Camera permission requested +- [ ] Permissions checked before use + +**Error Handling:** +- [ ] joinChannel failures handled +- [ ] Token expiration handled +- [ ] Network errors logged + +**Code Quality:** +- [ ] Follows naming conventions +- [ ] Properly organized with MARK sections +- [ ] Comments explain non-obvious logic +- [ ] No hardcoded credentials + +**API Usage:** +- [ ] Methods called in correct order +- [ ] Required parameters provided +- [ ] Return values checked +- [ ] No deprecated APIs + +**Resources:** +- [ ] Audio/video properly stopped +- [ ] Observers unregistered +- [ ] Timers invalidated +- [ ] No resource leaks + +**Platform:** +- [ ] Using Cocoa (AppKit) +- [ ] No UIKit or SwiftUI +- [ ] Window lifecycle handled +- [ ] No modern C++ patterns unless existing + +--- + +## Common Issues and Fixes + +### Issue: "Engine not initialized" +**Cause:** `destroy()` called without `leaveChannel()` first +**Fix:** Always call `leaveChannel()` before `destroy()` + +### Issue: "UI updates crash the app" +**Cause:** Direct UI update from background thread +**Fix:** Wrap with `DispatchQueue.main.async { ... }` + +### Issue: "Memory leak detected" +**Cause:** `destroy()` not called or engine recreated +**Fix:** Ensure `destroy()` in `viewWillClose()` and create engine once + +### Issue: "Token expired error" +**Cause:** No token refresh handling +**Fix:** Implement `tokenPrivilegeWillExpire()` delegate method + +### Issue: "No audio/video" +**Cause:** Permissions not requested +**Fix:** Request permissions before `enableAudio()` / `enableVideo()` + +--- + +## References + +- **Agora RTC SDK for macOS:** [Documentation](https://docs.agora.io/en/video-calling/reference/macos-sdk) +- **Existing examples:** Review `APIExample/Examples/Basic/JoinChannelVideo/` for reference +- **BaseViewController:** Check `APIExample/Common/` for base class implementation diff --git a/macOS/.agent/skills/upsert-case/SKILL.md b/macOS/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..4e7b61909 --- /dev/null +++ b/macOS/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,185 @@ +--- +name: upsert-case +description: > + Add a new API example or modify an existing one. Covers both creation and modification scenarios, + including file structure, per-example storyboard creation, registration, and ARCHITECTURE.md updates. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: macOS +--- + +# Upsert Case Skill — macOS + +## When to Use + +Use this skill when you need to: +- Create a new API example (case) +- Modify an existing example +- Ensure the example is properly registered and documented + +## Applicable Scenarios + +### Scenario 1: Create a New Example + +**Trigger:** User requests a new API demo (e.g., "Add a screen sharing example") + +**Steps:** +1. Determine if the example belongs in `Basic/` or `Advanced/` +2. Create the example folder with PascalCase name +3. Create the Swift implementation file +4. Create the example storyboard +5. Register the example in `ViewController.swift` +6. Update `ARCHITECTURE.md` Case Index +7. Verify compilation and functionality + +### Scenario 2: Modify an Existing Example + +**Trigger:** User requests changes to an existing example (e.g., "Update JoinChannelVideo to support token") + +**Steps:** +1. Locate the example in `APIExample/Examples/[Basic|Advanced]//` +2. Modify the Swift file +3. Modify the storyboard if outlets, actions, or controller identifiers changed +4. Update `ARCHITECTURE.md` Case Index if APIs changed +5. Verify compilation and functionality + +--- + +## Files to Modify + +### New Example + +| File | Action | Notes | +|------|--------|-------| +| `APIExample/Examples/[Basic\|Advanced]//.swift` | Create | Main implementation file | +| `APIExample/Examples/[Basic\|Advanced]//Base.lproj/.storyboard` | Create | Example UI and controller identifier | +| `APIExample/ViewController.swift` | Modify | Register example in menu/list | +| `ARCHITECTURE.md` | Modify | Add entry to Case Index | + +### Modify Existing Example + +| File | Action | Notes | +|------|--------|-------| +| `APIExample/Examples/[Basic\|Advanced]//.swift` | Modify | Update implementation | +| `APIExample/Examples/[Basic\|Advanced]//Base.lproj/.storyboard` | Modify if needed | Keep outlets and controller identifiers aligned | +| `ARCHITECTURE.md` | Modify | Update Case Index if APIs changed | + +--- + +## Step-by-Step Guide + +### Step 1: Determine Example Category + +- **Basic:** Simple, single-feature examples (JoinChannelVideo, JoinChannelAudio) +- **Advanced:** Complex features, multi-API examples (ScreenShare, CustomVideoSource) + +### Step 2: Create Example Folder + +```bash +mkdir -p APIExample/Examples/[Basic|Advanced]/ +``` + +Example folder name must be PascalCase and match the class name. + +### Step 3: Create Swift Implementation + +Create `APIExample/Examples/[Basic|Advanced]//.swift` + +Use the template from `references/example-template.swift` as a starting point. Replace `` with your example name. + +### Step 4: Create the Example Storyboard + +Create `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard`. + +The storyboard name must match the value passed to `NSStoryboard(name:bundle:)` from `ViewController.swift`, and the main controller identifier in the storyboard must match the `controller` field in `MenuItem`. + +### Step 5: Register in ViewController + +Edit `APIExample/ViewController.swift` and add the example to the menu/list: + +```swift +MenuItem(name: "Example Name".localized, + identifier: "menuCell", + controller: "", + storyboard: "") +``` + +Place it in the correct section (Basic / Advanced). + +### Step 6: Update ARCHITECTURE.md + +Add a new row to the Case Index table in `ARCHITECTURE.md`: + +```markdown +| ExampleName | `Examples/[Basic|Advanced]/ExampleName/` | `api1()`, `api2()`, `api3()` | Brief description of what this example demonstrates | +``` + +**Key APIs column:** List 2-5 core SDK methods used in this example. + +### Step 7: Verify + +- [ ] Code compiles without errors +- [ ] Example appears in the menu/list +- [ ] Example can join channel and receive callbacks +- [ ] `leaveChannel()` and `destroy()` are called on close +- [ ] UI updates happen on main thread +- [ ] Storyboard loads with the expected controller identifier +- [ ] ARCHITECTURE.md Case Index is updated + +--- + +## Code Patterns + +See `references/` directory for code patterns: +- `lifecycle-pattern.swift` — Proper engine lifecycle +- `thread-safety-pattern.swift` — Thread-safe UI updates + +--- + +## NEVER List + +**Do NOT:** +- Forget to call `destroy()` — this causes engine leaks +- Update UI directly from delegate callbacks — always use `DispatchQueue.main.async` +- Create multiple engine instances in one example — use a single shared instance +- Use Objective-C files — Swift only +- Use UIKit or SwiftUI — Cocoa (AppKit) only +- Hardcode App ID or token — use `KeyCenter` +- Forget to implement `AgoraRtcEngineDelegate` for event handling +- Leave the channel without calling `leaveChannel()` first +- Modify examples outside the `APIExample/Examples/[Basic|Advanced]/` structure +- Register a new menu item without also creating the per-example storyboard under the same example folder +- Forget to update `ARCHITECTURE.md` Case Index after adding/modifying an example + +--- + +## Verification Checklist + +After completing the upsert, verify: + +- [ ] Example folder is in correct location (`APIExample/Examples/[Basic|Advanced]//`) +- [ ] Swift file is named `.swift` (PascalCase) +- [ ] Class name is `Main` and extends `BaseViewController` +- [ ] Storyboard exists at `APIExample/Examples/[Basic|Advanced]//Base.lproj/.storyboard` +- [ ] Storyboard identifier matches the `controller` value registered in `ViewController.swift` +- [ ] Example is registered in `ViewController.swift` +- [ ] `initializeAgoraEngine()` creates engine with correct config +- [ ] `joinChannel()` uses token from `KeyCenter` +- [ ] `leaveChannel()` and `destroy()` are called in `viewWillClose()` +- [ ] All delegate callbacks dispatch UI updates to main thread +- [ ] `ARCHITECTURE.md` Case Index includes new/updated example +- [ ] Code compiles without warnings or errors +- [ ] Example appears in the application menu/list +- [ ] Example can successfully join channel and receive callbacks +- [ ] Example properly cleans up resources on close + +--- + +## References + +- **Template files:** See `references/` directory for Swift code templates +- **Existing examples:** Review `APIExample/Examples/Basic/JoinChannelVideo/` for reference implementation +- **SDK documentation:** Refer to Agora RTC SDK for macOS documentation for API details diff --git a/macOS/AGENTS.md b/macOS/AGENTS.md new file mode 100644 index 000000000..2116452eb --- /dev/null +++ b/macOS/AGENTS.md @@ -0,0 +1,69 @@ +# AGENTS.md — macOS + +## Project Context + +This is the Swift + Cocoa implementation of Agora RTC SDK examples for macOS. Before making any changes, read `ARCHITECTURE.md` to understand the structural rules. + +## Build Commands + +```bash +# Install dependencies +pod install + +# Build in Xcode +xcodebuild -workspace APIExample.xcworkspace -scheme APIExample -configuration Release + +# Or open in Xcode and build manually +open APIExample.xcworkspace +``` + +## App ID Configuration + +Configure your Agora App ID in `APIExample/Common/KeyCenter.swift`: + +```swift +struct KeyCenter { + static let AppId: String = "<#YOUR_APP_ID#>" + + // Token is optional for testing, but required for production + static func Token(channelName: String) -> String { + return "<#YOUR_TOKEN#>" + } +} +``` + +## Architecture Red Lines + +**Do NOT:** +- Introduce UIKit or SwiftUI — use Cocoa (AppKit) only +- Use Combine or async/await patterns unless already present in the file being modified +- Create examples outside the `APIExample/Examples/[Basic|Advanced]/` directory structure +- Forget to call `leaveChannel()` and `destroy()` when closing an example +- Update UI from background threads — always dispatch to main thread +- Share engine instances between examples — each example manages its own lifecycle + +## Rules + +### Follow the Architecture + +All work must conform to the rules defined in `ARCHITECTURE.md`: +- Every example is a self-contained class implementing `AgoraRtcEngineDelegate` +- Each example manages its own Agora engine lifecycle +- Configuration is passed via initialization or property injection +- All examples are registered in `APIExample/ViewController.swift` + +### Follow the Existing Language and Framework + +- Language is Swift — do not introduce Objective-C files +- UI framework is Cocoa (AppKit) — do not introduce UIKit or SwiftUI +- State management uses instance variables and delegate callbacks — do not introduce Combine or async/await patterns unless they already exist in the file being modified +- Match the code style, naming, and patterns of existing examples + +### Use Project-Level SKILLs + +For broader tasks, use the skills in `.agent/skills/`: + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify an example | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review example code for lifecycle, thread safety, and convention compliance | diff --git a/macOS/ARCHITECTURE.md b/macOS/ARCHITECTURE.md new file mode 100644 index 000000000..61149da5b --- /dev/null +++ b/macOS/ARCHITECTURE.md @@ -0,0 +1,155 @@ +# macOS ARCHITECTURE + +macOS example project using Swift + Cocoa. Demonstrates Agora RTC SDK features through a collection of self-contained examples organized by complexity. + +## Technology Stack + +- Language: Swift +- UI Framework: Cocoa (AppKit) +- Architecture: Single-window application with example selection +- State: Instance variables + delegate callbacks + +## Directory Structure + +``` +macOS/ +├── APIExample/ +│ ├── Examples/ +│ │ ├── Basic/ +│ │ │ └── / +│ │ │ ├── .swift +│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming) +│ │ └── Advanced/ +│ │ └── / +│ │ ├── .swift +│ │ └── SKILL.md # Per-example agent guide (present or forthcoming) +│ ├── Common/ # Shared utilities (KeyCenter, GlobalSettings, LogUtils, Util) +│ ├── Resources/ +│ ├── Base.lproj/ # Storyboard and localization +│ ├── AppDelegate.swift +│ └── ViewController.swift # Main window controller +├── SimpleFilter/ # Specialized filter example +├── APIExample.xcodeproj/ # Xcode project +├── APIExample.xcworkspace/ # Xcode workspace +├── libs/ # SDK libraries +├── Pods/ # CocoaPods dependencies +├── .agent/skills/ # Agent skills +│ ├── create-api-example/ +│ ├── find-api-example/ +│ └── migrate-api-to-project/ +├── AGENTS.md # Agent guide +└── ARCHITECTURE.md # This file +``` + +## Architectural Rules + +### Example Structure + +Each example lives in its own folder under `APIExample/Examples/Basic/` or `APIExample/Examples/Advanced/` and consists of: +- A Swift file containing the example implementation +- A per-example storyboard, typically at `Base.lproj/.storyboard` + +### Example Pattern + +Each example is a self-contained class that: +- Manages its own Agora engine lifecycle +- Implements `AgoraRtcEngineDelegate` +- Receives configuration via initialization or property injection +- Owns all UI elements for that example + +### Menu Registration + +All examples are registered in `APIExample/ViewController.swift` via a menu or list structure. The example name must match the folder name. + +### Naming + +- Example folder names: PascalCase (e.g., `JoinChannelVideo`) +- Example class: `` (e.g., `JoinChannelVideo`) + +### Common Utilities + +All examples share utilities from `APIExample/Common/`: +- `KeyCenter` — App ID and token +- `GlobalSettings` — Shared runtime configuration +- `LogUtils` — SDK log path +- `Util` — Privatization configuration + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelAudio | `Examples/Basic/JoinChannelAudio/` | `createAgoraRtcEngine()`, `joinChannel()`, `leaveChannel()`, `destroy()` | Basic audio call — join channel and manage audio stream | +| JoinChannelVideo | `Examples/Basic/JoinChannelVideo/` | `createAgoraRtcEngine()`, `joinChannel()`, `setupLocalVideo()`, `setupRemoteVideo()`, `leaveChannel()`, `destroy()` | Basic video call — join channel and render local/remote video | +| JoinChannelVideo(Token) | `Examples/Basic/JoinChannelVideo(Token)/` | `createAgoraRtcEngine()`, `joinChannel()` with token, `setupLocalVideo()`, `setupRemoteVideo()` | Video call with token authentication | +| JoinChannelVideo(Recorder) | `Examples/Basic/JoinChannelVideo(Recorder)/` | `createAgoraRtcEngine()`, `joinChannel()`, `startAudioRecording()`, `stopAudioRecording()` | Video call with local audio recording | +| AgoraBeauty | `Examples/Advanced/AgoraBeauty/` | `setBeautyEffectOptions()`, `setVideoEncoderConfiguration()` | Beauty filter and enhancement effects | +| AudioMixing | `Examples/Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()` | Audio file mixing and playback control | +| ChannelMediaRelay | `Examples/Advanced/ChannelMediaRelay/` | `startChannelMediaRelay()`, `updateChannelMediaRelay()`, `stopChannelMediaRelay()` | Relay media streams across multiple channels | +| ContentInspect | `Examples/Advanced/ContentInspect/` | `enableContentInspect()`, `disableContentInspect()` | Content inspection and moderation | +| CreateDataStream | `Examples/Advanced/CreateDataStream/` | `createDataStream()`, `sendStreamMessage()` | Custom data stream creation and messaging | +| CustomAudioRender | `Examples/Advanced/CustomAudioRender/` | `setExternalAudioSink()`, `pullAudioFrame()` | Custom audio rendering pipeline | +| CustomAudioSource | `Examples/Advanced/CustomAudioSource/` | `setExternalAudioSource()`, `pushAudioFrame()` | Custom audio source capture | +| CustomVideoRender | `Examples/Advanced/CustomVideoRender/` | `setExternalVideoSink()`, `pullVideoFrame()` | Custom video rendering pipeline | +| CustomVideoSourceMediaIO | `Examples/Advanced/CustomVideoSourceMediaIO/` | `setExternalVideoSource()`, `pushVideoFrame()` with MediaIO | Custom video source with media I/O | +| CustomVideoSourcePush | `Examples/Advanced/CustomVideoSourcePush/` | `setExternalVideoSource()`, `pushVideoFrame()` | Custom video source push | +| CustomVideoSourcePushMulti | `Examples/Advanced/CustomVideoSourcePushMulti/` | `setExternalVideoSource()`, `pushVideoFrame()` with multiple sources | Multiple custom video sources | +| FaceCapture | `Examples/Advanced/FaceCapture/` | `enableFaceDetection()`, `getFaceDetectionResult()` | Face detection and capture | +| JoinMultiChannel | `Examples/Advanced/JoinMultiChannel/` | `createRtcChannel()`, `joinChannel()` on multiple channels | Join and manage multiple channels simultaneously | +| LiveStreaming | `Examples/Advanced/LiveStreaming/` | `setClientRole()`, `startRtmpStreamWithTranscoding()`, `stopRtmpStream()` | RTMP live streaming with transcoding | +| LocalVideoTranscoding | `Examples/Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoding()`, `updateLocalTranscodingConfig()`, `stopLocalVideoTranscoding()` | Local video transcoding and composition | +| MediaPlayer | `Examples/Advanced/MediaPlayer/` | `createMediaPlayer()`, `open()`, `play()`, `pause()`, `stop()` | Media file playback and control | +| MultiCameraSourece | `Examples/Advanced/MultiCameraSourece/` | `enumerateDevices()`, `setDevice()` with multiple cameras | Multiple camera source selection | +| Multipath | `Examples/Advanced/Multipath/` | `enableMultipath()`, `setMultipathConfig()` | Multipath redundancy for reliability | +| PrecallTest | `Examples/Advanced/PrecallTest/` | `startEchoTest()`, `stopEchoTest()`, `startNetworkTest()`, `stopNetworkTest()` | Pre-call network and device testing | +| QuickSwitchChannel | `Examples/Advanced/QuickSwitchChannel/` | `switchChannel()` | Quick channel switching without reconnection | +| RawAudioData | `Examples/Advanced/RawAudioData/` | `setAudioFrameDelegate()`, `onMixedAudioFrame()` | Raw audio frame access and processing | +| RawMediaData | `Examples/Advanced/RawMediaData/` | `setVideoFrameDelegate()`, `setAudioFrameDelegate()` | Raw audio and video frame access | +| RawVideoData | `Examples/Advanced/RawVideoData/` | `setVideoFrameDelegate()`, `onCapturedVideoFrame()`, `onRemoteVideoFrame()` | Raw video frame capture and processing | +| RtePlayer | `Examples/Advanced/RtePlayer/` | `createMediaPlayer()`, `open()` with RTE protocol | RTE protocol media playback | +| RTMPStreaming | `Examples/Advanced/RTMPStreaming/` | `startRtmpStreamWithTranscoding()`, `updateRtmpTranscodingConfig()`, `stopRtmpStream()` | RTMP streaming with live transcoding | +| ScreenShare | `Examples/Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCaptureParameters()`, `stopScreenCapture()` | Screen sharing and capture | +| SimpleFilter | `Examples/Advanced/SimpleFilter/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Simple video filter effects | +| Simulcast | `Examples/Advanced/Simulcast/` | `setSimulcastConfig()`, `enableSimulcast()` | Simulcast streaming with multiple bitrates | +| SpatialAudio | `Examples/Advanced/SpatialAudio/` | `getLocalSpatialAudioEngine()`, `updateSelfPosition()`, `updateRemotePosition()` | 3D spatial audio positioning | +| StreamEncryption | `Examples/Advanced/StreamEncryption/` | `enableEncryption()`, `setEncryptionConfig()` | Stream encryption and security | +| VideoProcess | `Examples/Advanced/VideoProcess/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Video processing and enhancement | +| VoiceChanger | `Examples/Advanced/VoiceChanger/` | `setVoiceBeautifierPreset()`, `setAudioEffectPreset()` | Voice effects and voice changer | + +## Engine Lifecycle + +``` +1. Create Engine + createAgoraRtcEngine() + +2. Initialize Engine + initialize(AgoraRtcEngineConfig) + +3. Enable Features (optional) + enableVideo(), enableAudio() + +4. Setup Local Media (optional) + setupLocalVideo(), startAudioMixing() + +5. Join Channel + joinChannel(token, channelName, uid) + +6. Handle Callbacks + onJoinChannelSuccess(), onUserJoined(), onUserOffline() + +7. Leave Channel + leaveChannel() + +8. Destroy Engine + destroy() +``` + +## Token Flow + +Token is obtained from `KeyCenter.swift` and passed to `joinChannel()`: + +```swift +let token = KeyCenter.Token(channelName: channelName) +agoraKit.joinChannel(byToken: token, channelName: channelName, info: nil, uid: 0) +``` + +For production, tokens should be generated server-side and refreshed before expiration. diff --git a/macOS/CLAUDE.md b/macOS/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/macOS/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. diff --git a/windows/.agent/skills/review-case/SKILL.md b/windows/.agent/skills/review-case/SKILL.md new file mode 100644 index 000000000..ead1ccdfa --- /dev/null +++ b/windows/.agent/skills/review-case/SKILL.md @@ -0,0 +1,461 @@ +--- +name: review-case +description: > + Code review for API examples. Ensures examples follow project conventions, + handle lifecycle correctly, manage threads safely, and use APIs properly. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: Windows +--- + +# Review Case Skill — Windows + +## When to Use + +Use this skill when you need to: +- Review a new or modified example for correctness +- Ensure the example follows project conventions +- Verify lifecycle management and thread safety +- Check API usage and error handling + +## Review Dimensions (Priority Order) + +### 1. Engine Lifecycle (CRITICAL) + +**Check:** +- [ ] Engine is created in `InitializeAgoraEngine()` or similar +- [ ] Engine is initialized with `RtcEngineContext` +- [ ] `leaveChannel()` is called before `release()` +- [ ] `release()` is called in the case's real cleanup path +- [ ] No engine leaks (engine not recreated on every join) + +**Correct Pattern A — standalone dialog teardown:** +```cpp +BOOL CExampleDlg::OnInitDialog() { + CDialogEx::OnInitDialog(); + InitializeAgoraEngine(); // Create once + return TRUE; +} + +void CExampleDlg::PostNcDestroy() { + LeaveChannel(); + if (m_rtcEngine) { + m_rtcEngine->release(); + m_rtcEngine = nullptr; + } + CDialogEx::PostNcDestroy(); + delete this; +} + +void CExampleDlg::JoinChannel() { + if (!m_rtcEngine) return; + m_rtcEngine->joinChannel(token, channelName, "", 0); +} + +void CExampleDlg::LeaveChannel() { + if (!m_rtcEngine) return; + m_rtcEngine->leaveChannel(); +} +``` + +**Correct Pattern B — scene-switching dialog teardown:** +```cpp +bool CExampleDlg::InitAgora() { + m_rtcEngine = createAgoraRtcEngine(); + // initialize once when the scene becomes active + return m_rtcEngine != nullptr; +} + +void CExampleDlg::UnInitAgora() { + if (!m_rtcEngine) return; + if (m_joinChannel) { + m_rtcEngine->leaveChannel(); + } + m_rtcEngine->release(nullptr); + m_rtcEngine = nullptr; +} + +void CExampleDlg::OnShowWindow(BOOL bShow, UINT nStatus) { + CDialogEx::OnShowWindow(bShow, nStatus); + if (!bShow) { + UnInitAgora(); + } +} +``` + +Accept either pattern as long as the dialog follows one lifecycle consistently and does not leak the engine across scene switches. + +**Incorrect Pattern:** +See `references/incorrect-lifecycle.cpp` for common mistakes. + +--- + +### 2. Thread Safety (CRITICAL) + +**Check:** +- [ ] All UI updates in event handler use message map pattern +- [ ] Event handler posts messages to main thread via `PostMessage()` +- [ ] No direct UI updates from background threads +- [ ] Message handlers update UI on main thread + +**Correct Pattern:** +```cpp +// Event handler (may be called from background thread) +void CExampleRtcEngineEventHandler::onJoinChannelSuccess(const char* channel, uid_t uid, int elapsed) { + if (m_hMsgHandler) { + // Post message to main thread + ::PostMessage(m_hMsgHandler, WM_MSGID(EID_JOIN_CHANNEL_SUCCESS), (WPARAM)uid, 0); + } +} + +// Message handler (runs on main thread) +LRESULT CExampleDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) { + // Safe to update UI here + m_statusText.SetWindowText(_T("Joined channel")); + return 0; +} +``` + +**Incorrect Pattern:** +See `references/incorrect-thread-safety.cpp` for common mistakes. + +--- + +### 3. Permission Handling (HIGH) + +**Check:** +- [ ] Microphone permission checked before `enableAudio()` +- [ ] Camera permission checked before `enableVideo()` +- [ ] Device availability verified + +**Correct Pattern:** +```cpp +void CExampleDlg::InitializeAgoraEngine() { + m_rtcEngine = createAgoraRtcEngine(); + if (!m_rtcEngine) return; + + RtcEngineContext context; + context.appId = CConfig::GetAppId(); + context.eventHandler = &m_eventHandler; + m_eventHandler.SetMsgReceiver(m_hWnd); + + m_rtcEngine->initialize(context); + + // Check device availability + if (m_rtcEngine->enableVideo() == 0) { + // Video enabled successfully + } + if (m_rtcEngine->enableAudio() == 0) { + // Audio enabled successfully + } +} +``` + +--- + +### 4. Error Handling (HIGH) + +**Check:** +- [ ] `joinChannel()` return value checked +- [ ] Token expiration is handled +- [ ] Network errors are logged or displayed +- [ ] Invalid parameters are validated +- [ ] `onError()` callback implemented + +**Correct Pattern:** +```cpp +void CExampleDlg::JoinChannel() { + if (!m_rtcEngine) return; + + const char* token = CConfig::GetToken("test"); + int ret = m_rtcEngine->joinChannel(token, "test", "", 0); + if (ret != 0) { + // Handle error + MessageBox(_T("Failed to join channel"), _T("Error")); + } +} + +void CExampleRtcEngineEventHandler::onError(int err) { + if (m_hMsgHandler) { + ::PostMessage(m_hMsgHandler, WM_MSGID(EID_ERROR), (WPARAM)err, 0); + } +} + +LRESULT CExampleDlg::OnMsgEngineEvent(WPARAM wParam, LPARAM lParam) { + if (wParam == EID_ERROR) { + int errorCode = (int)lParam; + // Handle error + } + return 0; +} +``` + +--- + +### 5. Code Convention (MEDIUM) + +**Check:** +- [ ] Dialog class name follows pattern: `CDlg` +- [ ] Event handler class name: `CRtcEngineEventHandler` +- [ ] File names match class names (PascalCase with C prefix) +- [ ] Member variables use `m_` prefix +- [ ] Message map properly defined +- [ ] Comments explain non-obvious logic + +**Correct Pattern:** +```cpp +// Header: CScreenShareDlg.h +class CScreenShareRtcEngineEventHandler : public IRtcEngineEventHandler { + // ... +}; + +class CScreenShareDlg : public CDialogEx { + DECLARE_DYNAMIC(CScreenShareDlg) + +private: + IRtcEngine* m_rtcEngine = nullptr; + CScreenShareRtcEngineEventHandler m_eventHandler; + uid_t m_remoteUid = 0; + bool m_isJoined = false; + + BEGIN_MESSAGE_MAP(CScreenShareDlg, CDialogEx) + ON_BN_CLICKED(IDC_BUTTON_JOIN, &CScreenShareDlg::OnBnClickedButtonJoin) + END_MESSAGE_MAP() +}; +``` + +--- + +### 6. API Usage Correctness (MEDIUM) + +**Check:** +- [ ] SDK methods called in correct order +- [ ] Required parameters are provided +- [ ] Optional parameters are used correctly +- [ ] Return values are checked where necessary +- [ ] Deprecated APIs are not used + +**Correct Pattern:** +```cpp +// Correct order: create -> initialize -> enable -> join +m_rtcEngine = createAgoraRtcEngine(); +m_rtcEngine->initialize(context); +m_rtcEngine->enableVideo(); +m_rtcEngine->enableAudio(); +m_rtcEngine->joinChannel(token, channelName, "", 0); +``` + +**Incorrect Pattern:** +```cpp +// ❌ Wrong order +m_rtcEngine->joinChannel(...); // Join first +m_rtcEngine->enableVideo(); // Enable after join (too late) +``` + +--- + +### 7. Resource Cleanup (MEDIUM) + +**Check:** +- [ ] Audio files are stopped and released +- [ ] Video captures are stopped +- [ ] Custom audio/video sources are cleaned up +- [ ] Observers are unregistered +- [ ] Timers are killed + +**Correct Pattern:** +```cpp +void CExampleDlg::LeaveChannel() { + if (!m_rtcEngine) return; + + m_rtcEngine->stopAudioMixing(); // Stop audio + m_rtcEngine->stopScreenCapture(); // Stop screen share + m_rtcEngine->leaveChannel(); + m_isJoined = false; +} + +void CExampleDlg::PostNcDestroy() { + LeaveChannel(); + if (m_rtcEngine) { + m_rtcEngine->release(); + m_rtcEngine = nullptr; + } + CDialogEx::PostNcDestroy(); + delete this; +} +``` + +--- + +## Review Output Format + +When reviewing, provide feedback in this format: + +``` +## Review Results + +### ✅ Passed +- Engine lifecycle correctly managed +- Thread safety ensured with message map pattern +- Error handling implemented for joinChannel + +### ⚠️ Issues Found + +**[HIGH] Thread Safety Issue** +- File: `CScreenShareDlg.cpp` +- Line: 45 +- Issue: Direct UI update in event handler without PostMessage +- Suggestion: Use PostMessage to post event to main thread + +**[MEDIUM] Missing Error Handling** +- File: `CScreenShareDlg.cpp` +- Line: 78 +- Issue: joinChannel() return value not checked +- Suggestion: Check return value and handle errors + +### 🔧 Recommendations +- Add logging for debugging +- Consider adding retry logic for network failures +``` + +--- + +## Platform-Specific Checks + +### Windows-Specific + +**Check:** +- [ ] Using MFC — not WinForms or WPF +- [ ] Using C++ — not C# +- [ ] Following MFC naming conventions (C prefix, m_ prefix) +- [ ] Message map properly defined +- [ ] Dialog resource properly configured +- [ ] No modern C++ patterns unless already in codebase + +**Correct Pattern:** +```cpp +// Windows: Use MFC +#include "stdafx.h" +#include "APIExample.h" + +class CExampleDlg : public CDialogEx { + DECLARE_DYNAMIC(CExampleDlg) + + BEGIN_MESSAGE_MAP(CExampleDlg, CDialogEx) + ON_BN_CLICKED(IDC_BUTTON_JOIN, &CExampleDlg::OnBnClickedButtonJoin) + END_MESSAGE_MAP() +}; +``` + +**Incorrect Pattern:** +```cpp +// ❌ Non-MFC patterns +using namespace std; // Avoid in MFC +auto ptr = std::make_unique(); // Modern C++ not typical in MFC +``` + +--- + +## NEVER List + +**Do NOT accept:** +- Engine not released (memory leak) +- Direct UI updates from event handler without PostMessage +- Multiple engine instances in one example +- Hardcoded App ID or token (must use CConfig) +- Missing `leaveChannel()` before `release()` +- C# or other languages (C++ only) +- WinForms or WPF (MFC only) +- Examples outside `APIExample/APIExample/[Basic|Advanced]/` structure +- Missing event handler implementation +- No error handling for joinChannel failures +- Deviation from MFC naming conventions + +--- + +## Review Checklist + +Use this checklist when reviewing an example: + +**Lifecycle:** +- [ ] Engine created once in initialization +- [ ] `leaveChannel()` called before `release()` +- [ ] `release()` called in `PostNcDestroy()` +- [ ] No engine leaks + +**Thread Safety:** +- [ ] All UI updates use message map pattern +- [ ] Event handler posts messages via `PostMessage()` +- [ ] No direct UI updates from callbacks +- [ ] Message handlers run on main thread + +**Permissions:** +- [ ] Microphone availability checked +- [ ] Camera availability checked +- [ ] Device errors handled + +**Error Handling:** +- [ ] joinChannel return value checked +- [ ] Token expiration handled +- [ ] Network errors logged +- [ ] onError() callback implemented + +**Code Quality:** +- [ ] Follows MFC naming conventions +- [ ] Message map properly defined +- [ ] Comments explain non-obvious logic +- [ ] No hardcoded credentials + +**API Usage:** +- [ ] Methods called in correct order +- [ ] Required parameters provided +- [ ] Return values checked +- [ ] No deprecated APIs + +**Resources:** +- [ ] Audio/video properly stopped +- [ ] Observers unregistered +- [ ] Timers killed +- [ ] No resource leaks + +**Platform:** +- [ ] Using MFC (not WinForms/WPF) +- [ ] Using C++ (not C#) +- [ ] Following MFC conventions +- [ ] No modern C++ patterns unless existing + +--- + +## Common Issues and Fixes + +### Issue: "Engine not initialized" +**Cause:** `release()` called without `leaveChannel()` first +**Fix:** Always call `leaveChannel()` before `release()` + +### Issue: "UI crashes or doesn't update" +**Cause:** Direct UI update from event handler +**Fix:** Use PostMessage to post event to main thread + +### Issue: "Memory leak detected" +**Cause:** `release()` not called or engine recreated +**Fix:** Ensure `release()` in `PostNcDestroy()` and create engine once + +### Issue: "Token expired error" +**Cause:** No token refresh handling +**Fix:** Implement token refresh in error handler + +### Issue: "No audio/video" +**Cause:** Device not available or not enabled +**Fix:** Check return values of `enableAudio()` / `enableVideo()` + +--- + +## References + +- **Agora RTC SDK for Windows:** [Documentation](https://docs.agora.io/en/video-calling/reference/windows-sdk) +- **Existing examples:** Review `APIExample/APIExample/Basic/JoinChannelVideoByToken/` for reference +- **MFC Documentation:** [Microsoft Foundation Classes](https://docs.microsoft.com/en-us/cpp/mfc/mfc-desktop-applications) +- **Message Map:** [MFC Message Maps](https://docs.microsoft.com/en-us/cpp/mfc/message-maps) diff --git a/windows/.agent/skills/upsert-case/SKILL.md b/windows/.agent/skills/upsert-case/SKILL.md new file mode 100644 index 000000000..f809c4b4a --- /dev/null +++ b/windows/.agent/skills/upsert-case/SKILL.md @@ -0,0 +1,213 @@ +--- +name: upsert-case +description: > + Add a new API example or modify an existing one. Covers both creation and modification scenarios, + including dialog class structure, registration in APIExampleDlg, localization wiring, and + ARCHITECTURE.md updates. +compatibility: [Cursor, Kiro, Windsurf, Claude, Copilot] +license: MIT +metadata: + author: APIExample Team + version: 1.0.0 + platform: Windows +--- + +# Upsert Case Skill — Windows + +## When to Use + +Use this skill when you need to: +- Create a new API example (case) +- Modify an existing example +- Ensure the example is properly registered and documented + +## Applicable Scenarios + +### Scenario 1: Create a New Example + +**Trigger:** User requests a new API demo (e.g., "Add a screen sharing example") + +**Steps:** +1. Determine if the example belongs in `Basic/` or `Advanced/` +2. Create the example folder with PascalCase name +3. Create `.h` and `.cpp` files for the dialog class +4. Register the example in `APIExampleDlg.h` and `APIExampleDlg.cpp` +5. Add scene label wiring in `Language.h`, `stdafx.cpp`, and language `.ini` files +6. Update `ARCHITECTURE.md` Case Index +7. Verify compilation and functionality + +### Scenario 2: Modify an Existing Example + +**Trigger:** User requests changes to an existing example (e.g., "Update JoinChannelVideo to support token") + +**Steps:** +1. Locate the example in `APIExample/APIExample/[Basic|Advanced]//` +2. Modify the `.h` and `.cpp` files +3. Update `APIExampleDlg.cpp` if routing or lifecycle hooks changed +4. Update `ARCHITECTURE.md` Case Index if APIs changed +5. Verify compilation and functionality + +--- + +## Files to Modify + +### New Example + +| File | Action | Notes | +|------|--------|-------| +| `APIExample/APIExample/[Basic\|Advanced]//CDlg.h` | Create | Dialog class header | +| `APIExample/APIExample/[Basic\|Advanced]//CDlg.cpp` | Create | Dialog class implementation | +| `APIExample/APIExample/APIExampleDlg.h` | Modify | Add include and dialog member pointer | +| `APIExample/APIExample/APIExampleDlg.cpp` | Modify | Register, create, show, and release the dialog | +| `APIExample/APIExample/Language.h` | Modify | Declare the localized scene label | +| `APIExample/APIExample/stdafx.cpp` | Modify | Initialize the localized scene label in `InitKeyInfomation()` | +| `APIExample/APIExample/en.ini` | Modify | Add English display text | +| `APIExample/APIExample/zh-cn.ini` | Modify | Add Chinese display text | +| `APIExample/APIExample/APIExample.vcxproj` | Modify if needed | Add new source/header files when not using the Visual Studio UI | +| `APIExample/APIExample/APIExample.vcxproj.filters` | Modify if needed | Keep Solution Explorer grouping correct | +| `ARCHITECTURE.md` | Modify | Add entry to Case Index | + +### Modify Existing Example + +| File | Action | Notes | +|------|--------|-------| +| `APIExample/APIExample/[Basic\|Advanced]//CDlg.h` | Modify | Update dialog class | +| `APIExample/APIExample/[Basic\|Advanced]//CDlg.cpp` | Modify | Update implementation | +| `APIExample/APIExample/APIExampleDlg.cpp` | Modify if routing changes | Update show/hide or scene selection behavior if needed | +| `ARCHITECTURE.md` | Modify | Update Case Index if APIs changed | + +--- + +## Step-by-Step Guide + +### Step 1: Determine Example Category + +- **Basic:** Simple, single-feature examples (JoinChannelVideo, LiveBroadcasting) +- **Advanced:** Complex features, multi-API examples (ScreenShare, CustomVideoCapture) + +### Step 2: Create Example Folder + +```bash +mkdir APIExample\APIExample\[Basic|Advanced]\ +``` + +Example folder name must be PascalCase. + +### Step 3: Create Dialog Header File + +Create `APIExample/APIExample/[Basic|Advanced]//CDlg.h` + +Use the template from `references/example-template.h` as a starting point. Replace `` with your example name. + +### Step 4: Create Dialog Implementation File + +Create `APIExample/APIExample/[Basic|Advanced]//CDlg.cpp` + +Use the template from `references/example-template.cpp` as a starting point. Replace `` with your example name. + +### Step 5: Register in APIExampleDlg + +Do not edit `CSceneDialog.cpp` for case registration. In this project, scene ownership lives in the main dialog: + +- `APIExample/APIExample/APIExampleDlg.h` + Add the example header include and a member pointer such as `CDlg* m_pDlg = nullptr;` +- `APIExample/APIExample/APIExampleDlg.cpp` + Mirror an existing example across: + - `InitSceneDialog()` to push the localized label into `m_vecBasic` or `m_vecAdvanced`, create the dialog, and position it + - `CreateScene()` to call the dialog's init/show path when the tree item is selected + - `ReleaseScene()` to call the dialog's cleanup/hide path when the tree item is left + +`InitSceneList()` reads from `m_vecBasic` and `m_vecAdvanced`, so the tree updates automatically once the vectors are populated in `InitSceneDialog()`. + +### Step 6: Add localized scene labels + +Register the example name used by the tree view: + +- Add an `extern wchar_t ...[INFO_LEN];` declaration to `APIExample/APIExample/Language.h` +- Initialize it in `APIExample/APIExample/stdafx.cpp` inside `InitKeyInfomation()` +- Add matching keys to `APIExample/APIExample/en.ini` and `APIExample/APIExample/zh-cn.ini` + +Follow the existing naming pattern such as `Basic.JoinChannelVideoByToken` or `Advanced.ScreenCap`. + +### Step 7: Update ARCHITECTURE.md + +Add a new row to the Case Index table in `ARCHITECTURE.md`: + +```markdown +| ExampleName | `[Basic|Advanced]/ExampleName/` | `api1()`, `api2()`, `api3()` | Brief description of what this example demonstrates | +``` + +**Key APIs column:** List 2-5 core SDK methods used in this example. + +### Step 8: Verify + +- [ ] Code compiles without errors +- [ ] Example appears in the scene list +- [ ] Example can join channel and receive callbacks +- [ ] `leaveChannel()` and `release()` are called on close +- [ ] UI updates happen on main thread (via message map) +- [ ] Localized labels resolve correctly in both `en.ini` and `zh-cn.ini` +- [ ] ARCHITECTURE.md Case Index is updated + +--- + +## Code Patterns + +See `references/` directory for code patterns: +- `lifecycle-pattern.cpp` — Proper engine lifecycle +- `message-map-pattern.cpp` — Message map pattern for thread-safe UI updates +- `event-handler-pattern.cpp` — Event handler pattern + +--- + +## NEVER List + +**Do NOT:** +- Forget to call `release()` — this causes engine leaks +- Update UI directly from event handler callbacks — use message map pattern +- Create multiple engine instances in one example — use a single shared instance +- Use C# or other languages — C++ only +- Use WinForms or WPF — MFC only +- Deviate from MFC naming conventions (`C` prefix, `m_` prefix) +- Hardcode App ID or token — use `CConfig` +- Forget to implement `IRtcEngineEventHandler` for event handling +- Leave the channel without calling `leaveChannel()` first +- Register a new case in `CSceneDialog.cpp` — registration lives in `APIExampleDlg.h` and `APIExampleDlg.cpp` +- Modify examples outside the `APIExample/APIExample/[Basic|Advanced]/` structure +- Forget to update `ARCHITECTURE.md` Case Index after adding/modifying an example +- Use modern C++ patterns (lambdas, smart pointers) unless already in the file + +--- + +## Verification Checklist + +After completing the upsert, verify: + +- [ ] Example folder is in correct location (`APIExample/APIExample/[Basic|Advanced]//`) +- [ ] Header file is named `CDlg.h` (with C prefix) +- [ ] Implementation file is named `CDlg.cpp` +- [ ] Dialog class inherits from `CDialogEx` or `CDialog` +- [ ] Event handler implements `IRtcEngineEventHandler` +- [ ] Message map properly defined with `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP` +- [ ] Example is registered in `APIExampleDlg.h` and `APIExampleDlg.cpp` +- [ ] Scene label is declared in `Language.h` and initialized in `stdafx.cpp` +- [ ] Scene label has entries in both `en.ini` and `zh-cn.ini` +- [ ] `InitializeAgoraEngine()` creates engine with correct config +- [ ] `JoinChannel()` uses token from `CConfig` +- [ ] `LeaveChannel()` and `release()` are called in `PostNcDestroy()` +- [ ] All engine events posted to main thread via `PostMessage()` +- [ ] `APIExample.vcxproj` and `.filters` include the new files when they were added outside the IDE +- [ ] `ARCHITECTURE.md` Case Index includes new/updated example +- [ ] Code compiles without warnings or errors +- [ ] Example appears in the scene list +- [ ] Example can successfully join channel and receive callbacks +- [ ] Example properly cleans up resources on close + +--- + +## References + +- **Template files:** See `references/` directory for C++ code templates +- **Existing examples:** Review `APIExample/APIExample/Basic/JoinChannelVideoByToken/` for reference implementation +- **SDK documentation:** Refer to Agora RTC SDK for Windows documentation for API details +- **MFC Documentation:** [Microsoft Foundation Classes](https://docs.microsoft.com/en-us/cpp/mfc/mfc-desktop-applications) diff --git a/windows/AGENTS.md b/windows/AGENTS.md new file mode 100644 index 000000000..5e3601839 --- /dev/null +++ b/windows/AGENTS.md @@ -0,0 +1,70 @@ +# AGENTS.md — Windows + +## Project Context + +This is the C++ + MFC implementation of Agora RTC SDK examples for Windows. Before making any changes, read `ARCHITECTURE.md` to understand the structural rules. + +## Build Commands + +```bash +# Build using Visual Studio (from command line) +cd windows/APIExample +msbuild APIExample.sln /p:Configuration=Release /p:Platform=x64 + +# Or open in Visual Studio and build manually +start APIExample.sln +``` + +## App ID Configuration + +Configure your Agora App ID in `APIExample/APIExample/CConfig.h` and `CConfig.cpp`: + +```cpp +// CConfig.h +class CConfig { +public: + static const char* GetAppId() { return "<#YOUR_APP_ID#>"; } + static const char* GetToken(const char* channelName) { return "<#YOUR_TOKEN#>"; } +}; +``` + +## Architecture Red Lines + +**Do NOT:** +- Introduce C# or other languages — use C++ only +- Use WinForms or WPF — use MFC only +- Deviate from MFC naming conventions (`C` prefix for classes, `m_` prefix for members) +- Use modern C++ patterns (lambdas, smart pointers) unless already present in the file being modified +- Forget to call `leaveChannel()` and `release()` when closing an example +- Update UI from background threads — always post messages to the main thread +- Share engine instances between examples — each example manages its own lifecycle +- Forget to implement `IAgoraRtcEngineEventHandler` for event handling + +## Rules + +### Follow the Architecture + +All work must conform to the rules defined in `ARCHITECTURE.md`: +- Every example is a dialog class inheriting from `CDialogEx` or `CDialog` +- Each example implements `IAgoraRtcEngineEventHandler` interface +- Each example manages its own Agora engine lifecycle +- Message handlers are defined via `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP` +- All examples are registered in `APIExampleDlg.h` and `APIExampleDlg.cpp` +- Configuration is managed centrally via `CConfig` class + +### Follow the Existing Language and Framework + +- Language is C++ — do not introduce C# or other languages +- UI framework is MFC — do not introduce WinForms or WPF +- Use MFC conventions: `C` prefix for classes, `m_` prefix for member variables +- Use message map pattern for event handling — do not introduce modern C++ patterns unless they already exist in the file being modified +- Match the code style, naming, and patterns of existing examples + +### Use Project-Level SKILLs + +For broader tasks, use the skills in `.agent/skills/`: + +| Task | Skill | When to use | +|------|-------|-------------| +| Add or modify an example | `.agent/skills/upsert-case/` | Need to create a new API demo or update an existing one | +| Code review | `.agent/skills/review-case/` | Review example code for lifecycle, thread safety, and convention compliance | diff --git a/windows/ARCHITECTURE.md b/windows/ARCHITECTURE.md new file mode 100644 index 000000000..e92efe599 --- /dev/null +++ b/windows/ARCHITECTURE.md @@ -0,0 +1,172 @@ +# Windows ARCHITECTURE + +Windows example project using C++ + MFC (Microsoft Foundation Classes). Demonstrates Agora RTC SDK features through a collection of self-contained dialog-based examples organized by complexity. + +## Technology Stack + +- Language: C++ +- UI Framework: MFC (Microsoft Foundation Classes) +- Architecture: Dialog-based application with example selection +- State: Member variables + message map callbacks + +## Directory Structure + +``` +windows/ +├── APIExample/ +│ ├── APIExample/ +│ │ ├── Basic/ +│ │ │ └── / +│ │ │ ├── CDlg.cpp +│ │ │ ├── CDlg.h +│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming) +│ │ ├── Advanced/ +│ │ │ └── / +│ │ │ ├── CDlg.cpp +│ │ │ ├── CDlg.h +│ │ │ └── SKILL.md # Per-example agent guide (present or forthcoming) +│ │ ├── DirectShow/ # DirectShow utilities +│ │ ├── dsound/ # DirectSound utilities +│ │ ├── res/ # Resources (icons, dialogs, strings) +│ │ ├── APIExample.cpp +│ │ ├── APIExample.h +│ │ ├── APIExampleDlg.cpp # Main dialog and example registration +│ │ ├── APIExampleDlg.h +│ │ ├── CConfig.cpp # Configuration management +│ │ ├── CConfig.h +│ │ ├── CSceneDialog.cpp # Shared dialog helper, not the case registration source +│ │ └── CSceneDialog.h +│ ├── APIExample.sln # Visual Studio solution +│ ├── cicd/ # CI/CD scripts +│ └── .vscode/ # VS Code configuration +├── .agent/skills/ # Agent skills +│ ├── create-api-example/ +│ ├── find-api-example/ +│ └── migrate-api-to-project/ +├── AGENTS.md # Agent guide +└── ARCHITECTURE.md # This file +``` + +## Architectural Rules + +### Example Structure + +Each example lives in its own folder under `APIExample/APIExample/Basic/` or `APIExample/APIExample/Advanced/` and consists of: +- A `.h` + `.cpp` pair for the dialog class +- Optional: Resource definitions in `.rc` file + +### Dialog-Based Pattern + +Each example is a dialog class that: +- Inherits from `CDialogEx` or `CDialog` +- Implements message handlers via `BEGIN_MESSAGE_MAP` / `END_MESSAGE_MAP` +- Manages its own Agora engine lifecycle +- Implements `IAgoraRtcEngineEventHandler` interface +- Owns all UI controls and state for that example + +### Naming Convention + +- Example folder names: PascalCase (e.g., `JoinChannelVideo`) +- Dialog class: `CDlg` (e.g., `CJoinChannelVideoDlg`) +- Header file: `CDlg.h` +- Implementation file: `CDlg.cpp` + +### Menu Registration + +All examples are registered in `APIExampleDlg.h` and `APIExampleDlg.cpp`. The localized scene name is wired through `Language.h`, `stdafx.cpp`, `en.ini`, and `zh-cn.ini`, and the example name should still match the folder name. + +### Configuration Management + +Configuration is centralized in `CConfig` class: +- App ID management +- Token generation +- Global settings (resolution, frame rate, etc.) + +### Common Utilities + +All examples share utilities: +- `CConfig` — App ID, token, and global settings +- `VideoExtractor` — Video frame extraction +- `YUVReader` — YUV file reading +- DirectShow and DirectSound wrappers + +## Case Index + +| Case | Path | Key APIs | Description | +|------|------|----------|-------------| +| JoinChannelVideoByToken | `Basic/JoinChannelVideoByToken/` | `createAgoraRtcEngine()`, `joinChannel()` with token, `setupLocalVideo()`, `setupRemoteVideo()` | Basic video call with token authentication | +| LiveBroadcasting | `Basic/LiveBroadcasting/` | `setClientRole()`, `joinChannel()`, `startRtmpStreamWithTranscoding()` | Live broadcasting with RTMP streaming | +| AudioEffect | `Advanced/AudioEffect/` | `setAudioEffectPreset()`, `setVoiceBeautifierPreset()` | Audio effects and voice beautification | +| AudioMixing | `Advanced/AudioMixing/` | `startAudioMixing()`, `stopAudioMixing()`, `pauseAudioMixing()`, `resumeAudioMixing()` | Audio file mixing and playback control | +| AudioProfile | `Advanced/AudioProfile/` | `setAudioProfile()`, `setAudioScenario()` | Audio profile and scenario configuration | +| AudioVolume | `Advanced/AudioVolume/` | `adjustRecordingSignalVolume()`, `adjustPlaybackSignalVolume()`, `adjustUserPlaybackSignalVolume()` | Audio volume adjustment and control | +| Beauty | `Advanced/Beauty/` | `setBeautyEffectOptions()`, `setVideoEncoderConfiguration()` | Beauty filter and enhancement effects | +| Beauty2.0 | `Advanced/Beauty2.0/` | `setBeautyEffectOptions()` with v2.0 API | Enhanced beauty effects with v2.0 API | +| BeautyAudio | `Advanced/BeautyAudio/` | `setBeautyEffectOptions()`, `setAudioEffectPreset()` | Combined audio and video beauty effects | +| CrossChannel | `Advanced/CrossChannel/` | `startChannelMediaRelay()`, `updateChannelMediaRelay()`, `stopChannelMediaRelay()` | Media relay across multiple channels | +| CustomAudioCapture | `Advanced/CustomAudioCapture/` | `setExternalAudioSource()`, `pushAudioFrame()` | Custom audio source capture | +| CustomEncrypt | `Advanced/CustomEncrypt/` | `setEncryptionConfig()`, `enableEncryption()` | Custom stream encryption | +| CustomVideoCapture | `Advanced/CustomVideoCapture/` | `setExternalVideoSource()`, `pushVideoFrame()` | Custom video source capture | +| FaceCapture | `Advanced/FaceCapture/` | `enableFaceDetection()`, `getFaceDetectionResult()` | Face detection and capture | +| LocalVideoTranscoding | `Advanced/LocalVideoTranscoding/` | `startLocalVideoTranscoding()`, `updateLocalTranscodingConfig()`, `stopLocalVideoTranscoding()` | Local video transcoding and composition | +| MediaEncrypt | `Advanced/MediaEncrypt/` | `setEncryptionConfig()`, `enableEncryption()` | Media stream encryption | +| MediaPlayer | `Advanced/MediaPlayer/` | `createMediaPlayer()`, `open()`, `play()`, `pause()`, `stop()` | Media file playback and control | +| MediaRecorder | `Advanced/MediaRecorder/` | `startRecording()`, `stopRecording()`, `setRecordingAudioFrameParameters()` | Media recording with custom parameters | +| Metadata | `Advanced/Metadata/` | `registerMediaMetadataObserver()`, `onMetadataReceived()` | Metadata transmission and reception | +| MultiCamera | `Advanced/MultiCamera/` | `enumerateDevices()`, `setDevice()` with multiple cameras | Multiple camera source selection | +| MultiChannel | `Advanced/MultiChannel/` | `createRtcChannel()`, `joinChannel()` on multiple channels | Join and manage multiple channels simultaneously | +| Multipath | `Advanced/Multipath/` | `enableMultipath()`, `setMultipathConfig()` | Multipath redundancy for reliability | +| MultiVideoSource | `Advanced/MultiVideoSource/` | `setExternalVideoSource()`, `pushVideoFrame()` with multiple sources | Multiple video sources | +| MultiVideoSourceTracks | `Advanced/MultiVideoSourceTracks/` | `createCustomVideoTrack()`, `pushVideoFrame()` on custom tracks | Multiple video tracks with custom sources | +| OriginalAudio | `Advanced/OriginalAudio/` | `setAudioFrameDelegate()`, `onPlaybackAudioFrame()` | Raw audio frame access | +| OriginalVideo | `Advanced/OriginalVideo/` | `setVideoFrameDelegate()`, `onCapturedVideoFrame()`, `onRemoteVideoFrame()` | Raw video frame access | +| PreCallTest | `Advanced/PreCallTest/` | `startEchoTest()`, `stopEchoTest()`, `startNetworkTest()`, `stopNetworkTest()` | Pre-call network and device testing | +| PushExternalVideoYUV | `Advanced/PushExternalVideoYUV/` | `setExternalVideoSource()`, `pushVideoFrame()` with YUV format | Push external YUV video frames | +| RegionConn | `Advanced/RegionConn/` | `setCloudProxy()`, `setRegion()` | Region connection and cloud proxy | +| ReportInCall | `Advanced/ReportInCall/` | `startRtcStats()`, `getRtcStats()` | In-call statistics and reporting | +| RtePlayer | `Advanced/RtePlayer/` | `createMediaPlayer()`, `open()` with RTE protocol | RTE protocol media playback | +| RTMPinject | `Advanced/RTMPinject/` | `addInjectStreamUrl()`, `removeInjectStreamUrl()` | RTMP stream injection | +| RTMPStream | `Advanced/RTMPStream/` | `startRtmpStreamWithTranscoding()`, `updateRtmpTranscodingConfig()`, `stopRtmpStream()` | RTMP streaming with live transcoding | +| ScreenShare | `Advanced/ScreenShare/` | `startScreenCapture()`, `updateScreenCaptureParameters()`, `stopScreenCapture()` | Screen sharing and capture | +| Simulcast | `Advanced/Simulcast/` | `setSimulcastConfig()`, `enableSimulcast()` | Simulcast streaming with multiple bitrates | +| SpatialAudio | `Advanced/SpatialAudio/` | `getLocalSpatialAudioEngine()`, `updateSelfPosition()`, `updateRemotePosition()` | 3D spatial audio positioning | +| TransparentBg | `Advanced/TransparentBg/` | `setVideoEncoderConfiguration()`, `setBeautyEffectOptions()` | Transparent background effects | + +## Engine Lifecycle + +``` +1. Create Engine + createAgoraRtcEngine() + +2. Initialize Engine + initialize(RtcEngineContext) + +3. Enable Features (optional) + enableVideo(), enableAudio() + +4. Setup Local Media (optional) + setupLocalVideo(), startAudioMixing() + +5. Join Channel + joinChannel(token, channelName, uid) + +6. Handle Callbacks + onJoinChannelSuccess(), onUserJoined(), onUserOffline() + +7. Leave Channel + leaveChannel() + +8. Release Engine + release() +``` + +## Token Flow + +Token is obtained from `CConfig` and passed to `joinChannel()`: + +```cpp +const char* token = CConfig::GetToken(channelName); +m_rtcEngine->joinChannel(token, channelName, "", 0); +``` + +For production, tokens should be generated server-side and refreshed before expiration. diff --git a/windows/CLAUDE.md b/windows/CLAUDE.md new file mode 100644 index 000000000..2d1c323ad --- /dev/null +++ b/windows/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +This project uses `AGENTS.md` instead of a `CLAUDE.md` file. + +Please see @AGENTS.md in this same directory and treat its content as the primary reference for this project. From a706af429d8afef0d66dcd4eeda48a3b64400196 Mon Sep 17 00:00:00 2001 From: zhangwei Date: Fri, 24 Apr 2026 15:54:13 +0800 Subject: [PATCH 2/4] chore(android): remove deprecated APIExample demos and stale navigation --- .../CDNStreaming/AudienceFragment.java | 593 ------------------ .../advanced/CDNStreaming/EntryFragment.java | 116 ---- .../advanced/CDNStreaming/HostFragment.java | 569 ----------------- .../examples/advanced/InCallReport.java | 485 -------------- .../examples/advanced/PushExternalVideo.java | 555 ---------------- .../main/res/layout/fragment_cdn_audience.xml | 148 ----- .../main/res/layout/fragment_cdn_entry.xml | 68 -- .../src/main/res/layout/fragment_cdn_host.xml | 126 ---- .../res/layout/fragment_in_call_report.xml | 93 --- .../app/src/main/res/navigation/nav_graph.xml | 35 -- .../app/src/main/res/values-zh/strings.xml | 2 - .../app/src/main/res/values/strings.xml | 2 - 12 files changed, 2792 deletions(-) delete mode 100644 Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/AudienceFragment.java delete mode 100644 Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/EntryFragment.java delete mode 100644 Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/CDNStreaming/HostFragment.java delete mode 100644 Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java delete mode 100644 Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java delete mode 100644 Android/APIExample/app/src/main/res/layout/fragment_cdn_audience.xml delete mode 100644 Android/APIExample/app/src/main/res/layout/fragment_cdn_entry.xml delete mode 100644 Android/APIExample/app/src/main/res/layout/fragment_cdn_host.xml delete mode 100644 Android/APIExample/app/src/main/res/layout/fragment_in_call_report.xml 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/InCallReport.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java deleted file mode 100644 index 36ae325be..000000000 --- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/InCallReport.java +++ /dev/null @@ -1,485 +0,0 @@ -package io.agora.api.example.examples.advanced; - -import static io.agora.rtc2.video.VideoCanvas.RENDER_MODE_HIDDEN; -import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE; - -import android.content.Context; -import android.os.Bundle; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.SurfaceView; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatTextView; - -import io.agora.api.example.MainApplication; -import io.agora.api.example.R; -import io.agora.api.example.common.BaseFragment; -import io.agora.api.example.common.model.StatisticsInfo; -import io.agora.api.example.utils.CommonUtil; -import io.agora.api.example.utils.PermissonUtils; -import io.agora.api.example.utils.TokenUtils; -import io.agora.rtc2.Constants; -import io.agora.rtc2.IRtcEngineEventHandler; -import io.agora.rtc2.RtcEngine; -import io.agora.rtc2.RtcEngineConfig; -import io.agora.rtc2.proxy.LocalAccessPointConfiguration; -import io.agora.rtc2.video.VideoCanvas; -import io.agora.rtc2.video.VideoEncoderConfiguration; - -//@Example( -// index = 17, -// group = ADVANCED, -// name = R.string.item_incallreport, -// actionId = R.id.action_mainFragment_to_InCallReport, -// tipsId = R.string.incallstats -//) - -/** - * The type In call report. - * - * @deprecated The report has been moved to - * {@link io.agora.api.example.common.widget.VideoReportLayout}. - * You can refer to {@link LiveStreaming} or {@link io.agora.api.example.examples.basic.JoinChannelVideo} example. - */ -@Deprecated -public class InCallReport extends BaseFragment implements View.OnClickListener { - private static final String TAG = InCallReport.class.getSimpleName(); - - private FrameLayout fl_local, fl_remote; - private Button join; - private EditText et_channel; - private AppCompatTextView localStats, remoteStats; - private RtcEngine engine; - private StatisticsInfo statisticsInfo; - private int myUid; - private boolean joined = false; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_in_call_report, container, false); - return view; - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - join = view.findViewById(R.id.btn_join); - statisticsInfo = new StatisticsInfo(); - et_channel = view.findViewById(R.id.et_channel); - localStats = view.findViewById(R.id.local_stats); - localStats.bringToFront(); - remoteStats = view.findViewById(R.id.remote_stats); - remoteStats.bringToFront(); - view.findViewById(R.id.btn_join).setOnClickListener(this); - fl_local = view.findViewById(R.id.fl_local); - fl_remote = view.findViewById(R.id.fl_remote); - } - - private void updateLocalStats() { - handler.post(new Runnable() { - @Override - public void run() { - localStats.setText(statisticsInfo.getLocalVideoStats()); - } - }); - } - - private void updateRemoteStats() { - handler.post(new Runnable() { - @Override - public void run() { - remoteStats.setText(statisticsInfo.getRemoteVideoStats()); - } - }); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - // Check if the context is valid - Context context = getContext(); - if (context == null) { - return; - } - try { - RtcEngineConfig config = new RtcEngineConfig(); - /* - * The context of Android Activity - */ - config.mContext = context.getApplicationContext(); - /* - * The App ID issued to you by Agora. See How to get the App ID - */ - config.mAppId = getString(R.string.agora_app_id); - /* Sets the channel profile of the Agora RtcEngine. - CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. - Use this profile in one-on-one calls or group calls, where all users can talk freely. - CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast - channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; - an audience can only receive streams.*/ - config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; - /* - * IRtcEngineEventHandler is an abstract class providing default implementation. - * The SDK uses this class to report to the app on SDK runtime events. - */ - config.mEventHandler = iRtcEngineEventHandler; - config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); - config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); - engine = RtcEngine.create(config); - /* - * This parameter is for reporting the usages of APIExample to agora background. - * Generally, it is not necessary for you to set this parameter. - */ - engine.setParameters("{" - + "\"rtc.report_app_scenario\":" - + "{" - + "\"appScenario\":" + 100 + "," - + "\"serviceType\":" + 11 + "," - + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" - + "}" - + "}"); - /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ - LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); - if (localAccessPointConfiguration != null) { - // This api can only be used in the private media server scenario, otherwise some problems may occur. - engine.setLocalAccessPoint(localAccessPointConfiguration); - } - } - catch (Exception e) { - e.printStackTrace(); - getActivity().onBackPressed(); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - /*leaveChannel and Destroy the RtcEngine instance*/ - if (engine != null) { - engine.leaveChannel(); - engine.stopPreview(); - } - handler.post(RtcEngine::destroy); - engine = null; - } - - @Override - public void onClick(View v) { - if (v.getId() == R.id.btn_join) { - if (!joined) { - CommonUtil.hideInputBoard(getActivity(), et_channel); - // call when join button hit - String channelId = et_channel.getText().toString(); - // Check permission - checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { - @Override - public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { - // Permissions Granted - if (allPermissionsGranted) { - joinChannel(channelId); - } - } - }); - } else { - joined = false; - /*After joining a channel, the user must call the leaveChannel method to end the - * call before joining another channel. This method returns 0 if the user leaves the - * channel and releases all resources related to the call. This method call is - * asynchronous, and the user has not exited the channel when the method call returns. - * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. - * A successful leaveChannel method call triggers the following callbacks: - * 1:The local client: onLeaveChannel. - * 2:The remote client: onUserOffline, if the user leaving the channel is in the - * Communication channel, or is a BROADCASTER in the Live Broadcast profile. - * @returns 0: Success. - * < 0: Failure. - * PS: - * 1:If you call the destroy method immediately after calling the leaveChannel - * method, the leaveChannel process interrupts, and the SDK does not trigger - * the onLeaveChannel callback. - * 2:If you call the leaveChannel method during CDN live streaming, the SDK - * triggers the removeInjectStreamUrl method.*/ - engine.leaveChannel(); - join.setText(getString(R.string.join)); - } - } - } - - private void joinChannel(String channelId) { - // Check if the context is valid - Context context = getContext(); - if (context == null) { - return; - } - - // Create render view by RtcEngine - SurfaceView surfaceView = new SurfaceView(context); - if (fl_local.getChildCount() > 0) { - fl_local.removeAllViews(); - } - // Add to the local container - fl_local.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - // Setup local video to render your local camera preview - engine.setupLocalVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, 0)); - // Set audio route to microPhone - engine.setDefaultAudioRoutetoSpeakerphone(true); - - /*In the demo, the default is to enter as the anchor.*/ - engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); - // Enable video module - engine.enableVideo(); - // start preview - engine.startPreview(); - // Setup video encoding configs - engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration( - ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject(), - VideoEncoderConfiguration.FRAME_RATE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingFrameRate()), - STANDARD_BITRATE, - VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation()) - )); - - /*Please configure accessToken in the string_config file. - * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see - * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token - * A token generated at the server. This applies to scenarios with high-security requirements. For details, see - * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ - TokenUtils.gen(requireContext(), channelId, 0, ret -> { - /* Allows a user to join a channel. - if you do not specify the uid, we will generate the uid for you*/ - int res = engine.joinChannel(ret, channelId, "Extra Optional Data", 0); - if (res != 0) { - // Usually happens with invalid parameters - // Error code description can be found at: - // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html - // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html - showAlert(RtcEngine.getErrorDescription(Math.abs(res))); - return; - } - // Prevent repeated entry - join.setEnabled(false); - }); - } - - /** - * IRtcEngineEventHandler is an abstract class providing default implementation. - * The SDK uses this class to report to the app on SDK runtime events. - */ - private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { - /** - * Error code description can be found at: - * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror - * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror - */ - @Override - public void onError(int err) { - Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); - } - - - /**Occurs when a user leaves the channel. - * @param stats With this callback, the application retrieves the channel information, - * such as the call duration and statistics.*/ - @Override - public void onLeaveChannel(RtcStats stats) { - super.onLeaveChannel(stats); - Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); - showLongToast(String.format("local user %d leaveChannel!", myUid)); - } - - /**Occurs when the local user joins a specified channel. - * The channel name assignment is based on channelName specified in the joinChannel method. - * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. - * @param channel Channel name - * @param uid User ID - * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ - @Override - public void onJoinChannelSuccess(String channel, int uid, int elapsed) { - Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); - showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); - myUid = uid; - joined = true; - handler.post(new Runnable() { - @Override - public void run() { - join.setEnabled(true); - join.setText(getString(R.string.leave)); - } - }); - } - - /**Since v2.9.0. - * This callback indicates the state change of the remote audio stream. - * PS: This callback does not work properly when the number of users (in the Communication profile) or - * broadcasters (in the Live-broadcast profile) in the channel exceeds 17. - * @param uid ID of the user whose audio state changes. - * @param state State of the remote audio - * REMOTE_AUDIO_STATE_STOPPED(0): The remote audio is in the default state, probably due - * to REMOTE_AUDIO_REASON_LOCAL_MUTED(3), REMOTE_AUDIO_REASON_REMOTE_MUTED(5), - * or REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7). - * REMOTE_AUDIO_STATE_STARTING(1): The first remote audio packet is received. - * REMOTE_AUDIO_STATE_DECODING(2): The remote audio stream is decoded and plays normally, - * probably due to REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2), - * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4) or REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6). - * REMOTE_AUDIO_STATE_FROZEN(3): The remote audio is frozen, probably due to - * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1). - * REMOTE_AUDIO_STATE_FAILED(4): The remote audio fails to start, probably due to - * REMOTE_AUDIO_REASON_INTERNAL(0). - * @param reason The reason of the remote audio state change. - * REMOTE_AUDIO_REASON_INTERNAL(0): Internal reasons. - * REMOTE_AUDIO_REASON_NETWORK_CONGESTION(1): Network congestion. - * REMOTE_AUDIO_REASON_NETWORK_RECOVERY(2): Network recovery. - * REMOTE_AUDIO_REASON_LOCAL_MUTED(3): The local user stops receiving the remote audio - * stream or disables the audio module. - * REMOTE_AUDIO_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote audio - * stream or enables the audio module. - * REMOTE_AUDIO_REASON_REMOTE_MUTED(5): The remote user stops sending the audio stream or - * disables the audio module. - * REMOTE_AUDIO_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the audio stream - * or enables the audio module. - * REMOTE_AUDIO_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. - * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method - * until the SDK triggers this callback.*/ - @Override - public void onRemoteAudioStateChanged(int uid, int state, int reason, int elapsed) { - super.onRemoteAudioStateChanged(uid, state, reason, elapsed); - Log.i(TAG, "onRemoteAudioStateChanged->" + uid + ", state->" + state + ", reason->" + reason); - } - - /**Since v2.9.0. - * Occurs when the remote video state changes. - * PS: This callback does not work properly when the number of users (in the Communication - * profile) or broadcasters (in the Live-broadcast profile) in the channel exceeds 17. - * @param uid ID of the remote user whose video state changes. - * @param state State of the remote video: - * REMOTE_VIDEO_STATE_STOPPED(0): The remote video is in the default state, probably due - * to REMOTE_VIDEO_STATE_REASON_LOCAL_MUTED(3), REMOTE_VIDEO_STATE_REASON_REMOTE_MUTED(5), - * or REMOTE_VIDEO_STATE_REASON_REMOTE_OFFLINE(7). - * REMOTE_VIDEO_STATE_STARTING(1): The first remote video packet is received. - * REMOTE_VIDEO_STATE_DECODING(2): The remote video stream is decoded and plays normally, - * probably due to REMOTE_VIDEO_STATE_REASON_NETWORK_RECOVERY (2), - * REMOTE_VIDEO_STATE_REASON_LOCAL_UNMUTED(4), REMOTE_VIDEO_STATE_REASON_REMOTE_UNMUTED(6), - * or REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK_RECOVERY(9). - * REMOTE_VIDEO_STATE_FROZEN(3): The remote video is frozen, probably due to - * REMOTE_VIDEO_STATE_REASON_NETWORK_CONGESTION(1) or REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK(8). - * REMOTE_VIDEO_STATE_FAILED(4): The remote video fails to start, probably due to - * REMOTE_VIDEO_STATE_REASON_INTERNAL(0). - * @param reason The reason of the remote video state change: - * REMOTE_VIDEO_STATE_REASON_INTERNAL(0): Internal reasons. - * REMOTE_VIDEO_STATE_REASON_NETWORK_CONGESTION(1): Network congestion. - * REMOTE_VIDEO_STATE_REASON_NETWORK_RECOVERY(2): Network recovery. - * REMOTE_VIDEO_STATE_REASON_LOCAL_MUTED(3): The local user stops receiving the remote - * video stream or disables the video module. - * REMOTE_VIDEO_STATE_REASON_LOCAL_UNMUTED(4): The local user resumes receiving the remote - * video stream or enables the video module. - * REMOTE_VIDEO_STATE_REASON_REMOTE_MUTED(5): The remote user stops sending the video - * stream or disables the video module. - * REMOTE_VIDEO_STATE_REASON_REMOTE_UNMUTED(6): The remote user resumes sending the video - * stream or enables the video module. - * REMOTE_VIDEO_STATE_REASON_REMOTE_OFFLINE(7): The remote user leaves the channel. - * REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK(8): The remote media stream falls back to the - * audio-only stream due to poor network conditions. - * REMOTE_VIDEO_STATE_REASON_AUDIO_FALLBACK_RECOVERY(9): The remote media stream switches - * back to the video stream after the network conditions improve. - * @param elapsed Time elapsed (ms) from the local user calling the joinChannel method until - * the SDK triggers this callback.*/ - @Override - public void onRemoteVideoStateChanged(int uid, int state, int reason, int elapsed) { - super.onRemoteVideoStateChanged(uid, state, reason, elapsed); - Log.i(TAG, "onRemoteVideoStateChanged->" + uid + ", state->" + state + ", reason->" + reason); - } - - /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. - * @param uid ID of the user whose audio state changes. - * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole - * until this callback is triggered.*/ - @Override - public void onUserJoined(int uid, int elapsed) { - super.onUserJoined(uid, elapsed); - Log.i(TAG, "onUserJoined->" + uid); - showLongToast(String.format("user %d joined!", uid)); - /*Check if the context is correct*/ - Context context = getContext(); - if (context == null) { - return; - } - handler.post(() -> { - /*Display remote video stream*/ - SurfaceView surfaceView = null; - if (fl_remote.getChildCount() > 0) { - fl_remote.removeAllViews(); - } - // Create render view by RtcEngine - surfaceView = new SurfaceView(context); - surfaceView.setZOrderMediaOverlay(true); - // Add to the remote container - fl_remote.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - - // Setup remote video to render - engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid)); - }); - } - - /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. - * @param uid ID of the user whose audio state changes. - * @param reason Reason why the user goes offline: - * USER_OFFLINE_QUIT(0): The user left the current channel. - * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data - * packet was received within a certain period of time. If a user quits the - * call and the message is not passed to the SDK (due to an unreliable channel), - * the SDK assumes the user dropped offline. - * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from - * the host to the audience.*/ - @Override - public void onUserOffline(int uid, int reason) { - Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); - showLongToast(String.format("user %d offline! reason:%d", uid, reason)); - handler.post(new Runnable() { - @Override - public void run() { - /*Clear render view - Note: The video will stay at its last frame, to completely remove it you will need to - remove the SurfaceView from its parent*/ - engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid)); - } - }); - } - - @Override - public void onRemoteAudioStats(RemoteAudioStats remoteAudioStats) { - statisticsInfo.setRemoteAudioStats(remoteAudioStats); - updateRemoteStats(); - } - - @Override - public void onLocalAudioStats(LocalAudioStats localAudioStats) { - statisticsInfo.setLocalAudioStats(localAudioStats); - updateLocalStats(); - } - - @Override - public void onRemoteVideoStats(RemoteVideoStats remoteVideoStats) { - statisticsInfo.setRemoteVideoStats(remoteVideoStats); - updateRemoteStats(); - } - - @Override - public void onLocalVideoStats(Constants.VideoSourceType source, LocalVideoStats stats) { - super.onLocalVideoStats(source, stats); - statisticsInfo.setLocalVideoStats(stats); - updateLocalStats(); - } - - @Override - public void onRtcStats(RtcStats rtcStats) { - statisticsInfo.setRtcStats(rtcStats); - } - }; -} diff --git a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java b/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java deleted file mode 100644 index dcc49c6fc..000000000 --- a/Android/APIExample/app/src/main/java/io/agora/api/example/examples/advanced/PushExternalVideo.java +++ /dev/null @@ -1,555 +0,0 @@ -package io.agora.api.example.examples.advanced; - -import static io.agora.rtc2.video.VideoCanvas.RENDER_MODE_HIDDEN; -import static io.agora.rtc2.video.VideoEncoderConfiguration.STANDARD_BITRATE; - -import android.content.Context; -import android.graphics.SurfaceTexture; -import android.hardware.Camera; -import android.opengl.EGLSurface; -import android.opengl.GLES11Ext; -import android.opengl.GLES20; -import android.opengl.Matrix; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.SurfaceView; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.EditText; -import android.widget.FrameLayout; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.IOException; -import java.util.concurrent.Callable; - -import io.agora.api.example.MainApplication; -import io.agora.api.example.R; -import io.agora.api.example.common.BaseFragment; -import io.agora.api.example.common.gles.ProgramTextureOES; -import io.agora.api.example.common.gles.core.EglCore; -import io.agora.api.example.common.gles.core.GlUtil; -import io.agora.api.example.utils.CommonUtil; -import io.agora.api.example.utils.PermissonUtils; -import io.agora.api.example.utils.TokenUtils; -import io.agora.base.TextureBufferHelper; -import io.agora.base.VideoFrame; -import io.agora.base.internal.video.EglBase; -import io.agora.base.internal.video.EglBase14; -import io.agora.base.internal.video.RendererCommon; -import io.agora.base.internal.video.YuvConverter; -import io.agora.rtc2.ChannelMediaOptions; -import io.agora.rtc2.Constants; -import io.agora.rtc2.IRtcEngineEventHandler; -import io.agora.rtc2.RtcEngine; -import io.agora.rtc2.RtcEngineConfig; -import io.agora.rtc2.proxy.LocalAccessPointConfiguration; -import io.agora.rtc2.video.VideoCanvas; -import io.agora.rtc2.video.VideoEncoderConfiguration; - -//@Example( -// index = 7, -// group = ADVANCED, -// name = R.string.item_pushexternal, -// actionId = R.id.action_mainFragment_to_PushExternalVideo, -// tipsId = R.string.pushexternalvideo -//) - -/** - * The type Push external video. - * - * @deprecated The impletation of custom has been moved to {@link PushExternalVideoYUV}. You can refer to {@link PushExternalVideoYUV} example. - */ -@Deprecated -public class PushExternalVideo extends BaseFragment implements View.OnClickListener, TextureView.SurfaceTextureListener, - SurfaceTexture.OnFrameAvailableListener { - private static final String TAG = PushExternalVideo.class.getSimpleName(); - private final int DEFAULT_CAPTURE_WIDTH = 640; - private final int DEFAULT_CAPTURE_HEIGHT = 480; - - private FrameLayout fl_local, fl_remote; - private Button join; - private EditText et_channel; - private RtcEngine engine; - private int myUid; - private volatile boolean joined = false; - - private YuvConverter mYuvConverter = new YuvConverter(); - private Handler mHandler; - private int mPreviewTexture; - private SurfaceTexture mPreviewSurfaceTexture; - private EglCore mEglCore; - private EGLSurface mDummySurface; - private EGLSurface mDrawSurface; - private ProgramTextureOES mProgram; - private float[] mTransform = new float[16]; - private float[] mMVPMatrix = new float[16]; - private boolean mMVPMatrixInit = false; - private Camera mCamera; - private int mFacing = Camera.CameraInfo.CAMERA_FACING_FRONT; - private boolean mPreviewing = false; - private int mSurfaceWidth; - private int mSurfaceHeight; - private boolean mTextureDestroyed; - private volatile boolean glPrepared; - private volatile TextureBufferHelper textureBufferHelper; - - private boolean prepareGl(EglBase.Context eglContext, final int width, final int height) { - Log.d(TAG, "prepareGl"); - textureBufferHelper = TextureBufferHelper.create("STProcess", eglContext); - if (textureBufferHelper == null) { - return false; - } - Log.d(TAG, "prepareGl completed"); - return true; - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - View view = inflater.inflate(R.layout.fragment_push_externalvideo, container, false); - return view; - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - join = view.findViewById(R.id.btn_join); - et_channel = view.findViewById(R.id.et_channel); - view.findViewById(R.id.btn_join).setOnClickListener(this); - fl_local = view.findViewById(R.id.fl_local); - fl_remote = view.findViewById(R.id.fl_remote); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - // Check if the context is valid - Context context = getContext(); - if (context == null) { - return; - } - try { - RtcEngineConfig config = new RtcEngineConfig(); - /* - * The context of Android Activity - */ - config.mContext = context.getApplicationContext(); - /* - * The App ID issued to you by Agora. See How to get the App ID - */ - config.mAppId = getString(R.string.agora_app_id); - /* Sets the channel profile of the Agora RtcEngine. - CHANNEL_PROFILE_COMMUNICATION(0): (Default) The Communication profile. - Use this profile in one-on-one calls or group calls, where all users can talk freely. - CHANNEL_PROFILE_LIVE_BROADCASTING(1): The Live-Broadcast profile. Users in a live-broadcast - channel have a role as either broadcaster or audience. A broadcaster can both send and receive streams; - an audience can only receive streams.*/ - config.mChannelProfile = Constants.CHANNEL_PROFILE_LIVE_BROADCASTING; - /* - * IRtcEngineEventHandler is an abstract class providing default implementation. - * The SDK uses this class to report to the app on SDK runtime events. - */ - config.mEventHandler = iRtcEngineEventHandler; - config.mAudioScenario = Constants.AudioScenario.getValue(Constants.AudioScenario.DEFAULT); - config.mAreaCode = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getAreaCode(); - engine = RtcEngine.create(config); - /* - * This parameter is for reporting the usages of APIExample to agora background. - * Generally, it is not necessary for you to set this parameter. - */ - engine.setParameters("{" - + "\"rtc.report_app_scenario\":" - + "{" - + "\"appScenario\":" + 100 + "," - + "\"serviceType\":" + 11 + "," - + "\"appVersion\":\"" + RtcEngine.getSdkVersion() + "\"" - + "}" - + "}"); - /* setting the local access point if the private cloud ip was set, otherwise the config will be invalid.*/ - LocalAccessPointConfiguration localAccessPointConfiguration = ((MainApplication) getActivity().getApplication()).getGlobalSettings().getPrivateCloudConfig(); - if (localAccessPointConfiguration != null) { - // This api can only be used in the private media server scenario, otherwise some problems may occur. - engine.setLocalAccessPoint(localAccessPointConfiguration); - } - } - catch (Exception e) { - e.printStackTrace(); - getActivity().onBackPressed(); - } - } - - - @Override - public void onDestroy() { - /*leaveChannel and Destroy the RtcEngine instance*/ - if (engine != null) { - /*After joining a channel, the user must call the leaveChannel method to end the - * call before joining another channel. This method returns 0 if the user leaves the - * channel and releases all resources related to the call. This method call is - * asynchronous, and the user has not exited the channel when the method call returns. - * Once the user leaves the channel, the SDK triggers the onLeaveChannel callback. - * A successful leaveChannel method call triggers the following callbacks: - * 1:The local client: onLeaveChannel. - * 2:The remote client: onUserOffline, if the user leaving the channel is in the - * Communication channel, or is a BROADCASTER in the Live Broadcast profile. - * @returns 0: Success. - * < 0: Failure. - * PS: - * 1:If you call the destroy method immediately after calling the leaveChannel - * method, the leaveChannel process interrupts, and the SDK does not trigger - * the onLeaveChannel callback. - * 2:If you call the leaveChannel method during CDN live streaming, the SDK - * triggers the removeInjectStreamUrl method.*/ - engine.leaveChannel(); - engine.stopPreview(); - if (textureBufferHelper != null) { - textureBufferHelper.dispose(); - textureBufferHelper = null; - } - } - engine = null; - super.onDestroy(); - handler.post(RtcEngine::destroy); - } - - @Override - public void onClick(View v) { - if (v.getId() == R.id.btn_join) { - if (!joined) { - CommonUtil.hideInputBoard(getActivity(), et_channel); - // call when join button hit - String channelId = et_channel.getText().toString(); - // Check permission - checkOrRequestPermisson(new PermissonUtils.PermissionResultCallback() { - @Override - public void onPermissionsResult(boolean allPermissionsGranted, String[] permissions, int[] grantResults) { - // Permissions Granted - if (allPermissionsGranted) { - joinChannel(channelId); - } - } - }); - } else { - fl_local.setVisibility(View.GONE); - getActivity().onBackPressed(); - } - } - } - - private void joinChannel(String channelId) { -// engine.setParameters("{\"rtc.log_filter\":65535}"); - // Check if the context is valid - Context context = getContext(); - if (context == null) { - return; - } - - // Create render view by RtcEngine - TextureView textureView = new TextureView(getContext()); - //add SurfaceTextureListener - textureView.setSurfaceTextureListener(this); - // Add to the local container - fl_local.addView(textureView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - /*Set up to play remote sound with receiver*/ - engine.setDefaultAudioRoutetoSpeakerphone(true); - - /*In the demo, the default is to enter as the anchor.*/ - engine.setClientRole(Constants.CLIENT_ROLE_BROADCASTER); - // Enables the video module. - engine.enableVideo(); - // Setup video encoding configs - engine.setVideoEncoderConfiguration(new VideoEncoderConfiguration( - ((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingDimensionObject(), - VideoEncoderConfiguration.FRAME_RATE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingFrameRate()), - STANDARD_BITRATE, - VideoEncoderConfiguration.ORIENTATION_MODE.valueOf(((MainApplication) getActivity().getApplication()).getGlobalSettings().getVideoEncodingOrientation()) - )); - /*Configures the external video source. - * @param enable Sets whether or not to use the external video source: - * true: Use the external video source. - * false: Do not use the external video source. - * @param useTexture Sets whether or not to use texture as an input: - * true: Use texture as an input. - * false: (Default) Do not use texture as an input. - * @param pushMode - * VIDEO_FRAME: Use the ENCODED_VIDEO_FRAME. - * ENCODED_VIDEO_FRAME: Use the ENCODED_VIDEO_FRAME*/ - engine.setExternalVideoSource(true, true, Constants.ExternalVideoSourceType.VIDEO_FRAME); - - /*Please configure accessToken in the string_config file. - * A temporary token generated in Console. A temporary token is valid for 24 hours. For details, see - * https://docs.agora.io/en/Agora%20Platform/token?platform=All%20Platforms#get-a-temporary-token - * A token generated at the server. This applies to scenarios with high-security requirements. For details, see - * https://docs.agora.io/en/cloud-recording/token_server_java?platform=Java*/ - TokenUtils.gen(requireContext(), channelId, 0, token -> { - /* Allows a user to join a channel. - if you do not specify the uid, we will generate the uid for you*/ - - ChannelMediaOptions option = new ChannelMediaOptions(); - option.autoSubscribeAudio = true; - option.autoSubscribeVideo = true; - int res = engine.joinChannel(token, channelId, 0, option); - if (res != 0) { - // Usually happens with invalid parameters - // Error code description can be found at: - // en: https://docs.agora.io/en/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html - // cn: https://docs.agora.io/cn/Voice/API%20Reference/java/classio_1_1agora_1_1rtc_1_1_i_rtc_engine_event_handler_1_1_error_code.html - showAlert(RtcEngine.getErrorDescription(Math.abs(res))); - return; - } - // Prevent repeated entry - join.setEnabled(false); - }); - - } - - @Override - public void onFrameAvailable(SurfaceTexture surfaceTexture) { - if (mTextureDestroyed) { - return; - } - - if (!mEglCore.isCurrent(mDrawSurface)) { - mEglCore.makeCurrent(mDrawSurface); - } - try { - surfaceTexture.updateTexImage(); - surfaceTexture.getTransformMatrix(mTransform); - } catch (Exception e) { - e.printStackTrace(); - } - - /*The rectangle ratio of frames and the screen surface may be different, so cropping may - * happen when display frames to the screen. - * The display transformation matrix does not change for the same camera when the screen - * orientation remains the same.*/ - if (!mMVPMatrixInit) { - /*For simplicity, we only consider the activity as portrait mode. In this case, the captured - * images should be rotated 90 degrees (left or right).Thus the frame width and height - * should be swapped.*/ - float frameRatio = DEFAULT_CAPTURE_HEIGHT / (float) DEFAULT_CAPTURE_WIDTH; - float surfaceRatio = mSurfaceWidth / (float) mSurfaceHeight; - Matrix.setIdentityM(mMVPMatrix, 0); - - if (frameRatio >= surfaceRatio) { - float w = DEFAULT_CAPTURE_WIDTH * surfaceRatio; - float scaleW = DEFAULT_CAPTURE_HEIGHT / w; - Matrix.scaleM(mMVPMatrix, 0, scaleW, 1, 1); - } else { - float h = DEFAULT_CAPTURE_HEIGHT / surfaceRatio; - float scaleH = DEFAULT_CAPTURE_WIDTH / h; - Matrix.scaleM(mMVPMatrix, 0, 1, scaleH, 1); - } - mMVPMatrixInit = true; - } - GLES20.glViewport(0, 0, mSurfaceWidth, mSurfaceHeight); - mProgram.drawFrame(mPreviewTexture, mTransform, mMVPMatrix); - mEglCore.swapBuffers(mDrawSurface); - - if (joined) { - VideoFrame.Buffer buffer = textureBufferHelper.invoke(new Callable() { - @Override - public VideoFrame.Buffer call() throws Exception { - return textureBufferHelper.wrapTextureBuffer(DEFAULT_CAPTURE_HEIGHT, - DEFAULT_CAPTURE_WIDTH, VideoFrame.TextureBuffer.Type.OES, mPreviewTexture, - RendererCommon.convertMatrixToAndroidGraphicsMatrix(mTransform)); - } - }); - VideoFrame frame = new VideoFrame(buffer, 0, 0); - /*Pushes the video frame using the AgoraVideoFrame class and passes the video frame to the Agora SDK. - * Call the setExternalVideoSource method and set pushMode as true before calling this - * method. Otherwise, a failure returns after calling this method. - * @param frame AgoraVideoFrame - * @return - * true: The frame is pushed successfully. - * false: Failed to push the frame. - * PS: - * In the Communication profile, the SDK does not support textured video frames.*/ - boolean a = engine.pushExternalVideoFrame(frame); - Log.d(TAG, "pushExternalVideoFrame:" + a); - } - } - - @Override - public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { - Log.i(TAG, "onSurfaceTextureAvailable"); - mTextureDestroyed = false; - mSurfaceWidth = width; - mSurfaceHeight = height; - /* handler associate to the GL thread which creates the texture. - * in some condition SDK need to convert from texture format to YUV format, in this case, - * SDK will use this handler to switch into the GL thread to complete the conversion. - * */ - mHandler = new Handler(Looper.myLooper()); - mEglCore = new EglCore(); - if (!glPrepared) { - // setup egl context - EglBase.Context eglContext = new EglBase14.Context(mEglCore.getEGLContext()); - glPrepared = prepareGl(eglContext, width, height); - } - mDummySurface = mEglCore.createOffscreenSurface(1, 1); - mEglCore.makeCurrent(mDummySurface); - mPreviewTexture = GlUtil.createTextureObject(GLES11Ext.GL_TEXTURE_EXTERNAL_OES); - mPreviewSurfaceTexture = new SurfaceTexture(mPreviewTexture); - mPreviewSurfaceTexture.setOnFrameAvailableListener(this); - mDrawSurface = mEglCore.createWindowSurface(surface); - mProgram = new ProgramTextureOES(); - if (mCamera != null || mPreviewing) { - Log.e(TAG, "Camera preview has been started"); - return; - } - try { - mCamera = Camera.open(mFacing); - /*It is assumed to capture images of resolution 640x480. During development, it should - * be the most suitable supported resolution that best fits the scenario.*/ - Camera.Parameters parameters = mCamera.getParameters(); - parameters.setPreviewSize(DEFAULT_CAPTURE_WIDTH, DEFAULT_CAPTURE_HEIGHT); - mCamera.setParameters(parameters); - mCamera.setPreviewTexture(mPreviewSurfaceTexture); - /*The display orientation is 90 for both front and back facing cameras using a surface - * texture for the preview when the screen is in portrait mode.*/ - mCamera.setDisplayOrientation(90); - mCamera.startPreview(); - mPreviewing = true; - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Override - public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { - - } - - @Override - public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { - Log.i(TAG, "onSurfaceTextureDestroyed"); - mTextureDestroyed = true; - if (mCamera != null && mPreviewing) { - mCamera.stopPreview(); - mPreviewing = false; - mCamera.release(); - mCamera = null; - } - mProgram.release(); - mEglCore.releaseSurface(mDummySurface); - mEglCore.releaseSurface(mDrawSurface); - mEglCore.release(); - return true; - } - - @Override - public void onSurfaceTextureUpdated(SurfaceTexture surface) { - - } - - /** - * IRtcEngineEventHandler is an abstract class providing default implementation. - * The SDK uses this class to report to the app on SDK runtime events. - */ - private final IRtcEngineEventHandler iRtcEngineEventHandler = new IRtcEngineEventHandler() { - /** - * Error code description can be found at: - * en: https://api-ref.agora.io/en/video-sdk/android/4.x/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror - * cn: https://docs.agora.io/cn/video-call-4.x/API%20Reference/java_ng/API/class_irtcengineeventhandler.html#callback_irtcengineeventhandler_onerror - */ - @Override - public void onError(int err) { - Log.w(TAG, String.format("onError code %d message %s", err, RtcEngine.getErrorDescription(err))); - } - - /**Occurs when a user leaves the channel. - * @param stats With this callback, the application retrieves the channel information, - * such as the call duration and statistics.*/ - @Override - public void onLeaveChannel(RtcStats stats) { - super.onLeaveChannel(stats); - Log.i(TAG, String.format("local user %d leaveChannel!", myUid)); - showLongToast(String.format("local user %d leaveChannel!", myUid)); - } - - /**Occurs when the local user joins a specified channel. - * The channel name assignment is based on channelName specified in the joinChannel method. - * If the uid is not specified when joinChannel is called, the server automatically assigns a uid. - * @param channel Channel name - * @param uid User ID - * @param elapsed Time elapsed (ms) from the user calling joinChannel until this callback is triggered*/ - @Override - public void onJoinChannelSuccess(String channel, int uid, int elapsed) { - Log.i(TAG, String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); - showLongToast(String.format("onJoinChannelSuccess channel %s uid %d", channel, uid)); - myUid = uid; - joined = true; - handler.post(new Runnable() { - @Override - public void run() { - join.setEnabled(true); - join.setText(getString(R.string.leave)); - } - }); - } - - /**Occurs when a remote user (Communication)/host (Live Broadcast) joins the channel. - * @param uid ID of the user whose audio state changes. - * @param elapsed Time delay (ms) from the local user calling joinChannel/setClientRole - * until this callback is triggered.*/ - @Override - public void onUserJoined(int uid, int elapsed) { - super.onUserJoined(uid, elapsed); - Log.i(TAG, "onUserJoined->" + uid); - showLongToast(String.format("user %d joined!", uid)); - /*Check if the context is correct*/ - Context context = getContext(); - if (context == null) { - return; - } - handler.post(() -> { - /*Display remote video stream*/ - // Create render view by RtcEngine - SurfaceView surfaceView = new SurfaceView(context); - surfaceView.setZOrderMediaOverlay(true); - if (fl_remote.getChildCount() > 0) { - fl_remote.removeAllViews(); - } - // Add to the remote container - fl_remote.addView(surfaceView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - // Setup remote video to render - engine.setupRemoteVideo(new VideoCanvas(surfaceView, RENDER_MODE_HIDDEN, uid)); - }); - } - - /**Occurs when a remote user (Communication)/host (Live Broadcast) leaves the channel. - * @param uid ID of the user whose audio state changes. - * @param reason Reason why the user goes offline: - * USER_OFFLINE_QUIT(0): The user left the current channel. - * USER_OFFLINE_DROPPED(1): The SDK timed out and the user dropped offline because no data - * packet was received within a certain period of time. If a user quits the - * call and the message is not passed to the SDK (due to an unreliable channel), - * the SDK assumes the user dropped offline. - * USER_OFFLINE_BECOME_AUDIENCE(2): (Live broadcast only.) The client role switched from - * the host to the audience.*/ - @Override - public void onUserOffline(int uid, int reason) { - Log.i(TAG, String.format("user %d offline! reason:%d", uid, reason)); - showLongToast(String.format("user %d offline! reason:%d", uid, reason)); - handler.post(new Runnable() { - @Override - public void run() { - /*Clear render view - Note: The video will stay at its last frame, to completely remove it you will need to - remove the SurfaceView from its parent*/ - engine.setupRemoteVideo(new VideoCanvas(null, RENDER_MODE_HIDDEN, uid)); - } - }); - } - }; -} diff --git a/Android/APIExample/app/src/main/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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -