diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ec2ca62..7b21ee0 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,114 +1,70 @@ # Peloton Communicator Project ## Overview -This project is a **walkie-talkie style communication app** for people riding Peloton bikes together. The core concept is to enable **push-to-talk (PTT) functionality using Bluetooth headset play/pause buttons** as triggers for voice communication between riders. +A **walkie-talkie style communication app** for people riding (Peloton) bikes together. Push-to-talk (PTT) is triggered by Bluetooth headset buttons, phone hardware buttons, an on-screen button, or the iOS system PTT UI, and voice is streamed to other riders over WebRTC. -### Project Components -- A Flutter mobile app (`/app/`) for cross-platform client applications -- A Go backend server (`/server/`) using Gin framework with REST API -- Infrastructure configuration for AWS and GCP deployments (`/aws/`, `/gcp/`) -- WebRTC demo implementation (`/flutter-webrtc-demo/`) +## Status (July 2026) -### Core Innovation: Bluetooth Headset PTT -The **major technical challenge** and primary focus is capturing Bluetooth headset play/pause button events to trigger voice recording/transmission. This creates a hands-free communication experience essential for cycling. +**The historic "major blocker" — capturing Bluetooth headset button events — is solved.** Do not re-investigate it from scratch; see `docs/bluetooth-ptt-implementation.md` for the working architecture and `ROADMAP.md` for what remains. -## Architecture -- **Frontend**: Flutter app with Material Design -- **Backend**: Go server with Gin framework, RESTful API -- **API Documentation**: Swagger/OpenAPI specification in `documentation.yaml` -- **Infrastructure**: Terraform configurations for cloud deployment +- **Android**: A Media3 `MediaSessionService` foreground service (`PttMediaSessionService.kt`) owns the media session and intercepts BT play/pause/headsethook key events, even when the app is backgrounded or the screen is off. Events flow `PttEventBus` → `MainActivity` → MethodChannel → `ptt_service.dart`. Volume buttons are captured in the activity (`onKeyDown`/`onKeyUp`). +- **iOS**: Apple's **PushToTalk framework** (iOS 16+, `PTTSystemManager.swift`) provides system PTT UI, background transmit, and headset button events via `setAccessoryButtonEventsEnabled(true)`. This framework (released 2022) is the official platform unlock — use it, don't fight MPRemoteCommandCenter. +- **WebRTC**: Signaling service (Go, `packages/services/signaling`), Flutter signaling client, and WebRTC service are implemented. PTT→WebRTC integration and NAT traversal (STUN/TURN) are the current MVP focus. -## Key Components +### Known Bluetooth protocol constraints (not bugs — do not try to "fix" in-app) +1. **Hold-to-talk on BT play/pause is unreliable.** Most headset firmware buffers the button to disambiguate single/double/long presses, so the AVRCP command arrives as a press+release pair at physical release; Android may also suppress `ACTION_UP` for BT devices. The app therefore **forces toggle mode for the play/pause button** — keep that behavior. +2. **Headset volume buttons don't generate KeyEvents.** With AVRCP absolute volume, the headset sends `SET_ABSOLUTE_VOLUME` straight to the audio system. Implemented workaround: `PttPlayer` claims remote device volume (Media3's `VolumeProvider` equivalent) while the volume button is selected, and routes steps through `PttEventBus.emitDiscrete()` — toggle mode only, never hold. +3. True press-and-hold with reliable down/up is achievable with **dedicated BLE PTT buttons** (handlebar-mountable; the Zello/ESChat ecosystem) — a candidate premium path. -### Flutter App (`/app/`) -- Main entry point: `lib/main.dart` -- Current state: Basic counter app template (needs development) -- Target platforms: iOS, Android, Web, Desktop (Linux, macOS, Windows) - -### Go Server (`/server/`) -- Main file: `main.go` (currently a basic albums API example) -- Framework: Gin v1.7.2 -- API specification: Based on `documentation.yaml` swagger spec -- Generated code: `server/generated/` contains auto-generated API handlers +## Repository Structure (monorepo) +``` +├── packages/ +│ ├── mobile/ # Flutter app (iOS/Android) +│ │ ├── lib/services/ # ptt_service, recorder_service, WebRTC, signaling client +│ │ ├── android/…/app/ # MainActivity, PttMediaSessionService, PttEventBus, PttPlayer +│ │ └── ios/Runner/ # AppDelegate, PTTSystemManager (PushToTalk framework) +│ ├── server/ # Go backend API server (legacy) +│ ├── services/signaling/ # WebRTC signaling service (Go, WebSocket) +│ └── infra/ # docker-compose, k3s manifests +├── docs/ # bluetooth-ptt-implementation.md and others +├── ROADMAP.md # Current diagnosis + next steps +└── scripts/ # Build and deployment scripts +``` -### API Specification -- Swagger 2.0 specification in `documentation.yaml` -- Endpoints for: - - Club management (CRUD operations) - - User management and authentication - - Store/order functionality - - File upload capabilities +## Communication Flow +1. Button press (headset / hardware / on-screen / iOS system PTT) → `pttPressed` over MethodChannel +2. `ptt_service.dart` drives state; recorder starts (POC) — target: unmute WebRTC audio track +3. Release/toggle → `pttReleased` → stop transmitting ## Development Guidelines ### Testing -- Flutter: Use `flutter test` for unit and widget tests -- Go: Use `go test` for backend testing +- Flutter: `flutter test` in `packages/mobile` +- Signaling (Go): `go test -race ./...` (unit) and `go test -race -tags=integration ./cmd/...` (real WebSocket flows) in `packages/services/signaling` +- `packages/server` is legacy (placeholder only) and is not in CI - Follow TDD practices where applicable +### CI and local gates +- Single pipeline: `.github/workflows/ci.yml` (quality → build & unit → integration → security → `CI Status`). Details and local repro commands: `docs/ci-pipeline.md` +- `CI Status` is the required check on `main`; add new jobs to its `needs:` list to make them merge-blocking +- Toolchain pins live in the workflow `env:` (Flutter 3.47.1, Go 1.27.x, Java 17); keep the signaling Dockerfile's Go version in sync +- Local hooks via lefthook (`brew install lefthook && lefthook install`): format + analyze/vet on commit, tests on push. Never use `--no-verify` +- Analyzer runs with `--fatal-infos`: infos (deprecations, missing `const`) fail the build +- **Headset behavior requires physical devices** (emulators insufficient); expect per-headset AVRCP variance +- Testing guides: `TESTING.md`, `PHASE*_TESTING.md`, `WIRELESS_DEBUG_SETUP.md` + ### Code Organization -- Keep Flutter UI components modular and reusable +- Keep Flutter UI components modular and reusable; PTT state management lives in `ptt_service.dart` (Provider) +- All press/release paths converge on one state transition so input sources are indistinguishable downstream - Follow Go best practices for package organization -- Use existing patterns established in the codebase - -### API Development -- Follow the OpenAPI specification in `documentation.yaml` -- Implement proper error handling and validation -- Use OAuth2 and API key authentication as specified - -### Infrastructure -- Terraform configurations available for AWS and GCP -- Docker support through generated Dockerfile in server - -## Technical Challenges & Solutions - -### Bluetooth Headset Integration -**Problem**: Capturing Bluetooth headset play/pause button events for PTT functionality -**Current Status**: Major blocker - multiple approaches attempted - -**Attempted Solutions**: -1. **audio_service Package**: MediaSession approach with custom handlers -2. **flutter_blue_plus**: BLE approach for modern Bluetooth devices -3. **Native Platform Channels**: Direct Android/iOS media button handling - -**Key Learnings**: -- Requires physical device testing (emulators insufficient) -- Android: MediaSessionCompat + BroadcastReceiver for media button events -- iOS: MPRemoteCommandCenter for media button handling -- Build system compatibility: Java 21 + Gradle 8.7 + AGP 8.4+ - -### Current Architecture (POC) -``` -Bluetooth Headset -> Platform Channel -> Flutter App -> Record Audio -> Playback -``` - -### Future Architecture (Full Implementation) -``` -Bluetooth Headset -> PTT Trigger -> Record -> WebRTC/UDP -> Other Riders -``` - -## Communication Flow -1. **Press play button** → Start recording microphone -2. **Release button** → Stop recording, immediately playback locally (POC) -3. **Future**: Stream to other riders in real-time via WebRTC - -## Project Structure -``` -/app/ # Flutter app (currently template) - /lib/services/ # Audio controller, media button handler - /lib/ui/ # UI components -/server/ # Go backend (currently example API) -/.claude/tmp/ # Previous implementation attempts/conversations -``` ## Development Environment -- **Java**: 21 (requires Gradle 8.7+, AGP 8.4+) -- **Flutter**: Latest stable -- **Target Platforms**: Android 12+, iOS 14+ -- **Testing**: Requires physical devices with Bluetooth headsets +- **Java**: 21 (Gradle 8.7+, AGP 8.4+); Android SDK 36, Kotlin 2.1 +- **Flutter**: >= 3.16.0 · **Go**: >= 1.21 +- **Target Platforms**: Android 12+, iOS 16+ (PushToTalk framework floor) +- iOS PushToTalk requires the `com.apple.developer.push-to-talk` entitlement and a real device ## Common Commands -- Flutter: `flutter run`, `flutter build`, `flutter test` -- Go: `go run main.go`, `go build`, `go test` -- Infrastructure: `terraform plan`, `terraform apply` -- Clean build: `flutter clean && flutter pub get` \ No newline at end of file +- Mobile: `cd packages/mobile && flutter run` / `flutter test` / `flutter clean && flutter pub get` +- Signaling: `cd packages/services/signaling && go run ./cmd/main.go` (ws://localhost:8080/ws, GET /health) +- Infra: `docker compose -f packages/infra/docker-compose.yaml up`, k3s manifests in `packages/infra/k3s/` diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..165ebd5 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "model": "opus" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..03da14e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,335 @@ +# Peloton Communicator - unified CI pipeline +# +# Stages (each waits on the one before it): +# 1. Quality - formatting and static analysis per component (fast feedback) +# 2. Build & Unit - build + unit tests per component, in parallel +# 3. Integration - real WebSocket signaling flows against the running server +# 4. Security - CodeQL (SAST), govulncheck, gitleaks secret scan +# 5. CI Status - single aggregate check; this is the required check on main +# +# Mobile end-to-end testing stays manual: headset buttons need physical devices. +# See docs/ci-pipeline.md for details and how to reproduce each gate locally. +name: CI + +on: + push: + branches: [main, 'feat/**', 'fix/**', 'hotfix/**', 'chore/**', 'ci/**'] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # Pin toolchains explicitly. Deriving Flutter from pubspec's lower bound is what broke + # the previous pipeline (it installed Flutter 3.16 / Dart 3.2 and pub get failed). + FLUTTER_VERSION: '3.47.1' + GO_VERSION: '1.27.x' + JAVA_VERSION: '17' + SIGNALING_DIR: packages/services/signaling + +jobs: + # ========================================================================== + # 1. QUALITY + # ========================================================================== + + quality-mobile: + name: Quality • mobile + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/mobile + steps: + - uses: actions/checkout@v7 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Check formatting + run: dart format --output=none --set-exit-if-changed lib test + + - name: Analyze + run: flutter analyze --fatal-infos + + quality-signaling: + name: Quality • signaling + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.SIGNALING_DIR }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: ${{ env.SIGNALING_DIR }}/go.sum + + - name: Verify modules + run: go mod verify + + - name: Check go.mod is tidy + run: go mod tidy && git diff --exit-code go.mod go.sum + + - name: Check formatting + run: | + unformatted=$(gofmt -s -l .) + if [ -n "$unformatted" ]; then + echo "::error::gofmt -s needed on:"; echo "$unformatted"; exit 1 + fi + + - name: Vet (unit and integration build tags) + run: go vet ./... && go vet -tags=integration ./... + + # ========================================================================== + # 2. BUILD & UNIT (parallel per component) + # ========================================================================== + + unit-mobile: + name: Build & Unit • mobile + runs-on: ubuntu-latest + needs: quality-mobile + defaults: + run: + working-directory: packages/mobile + steps: + - uses: actions/checkout@v7 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Unit and widget tests + run: flutter test --coverage + + - name: Upload coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-mobile + path: packages/mobile/coverage/lcov.info + retention-days: 14 + + build-android: + name: Build & Unit • android + runs-on: ubuntu-latest + needs: quality-mobile + defaults: + run: + working-directory: packages/mobile + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: gradle + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + # Compiles the native Kotlin PTT layer (MediaSessionService, PttPlayer, ...), + # which has no other automated coverage. + - name: Build debug APK + run: flutter build apk --debug + + build-ios: + name: Build & Unit • ios + runs-on: macos-latest + needs: quality-mobile + defaults: + run: + working-directory: packages/mobile + steps: + - uses: actions/checkout@v7 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + # Compiles the Swift PTT layer (PushToTalk framework integration). + - name: Build iOS (no codesign) + run: flutter build ios --debug --no-codesign + + unit-signaling: + name: Build & Unit • signaling + runs-on: ubuntu-latest + needs: quality-signaling + defaults: + run: + working-directory: ${{ env.SIGNALING_DIR }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: ${{ env.SIGNALING_DIR }}/go.sum + + - name: Build + run: go build ./... + + - name: Unit tests + run: go test -race -count=1 -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v7 + with: + name: coverage-signaling + path: ${{ env.SIGNALING_DIR }}/coverage.out + retention-days: 14 + + - name: Build container image + run: docker build --tag signaling:ci . + + # ========================================================================== + # 3. INTEGRATION + # ========================================================================== + + integration-signaling: + name: Integration • signaling + runs-on: ubuntu-latest + needs: unit-signaling + defaults: + run: + working-directory: ${{ env.SIGNALING_DIR }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: ${{ env.SIGNALING_DIR }}/go.sum + + - name: WebSocket signaling flows + run: go test -race -count=1 -tags=integration -v ./cmd/... + + # ========================================================================== + # 4. SECURITY + # ========================================================================== + + codeql: + name: Security • CodeQL (${{ matrix.language }}) + runs-on: ubuntu-latest + needs: [unit-mobile, unit-signaling] + permissions: + contents: read + actions: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: manual + - language: actions + build-mode: none + steps: + - uses: actions/checkout@v7 + + - if: matrix.language == 'go' + uses: actions/setup-go@v7 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: ${{ env.SIGNALING_DIR }}/go.sum + + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - if: matrix.language == 'go' + name: Build for analysis + working-directory: ${{ env.SIGNALING_DIR }} + run: go build ./... + + - uses: github/codeql-action/analyze@v4 + with: + category: /language:${{ matrix.language }} + + govulncheck: + name: Security • govulncheck + runs-on: ubuntu-latest + needs: unit-signaling + steps: + - uses: actions/checkout@v7 + + - uses: golang/govulncheck-action@v1 + with: + go-version-input: ${{ env.GO_VERSION }} + work-dir: ${{ env.SIGNALING_DIR }} + + secret-scan: + name: Security • gitleaks + runs-on: ubuntu-latest + needs: [quality-mobile, quality-signaling] + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Full-history scan with the pinned CLI so results don't depend on push ranges. + # Reviewed, known-benign findings are listed in .gitleaksignore. + - name: Scan history for secrets + run: | + docker run --rm -v "$PWD:/repo" -w /repo ghcr.io/gitleaks/gitleaks:v8.30.1 \ + git --redact --no-banner --exit-code 1 . + + # ========================================================================== + # 5. AGGREGATE STATUS (required check on main) + # ========================================================================== + + ci-status: + name: CI Status + runs-on: ubuntu-latest + if: always() + needs: + - quality-mobile + - quality-signaling + - unit-mobile + - build-android + - build-ios + - unit-signaling + - integration-signaling + - codeql + - govulncheck + - secret-scan + steps: + - name: Require every gate to pass + env: + NEEDS: ${{ toJSON(needs) }} + run: | + echo "$NEEDS" | jq -r 'to_entries[] | "\(.value.result)\t\(.key)"' | sort + failed=$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result != "success") | .key') + if [ -n "$failed" ]; then + echo "::error::Gates not passing: $(echo "$failed" | tr '\n' ' ')" + exit 1 + fi + echo "All gates passed." diff --git a/.gitignore b/.gitignore index 70900ed..59d4250 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,7 @@ docs/coverage/ *.tmp *.temp .cache/ + +# Signaling service build output +packages/services/signaling/signaling +packages/services/signaling/coverage.out diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..878daca --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,7 @@ +# Reviewed gitleaks findings that are not real secrets. One fingerprint per line. +# +# Upstream cloudwebrtc/flutter-webrtc-demo sample (committed 2022, now only under the +# untracked archive/). The match is inside a code comment illustrating the JSON shape of a +# TURN credential response (example username from 2020, TURN URI 127.0.0.1). Not a live +# credential and not ours. +fdbc53e1b6296b736e96b51f2a4e49b3cdf21000:flutter-webrtc-demo/lib/src/call_sample/signaling.dart:generic-api-key:266 diff --git a/.nvmrc b/.nvmrc index 300f562..d1b1206 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -^v22 +^v24 diff --git a/PHASE1_VOLUME_BUTTON_TESTING.md b/PHASE1_VOLUME_BUTTON_TESTING.md new file mode 100644 index 0000000..2c667a9 --- /dev/null +++ b/PHASE1_VOLUME_BUTTON_TESTING.md @@ -0,0 +1,296 @@ +# Phase 1: Volume Button PTT - Testing Guide + +## ✅ Implementation Complete + +Phase 1 implements configurable button support with volume buttons as the primary PTT trigger. + +### What's Been Implemented + +#### Flutter/Dart Layer +- ✅ `PTTButton` enum with all button types +- ✅ `PTTConfiguration` class for managing button/mode/settings +- ✅ Updated `PTTService` to handle configuration changes +- ✅ Method channel communication for configuration updates + +#### Android Layer (`MainActivity.kt`) +- ✅ Volume Down button capture (default) +- ✅ Volume Up button capture +- ✅ Headset Play/Pause button (existing) +- ✅ Headset Next/Previous track buttons +- ✅ Camera button support +- ✅ Long-press detection to prevent Google Assistant +- ✅ Screen wake lock support +- ✅ Configurable button routing via `dispatchKeyEvent()` + +#### iOS Layer (`AppDelegate.swift`) +- ✅ Volume button observer using KVO +- ✅ Volume Up/Down button differentiation +- ✅ Headset button support via `MPRemoteCommandCenter` +- ✅ Screen idle timer disable support +- ✅ Volume reset to prevent actual volume changes +- ✅ Dynamic button listener reconfiguration + +--- + +## 🧪 Stage Gate 1: Volume Button Testing + +**OBJECTIVE:** Validate that volume buttons work reliably for PTT on both platforms without triggering voice assistants. + +### Prerequisites + +1. **Physical Devices Required** + - Android device (Android 10+) + - iOS device (iOS 14+) + - Bluetooth headset (optional, for headset button testing) + +2. **Build the App** + ```bash + cd packages/mobile + flutter clean + flutter pub get + flutter build apk --debug # For Android + flutter build ios --debug # For iOS + ``` + +3. **Deploy to Devices** + ```bash + # Android + flutter run --device-id= + + # iOS + flutter run --device-id= + ``` + +--- + +### Test Plan + +## Android Testing + +### Test 1: Volume Down Button (Default) + +**Setup:** +1. Launch app on Android device +2. Verify default configuration shows "Volume Down" button +3. Enable PTT "Toggle Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Press Volume Down once | PTT activates (green icon, "RECORDING") | ⬜ | +| 2 | Verify logs | `adb logcat \| grep PTT` shows "Starting recording (toggle mode)" | ⬜ | +| 3 | Press Volume Down again | PTT deactivates (red icon, "READY") | ⬜ | +| 4 | Verify logs | Shows "Stopping recording (toggle mode)" | ⬜ | +| 5 | **CRITICAL:** Long-press Volume Down (>1s) | Google Assistant DOES NOT activate | ⬜ | +| 6 | Verify logs | Shows "Long press detected - consuming to prevent voice assistant" | ⬜ | + +**Acceptance Criteria:** +- ✅ Volume Down triggers PTT in both toggle and hold modes +- ✅ Google Assistant does NOT activate on long-press +- ✅ System volume does NOT change during PTT use + +### Test 2: Volume Up Button + +**Setup:** +1. Switch PTT button to "Volume Up" (we'll add UI in Phase 3, for now use debug) +2. Enable PTT "Hold Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Press and hold Volume Up | PTT activates immediately | ⬜ | +| 2 | Release Volume Up | PTT deactivates immediately | ⬜ | +| 3 | Rapid press/release 5 times | Only actual presses register (debouncing works) | ⬜ | +| 4 | Long-press Volume Up (>1s) | Google Assistant DOES NOT activate | ⬜ | + +**Acceptance Criteria:** +- ✅ Volume Up triggers PTT reliably +- ✅ Hold mode works correctly (press/release) +- ✅ Debouncing prevents accidental double triggers + +### Test 3: Screen Wake Lock + +**Setup:** +1. Enable "Prevent Screen Lock" in configuration +2. Launch app + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Wait 30 seconds without touching device | Screen stays on | ⬜ | +| 2 | Wait 2 minutes without touching device | Screen still stays on | ⬜ | +| 3 | Disable "Prevent Screen Lock" | Screen lock resumes normal behavior | ⬜ | + +--- + +## iOS Testing + +### Test 4: Volume Down Button + +**Setup:** +1. Launch app on iOS device +2. Verify default configuration shows "Volume Down" button +3. Enable PTT "Toggle Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Press Volume Down once | PTT activates (green icon, "RECORDING") | ⬜ | +| 2 | Check Xcode console | Shows "Volume button pressed: volumeDown" | ⬜ | +| 3 | Press Volume Down again | PTT deactivates (red icon, "READY") | ⬜ | +| 4 | Verify volume level | System volume DOES NOT change | ⬜ | +| 5 | Long-press Volume Down | **NOTE:** Siri MAY activate (known iOS limitation) | ⬜ | + +**Acceptance Criteria:** +- ✅ Volume Down triggers PTT in toggle mode +- ✅ System volume does NOT change (or resets quickly) +- ⚠️ Siri activation on long-press is acceptable (document as known limitation) + +### Test 5: Volume Up Button + +**Setup:** +1. Switch to "Volume Up" button +2. Enable PTT "Toggle Mode" (recommended for iOS) + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Press Volume Up once | PTT activates | ⬜ | +| 2 | Press Volume Up again | PTT deactivates | ⬜ | +| 3 | Verify volume | System volume restored to original level | ⬜ | + +### Test 6: Headset Buttons (iOS) + +**Setup:** +1. Connect Bluetooth headset +2. Switch to "Headset Play/Pause" button +3. Enable PTT "Toggle Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Single-press headset button | PTT toggles on/off | ⬜ | +| 2 | Long-press headset button | **Siri WILL activate** (expected) | ⬜ | +| 3 | Switch to "Headset Next Track" | Use double-press for PTT | ⬜ | +| 4 | Double-press headset button | PTT toggles, NO Siri | ⬜ | + +--- + +## Cross-Platform Testing + +### Test 7: Configuration Persistence (Future) + +**NOTE:** This test will be fully enabled in Phase 3 with settings UI + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Set button to Volume Up | Configuration updates | ⬜ | +| 2 | Kill and restart app | Volume Up still selected | ⬜ | +| 3 | Change to Hold mode | Mode persists across restarts | ⬜ | + +--- + +## Troubleshooting + +### Android Issues + +**Volume doesn't trigger PTT:** +```bash +# Check logs +adb logcat | grep PTT + +# Look for: +# - "dispatchKeyEvent: keyCode=25" (Volume Down) +# - "dispatchKeyEvent: keyCode=24" (Volume Up) +# - "Handling PTT event for button: volumeDown" +``` + +**Google Assistant still activating:** +```bash +# Verify long-press detection +adb logcat | grep "Long press detected" + +# Should see: "consuming to prevent voice assistant" +``` + +**System volume changing:** +- This is expected briefly on iOS (will reset) +- On Android, should NOT change at all + +### iOS Issues + +**Volume button not working:** +``` +# Check Xcode console for: +# - "Volume button observer started for: volumeDown" +# - "Volume button pressed: volumeDown" +``` + +**Siri keeps interrupting:** +- This is a **known iOS limitation** for long-press +- Recommend using **toggle mode** with quick single presses +- Consider using **Headset Next/Previous** buttons instead (no Siri conflict) + +**Volume changes not resetting:** +- Check `MPVolumeView` slider access in logs +- May need additional permissions or delay adjustment + +--- + +## Success Criteria for Phase 1 + +### Must Have ✅ +- [ ] Volume Down works on Android (toggle & hold modes) +- [ ] Volume Down works on iOS (toggle mode) +- [ ] Google Assistant does NOT activate on Android long-press +- [ ] Screen wake lock works on both platforms +- [ ] No system volume changes (or quick reset on iOS) + +### Nice to Have ⭐ +- [ ] Volume Up also works reliably +- [ ] Headset buttons work as alternatives +- [ ] Camera button works (Android only) +- [ ] Smooth volume reset on iOS (imperceptible) + +### Known Limitations (Acceptable) ⚠️ +- [ ] iOS Siri activation on long-press (documented) +- [ ] Brief volume change on iOS before reset (documented) +- [ ] Volume buttons in background require accessibility service (Phase 4) + +--- + +## Next Steps After Phase 1 Validation + +Once Phase 1 tests pass: + +**✅ Approved to proceed → Phase 2: On-Screen PTT Button** +- Add large on-screen button with `GestureDetector` +- Implement wake lock package +- Test on-screen button as universal fallback + +**❌ Issues found → Fix before proceeding** +- Document failing tests +- Debug using platform logs +- Iterate on native implementations + +--- + +## Testing Log + +**Date:** ___________ +**Tester:** ___________ +**Devices Tested:** +- Android: ___________ +- iOS: ___________ + +**Overall Result:** PASS / FAIL / NEEDS WORK + +**Notes:** +``` +(Add any observations, issues, or recommendations here) +``` + +--- + +**Phase 1 Status:** ⬜ NOT TESTED | ⬜ IN PROGRESS | ⬜ PASSED | ⬜ FAILED diff --git a/PHASE2_ONSCREEN_BUTTON_TESTING.md b/PHASE2_ONSCREEN_BUTTON_TESTING.md new file mode 100644 index 0000000..c72e868 --- /dev/null +++ b/PHASE2_ONSCREEN_BUTTON_TESTING.md @@ -0,0 +1,354 @@ +# Phase 2: On-Screen PTT Button - Testing Guide + +## ✅ Implementation Complete + +Phase 2 adds a large on-screen PTT button as a universal fallback that works on all devices. + +### What's Been Implemented + +#### Flutter/Dart Layer +- ✅ Interactive on-screen PTT button with `GestureDetector` +- ✅ Visual feedback (glow effect when on-screen button active) +- ✅ TAP/HOLD label based on mode +- ✅ Button-aware instruction text +- ✅ Current button configuration display +- ✅ WakeLock integration via `wakelock_plus` package + +#### Wake Lock Support +- ✅ Prevents screen from sleeping during rides +- ✅ Configurable via `preventScreenLock` setting +- ✅ Cross-platform (Android + iOS) +- ✅ Automatic enable/disable based on configuration + +--- + +## 🧪 Stage Gate 2: On-Screen Button Testing + +**OBJECTIVE:** Validate that the on-screen PTT button works reliably as a universal fallback and that wake lock prevents screen sleep. + +### Prerequisites + +**Ensure Phase 1 is complete:** +- ⬜ Phase 1 tests passed +- ⬜ Volume buttons working on both platforms + +**New dependencies:** +```bash +cd packages/mobile +flutter pub get # Install wakelock_plus package +``` + +--- + +### Test Plan + +## Test 1: On-Screen Button - Toggle Mode + +**Setup:** +1. Launch app +2. Switch PTT button to "On-Screen Button" (future: via settings, now: default or debug) +3. Enable "Toggle Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Verify UI | Large button shows "TAP" label | ⬜ | +| 2 | Verify UI | Button has glowing shadow effect | ⬜ | +| 3 | Tap the button once | PTT activates (green, "RECORDING") | ⬜ | +| 4 | Verify instruction | Shows "Tap the button to stop recording" | ⬜ | +| 5 | Tap the button again | PTT deactivates (red, "READY") | ⬜ | +| 6 | Rapid tap 10 times | Toggles on/off reliably, no missed taps | ⬜ | +| 7 | Check logs | Shows "PTT State changed" messages | ⬜ | + +**Acceptance Criteria:** +- ✅ On-screen button responds instantly to taps +- ✅ Visual feedback is clear (color change, glow) +- ✅ Toggle mode works reliably +- ✅ No missed or double triggers + +--- + +## Test 2: On-Screen Button - Hold Mode + +**Setup:** +1. Keep PTT button as "On-Screen Button" +2. Switch to "Hold Mode" + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Verify UI | Button shows "HOLD" label | ⬜ | +| 2 | Press and hold button (2s) | PTT active while holding (green) | ⬜ | +| 3 | Release button | PTT deactivates immediately (red) | ⬜ | +| 4 | Press and hold (5s) | PTT stays active entire time | ⬜ | +| 5 | Quick tap (< 0.5s) | PTT activates briefly then deactivates | ⬜ | +| 6 | Check instruction | Shows "Press and hold the button to record" | ⬜ | + +**Acceptance Criteria:** +- ✅ Hold mode activates on long-press start +- ✅ Hold mode deactivates on long-press end +- ✅ No delay or lag in activation/deactivation +- ✅ Visual feedback immediate + +--- + +## Test 3: Wake Lock - Screen Stays On + +**Setup:** +1. Ensure "Prevent Screen Lock" is enabled (default) +2. Launch app + +**Android Test:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Launch app | Check logs: "WakeLock enabled" | ⬜ | +| 2 | Wait 1 minute without touching | Screen stays on | ⬜ | +| 3 | Wait 5 minutes without touching | Screen still on | ⬜ | +| 4 | Check battery settings | App uses "Screen On" permission | ⬜ | +| 5 | Press power button | Screen turns off (manual override works) | ⬜ | +| 6 | Press power again | Screen turns on, app still visible | ⬜ | + +**iOS Test:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Launch app | Check Xcode console: WakeLock enabled | ⬜ | +| 2 | Wait 1 minute without touching | Screen stays on | ⬜ | +| 3 | Wait 5 minutes without touching | Screen still on | ⬜ | +| 4 | Lock device manually | Screen turns off (manual override works) | ⬜ | +| 5 | Unlock device | App still in foreground | ⬜ | + +**Acceptance Criteria:** +- ✅ Screen stays on indefinitely when app is active +- ✅ Manual power button/lock still works +- ✅ No excessive battery drain (check device battery stats) + +--- + +## Test 4: Wake Lock - Disable Functionality + +**Setup:** +1. In configuration, disable "Prevent Screen Lock" +2. Restart app (future: just toggle setting) + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Launch app with setting OFF | Check logs: "WakeLock disabled" or not enabled | ⬜ | +| 2 | Wait for auto-lock timeout | Screen dims/locks per device settings | ⬜ | +| 3 | Re-enable "Prevent Screen Lock" | WakeLock activates, screen stays on | ⬜ | + +**Acceptance Criteria:** +- ✅ Wake lock respects configuration setting +- ✅ Disabling allows normal screen lock behavior +- ✅ Enabling prevents screen lock + +--- + +## Test 5: Button Configuration Display + +**Setup:** +1. Launch app with different button configurations + +**Test Steps:** +| Button | Icon | Display Name | Pass/Fail | +|--------|------|--------------|-----------| +| Volume Down | 🔉 | "Volume Down" | ⬜ | +| Volume Up | 🔊 | "Volume Up" | ⬜ | +| On-Screen | 📱 | "On-Screen Button" | ⬜ | +| Headset Play/Pause | 🎧 | "Headset Play/Pause" | ⬜ | + +**Acceptance Criteria:** +- ✅ Current button configuration clearly displayed +- ✅ Icon + name visible at top of screen +- ✅ Instruction text adapts to button type + +--- + +## Test 6: Cycling Use Case Simulation + +**Real-world scenario:** Using app while cycling + +**Setup:** +1. Enable "Prevent Screen Lock" +2. Set PTT button to "On-Screen Button" +3. Set mode to "Hold Mode" +4. Mount device on bike (or simulate) + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Start ride simulation (10 min) | Screen stays on entire time | ⬜ | +| 2 | Press PTT button with glove | Button activates reliably | ⬜ | +| 3 | Hold button while "talking" (30s) | PTT stays active, screen doesn't dim | ⬜ | +| 4 | Release button | PTT deactivates | ⬜ | +| 5 | Repeat 20 times during ride | All presses register correctly | ⬜ | +| 6 | Check battery after 30 min | Battery drain acceptable (< 10%/30min) | ⬜ | + +**Acceptance Criteria:** +- ✅ On-screen button usable with cycling gloves +- ✅ Button size adequate (200x200px) +- ✅ Screen stays visible entire ride +- ✅ No performance issues or lag +- ✅ Acceptable battery consumption + +--- + +## Test 7: Switching Between Button Types + +**Setup:** +1. Start with Volume Down button +2. Activate PTT (recording) + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | While recording, switch to On-Screen | Recording stops, switches to on-screen | ⬜ | +| 2 | Verify button display | Shows "On-Screen Button" with icon | ⬜ | +| 3 | Tap on-screen button | PTT activates with on-screen button | ⬜ | +| 4 | Press Volume Down | No effect (not configured) | ⬜ | +| 5 | Switch back to Volume Down | On-screen button becomes passive indicator | ⬜ | +| 6 | Press Volume Down | PTT activates with volume button | ⬜ | + +**Acceptance Criteria:** +- ✅ Switching buttons stops active recording +- ✅ Only configured button triggers PTT +- ✅ Visual indicator shows active button +- ✅ On-screen button only interactive when selected + +--- + +## Cross-Platform Verification + +**Compare Android vs iOS:** +| Feature | Android Result | iOS Result | Notes | +|---------|---------------|------------|-------| +| On-screen tap | ⬜ | ⬜ | Should be identical | +| On-screen hold | ⬜ | ⬜ | Should be identical | +| Wake lock ON | ⬜ | ⬜ | Should be identical | +| Wake lock OFF | ⬜ | ⬜ | Should be identical | +| Button size/usability | ⬜ | ⬜ | Should be identical | +| Battery usage (30 min) | ___% | ___% | Document actual | + +--- + +## Known Issues & Limitations + +### On-Screen Button +- ⚠️ Requires screen to be on (can't work from lock screen) +- ⚠️ May be difficult to use with thick winter gloves +- ⚠️ Accidental touches possible if device in pocket + +### Wake Lock +- ⚠️ Battery drain during long rides (mitigated by design) +- ⚠️ May interfere with other apps' screen control +- ⚠️ User must remember to exit app to allow screen lock + +--- + +## Troubleshooting + +### On-Screen Button Not Responding + +**Check Flutter console:** +``` +# Should see when tapping: +PTT State changed to: active +``` + +**If button doesn't respond:** +1. Verify button config is "onScreen" +2. Check `GestureDetector` handlers in `home_screen.dart:22-34` +3. Try toggle vs hold mode + +### Wake Lock Not Working + +**Android:** +```bash +adb logcat | grep -i wakelock +# Should see: "WakeLock enabled" +``` + +**iOS:** +``` +# Check Xcode console +# Should see: "WakeLock enabled" +``` + +**If wake lock fails:** +1. Check `wakelock_plus` package installed: `flutter pub get` +2. Verify `preventScreenLock` is `true` in config +3. Check device battery saver mode (may override) + +### Battery Drain Issues + +**Expected battery usage:** +- With wake lock ON: ~15-20% per hour +- With wake lock OFF: ~5-10% per hour + +**If excessive drain:** +1. Check for other background apps +2. Verify screen brightness isn't at maximum +3. Consider disabling wake lock for short rides + +--- + +## Success Criteria for Phase 2 + +### Must Have ✅ +- [ ] On-screen button works in toggle mode +- [ ] On-screen button works in hold mode +- [ ] Wake lock prevents screen sleep +- [ ] Button size adequate for gloved use +- [ ] Visual feedback clear and immediate + +### Nice to Have ⭐ +- [ ] Haptic feedback on button press (future enhancement) +- [ ] Customizable button size (future enhancement) +- [ ] Battery usage optimization + +### Known Limitations (Acceptable) ⚠️ +- [ ] Requires screen to be on (documented) +- [ ] May be difficult with very thick gloves (volume buttons recommended) +- [ ] Battery usage higher with wake lock (documented) + +--- + +## Next Steps After Phase 2 Validation + +Once Phase 2 tests pass: + +**✅ Approved to proceed → Phase 3: Settings UI** +- Create settings screen for button configuration +- Add platform-aware button selection +- Implement configuration persistence +- Test complete system end-to-end + +**❌ Issues found → Fix before proceeding** +- Document failing tests +- Debug Flutter/native integration +- Iterate on UI/UX + +--- + +## Testing Log + +**Date:** ___________ +**Tester:** ___________ +**Devices Tested:** +- Android: ___________ +- iOS: ___________ + +**Overall Result:** PASS / FAIL / NEEDS WORK + +**Battery Usage Data:** +- Android (30 min): ____% +- iOS (30 min): ____% + +**Notes:** +``` +(Add any observations, issues, or recommendations here) +``` + +--- + +**Phase 2 Status:** ⬜ NOT TESTED | ⬜ IN PROGRESS | ⬜ PASSED | ⬜ FAILED diff --git a/PHASE3_SETTINGS_UI_TESTING.md b/PHASE3_SETTINGS_UI_TESTING.md new file mode 100644 index 0000000..aba3933 --- /dev/null +++ b/PHASE3_SETTINGS_UI_TESTING.md @@ -0,0 +1,406 @@ +# Phase 3: Settings UI & Configuration - Testing Guide + +## ✅ Implementation Complete + +Phase 3 provides a complete settings UI for configuring PTT button, mode, and screen lock preferences. + +### What's Been Implemented + +#### Settings Screen +- ✅ Full settings UI with sections for Mode, Button, and Screen Lock +- ✅ Radio button selection for PTT modes +- ✅ Platform-aware button selection (Android vs iOS) +- ✅ Visual feedback for selected options +- ✅ "RECOMMENDED" badge for volume buttons +- ✅ Platform-specific tips and warnings +- ✅ Settings icon in home screen app bar + +#### Platform Intelligence +- ✅ Filters button options by platform (camera button Android-only, etc.) +- ✅ Shows iOS Siri limitation warnings +- ✅ Displays platform-specific recommendations +- ✅ Adaptive tips based on Android/iOS + +--- + +## 🧪 Stage Gate 3: Complete System Testing + +**OBJECTIVE:** Validate the complete configurable PTT system works end-to-end with all button types and modes. + +### Prerequisites + +**Ensure Phases 1 & 2 are complete:** +- ⬜ Phase 1 volume button tests passed +- ⬜ Phase 2 on-screen button tests passed +- ⬜ Wake lock functionality verified + +--- + +### Test Plan + +## Test 1: Settings Screen Navigation + +**Setup:** +1. Launch app +2. Home screen should show settings icon + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Tap settings icon (top-right) | Settings screen opens | ⬜ | +| 2 | Verify sections | Three sections: PTT Mode, PTT Button, Screen Lock | ⬜ | +| 3 | Tap back button | Returns to home screen | ⬜ | +| 4 | Re-open settings | Previous selections still active | ⬜ | + +**Acceptance Criteria:** +- ✅ Settings screen accessible from home +- ✅ Clean UI with clear sections +- ✅ Back navigation works +- ✅ Settings persist during session + +--- + +## Test 2: PTT Mode Selection + +**Setup:** +1. Open settings screen +2. Navigate to PTT Mode section + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | View mode options | Shows Toggle and Hold modes | ⬜ | +| 2 | Default selection | Toggle mode selected by default | ⬜ | +| 3 | Tap Hold mode | Radio button changes, background highlights | ⬜ | +| 4 | Return to home | Home screen shows "HOLD" on on-screen button | ⬜ | +| 5 | Return to settings | Hold mode still selected | ⬜ | +| 6 | Switch back to Toggle | Updates immediately | ⬜ | + +**Acceptance Criteria:** +- ✅ Both modes visible with descriptions +- ✅ Selection changes immediately +- ✅ Home screen reflects mode change +- ✅ Visual feedback clear (radio button + highlight) + +--- + +## Test 3: PTT Button Selection - Platform Awareness + +**Android Test:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Count available buttons | Shows all except "System PTT" | ⬜ | +| 2 | Verify Camera button present | "Camera Button" option visible | ⬜ | +| 3 | Check RECOMMENDED badges | Volume Down/Up show green badge | ⬜ | +| 4 | Read Android tips | Shows Android-specific tip about Google Assistant | ⬜ | + +**iOS Test:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Count available buttons | Shows all except "Camera Button" | ⬜ | +| 2 | Verify System PTT option | "System PTT (iOS 16+)" visible | ⬜ | +| 3 | Check warning section | Shows iOS Siri limitation warning | ⬜ | +| 4 | Read iOS tips | Shows iOS-specific tip about Siri | ⬜ | + +**Acceptance Criteria:** +- ✅ Platform-specific button filtering works +- ✅ Recommended options clearly marked +- ✅ Platform tips accurate and helpful +- ✅ All available buttons show icon + name + description + +--- + +## Test 4: Button Selection & Immediate Effect + +**Setup:** +1. Start with Volume Down selected +2. Open settings + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Tap On-Screen Button | Selection changes, radio button updates | ⬜ | +| 2 | Return to home | Shows "On-Screen Button" with 📱 icon | ⬜ | +| 3 | Tap on-screen button | PTT activates | ⬜ | +| 4 | Press Volume Down | No effect (not configured) | ⬜ | +| 5 | Return to settings, select Volume Down | Updates immediately | ⬜ | +| 6 | Return to home | Shows "Volume Down" with 🔉 icon | ⬜ | +| 7 | Press Volume Down | PTT activates | ⬜ | +| 8 | Tap on-screen button | No effect (passive indicator only) | ⬜ | + +**Acceptance Criteria:** +- ✅ Button selection updates immediately +- ✅ Home screen reflects current button +- ✅ Only active button triggers PTT +- ✅ On-screen button becomes passive when not selected + +--- + +## Test 5: Screen Lock Toggle + +**Setup:** +1. Open settings +2. Navigate to Screen Lock section + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Verify default state | Switch ON by default | ⬜ | +| 2 | Toggle switch OFF | Switch animates to OFF position | ⬜ | +| 3 | Check logs | Shows "WakeLock disabled" | ⬜ | +| 4 | Wait for screen timeout | Screen dims/locks after device timeout | ⬜ | +| 5 | Toggle switch ON | Switch animates to ON position | ⬜ | +| 6 | Check logs | Shows "WakeLock enabled" | ⬜ | +| 7 | Wait 5 minutes | Screen stays on | ⬜ | + +**Acceptance Criteria:** +- ✅ Toggle switch works smoothly +- ✅ Wake lock enables/disables immediately +- ✅ Logs confirm state changes +- ✅ Screen behavior matches setting + +--- + +## Test 6: Complete Configuration Flow + +**Scenario:** User wants "Volume Up + Hold Mode + No Screen Lock" for quick testing + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Open settings | Current config visible | ⬜ | +| 2 | Set Mode to Hold | Updates with highlight | ⬜ | +| 3 | Set Button to Volume Up | Updates with highlight | ⬜ | +| 4 | Set Screen Lock to OFF | Toggle switches off | ⬜ | +| 5 | Return to home | Shows "🔊 Volume Up", no HOLD label on button | ⬜ | +| 6 | Press and hold Volume Up | PTT activates | ⬜ | +| 7 | Release Volume Up | PTT deactivates | ⬜ | +| 8 | Wait for timeout | Screen locks (wake lock off) | ⬜ | + +**Acceptance Criteria:** +- ✅ All three settings work together +- ✅ Configuration changes reflected immediately +- ✅ Home screen UI adapts to configuration +- ✅ PTT behavior matches selected mode + button + +--- + +## Test 7: All Button Types - Comprehensive + +**Setup:** Test each button type systematically + +**Test Matrix:** +| Button | Icon | Android | iOS | Tested | Pass/Fail | +|--------|------|---------|-----|--------|-----------| +| On-Screen | 📱 | ✓ | ✓ | ⬜ | ⬜ | +| Volume Down | 🔉 | ✓ | ✓ | ⬜ | ⬜ | +| Volume Up | 🔊 | ✓ | ✓ | ⬜ | ⬜ | +| Headset Play/Pause | 🎧 | ✓ | ✓ | ⬜ | ⬜ | +| Headset Next | ⏭️ | ✓ | ✓ | ⬜ | ⬜ | +| Headset Previous | ⏮️ | ✓ | ✓ | ⬜ | ⬜ | +| Camera | 📷 | ✓ | ✗ | ⬜ | ⬜ | +| System PTT | 🍎 | ✗ | ✓ | ⬜ | ⬜ | + +**For each button:** +1. Select in settings +2. Return to home +3. Verify button display +4. Test activation (press/tap) +5. Verify PTT state changes + +**Acceptance Criteria:** +- ✅ All buttons selectable on their supported platforms +- ✅ Each button triggers PTT correctly +- ✅ Icons and names display correctly +- ✅ Platform filtering prevents unsupported options + +--- + +## Test 8: Persistence Across Sessions (Future Enhancement) + +**Note:** Configuration persistence not yet implemented - this tests current session behavior only + +**Test Steps:** +| Step | Action | Expected Result | Pass/Fail | +|------|--------|----------------|-----------| +| 1 | Configure: Hold + Volume Up + Lock OFF | Settings update | ⬜ | +| 2 | Use app for 5 minutes | Configuration stable | ⬜ | +| 3 | Hot reload (Flutter dev) | ⚠️ Configuration resets to default | ⬜ | +| 4 | Kill and restart app | ⚠️ Configuration resets to default | ⬜ | + +**Known Limitation:** +- ⚠️ Configuration does NOT persist across app restarts (future enhancement) +- ✅ Configuration IS stable during active session + +--- + +## Test 9: UI/UX Quality + +**Evaluate overall user experience:** + +| Aspect | Rating (1-5) | Notes | +|--------|--------------|-------| +| Settings discoverability | ___ | Is settings icon obvious? | +| Selection clarity | ___ | Clear what's selected? | +| Descriptions helpful | ___ | Do descriptions explain options? | +| Platform tips useful | ___ | Are tips relevant? | +| Visual feedback | ___ | Is selection feedback immediate? | +| Navigation smoothness | ___ | Smooth transitions? | +| Dark theme consistency | ___ | Consistent with app theme? | +| Text readability | ___ | Text easy to read? | + +**Target:** All ratings ≥ 4/5 + +--- + +## Test 10: Edge Cases & Error Handling + +**Test Steps:** +| Scenario | Expected Behavior | Pass/Fail | +|----------|-------------------|-----------| +| Rapidly switch buttons 10 times | No crashes, updates smooth | ⬜ | +| Toggle mode while PTT active | Recording stops, mode changes | ⬜ | +| Change button while PTT active | Recording stops, button changes | ⬜ | +| Open settings while recording | Settings open, recording continues | ⬜ | +| Navigate back while recording | Returns to home, still recording | ⬜ | +| Toggle wake lock rapidly | No crashes, stable behavior | ⬜ | + +**Acceptance Criteria:** +- ✅ No crashes under rapid input +- ✅ Active recording stops cleanly on config change +- ✅ Settings accessible during recording +- ✅ Stable behavior under stress + +--- + +## Integration Test: Real-World Cycling Scenario + +**Scenario:** User prepares for a Peloton ride + +**Setup:** +1. User has Bluetooth headset +2. User is on a bike (or simulating) + +**Test Steps:** +| Step | User Action | Expected Result | Pass/Fail | +|------|------------|----------------|-----------| +| 1 | Opens app, taps settings | Settings screen opens | ⬜ | +| 2 | Sees recommendation for Volume Down | Green "RECOMMENDED" badge visible | ⬜ | +| 3 | Selects Volume Down + Hold Mode | Configuration updates | ⬜ | +| 4 | Enables Screen Lock prevention | Wake lock activates | ⬜ | +| 5 | Returns to home | Shows "🔉 Volume Down" | ⬜ | +| 6 | Mounts phone, starts ride | Screen stays on | ⬜ | +| 7 | Presses Volume Down to talk | PTT activates, recording | ⬜ | +| 8 | Releases Volume Down | PTT deactivates | ⬜ | +| 9 | Repeats 20 times during ride | All presses register correctly | ⬜ | +| 10 | Finishes ride, exits app | Screen lock resumes normally | ⬜ | + +**Acceptance Criteria:** +- ✅ Configuration flow intuitive and quick (< 30 seconds) +- ✅ Recommended options clear +- ✅ PTT works reliably throughout ride +- ✅ Screen stays on entire session +- ✅ System returns to normal after app exit + +--- + +## Cross-Platform Comparison + +**Compare Android vs iOS experience:** + +| Feature | Android | iOS | Notes | +|---------|---------|-----|-------| +| Button options available | 7 | 7 | Different sets | +| Recommended button | Volume Down | Volume Down | Same | +| Siri/Assistant warning | Google Assistant prevented | Siri limitation documented | iOS has known issue | +| Settings UI | ⬜ Works | ⬜ Works | Should be identical | +| Platform tips | ⬜ Relevant | ⬜ Relevant | Should be different | +| Wake lock | ⬜ Works | ⬜ Works | Should be identical | + +--- + +## Success Criteria for Phase 3 + +### Must Have ✅ +- [ ] Settings screen accessible and intuitive +- [ ] All configuration options work correctly +- [ ] Platform-aware button filtering functional +- [ ] Immediate configuration updates +- [ ] Stable during active session +- [ ] Platform-specific tips displayed + +### Nice to Have ⭐ +- [ ] Configuration persistence (future enhancement) +- [ ] Haptic feedback on selection (future enhancement) +- [ ] Button test mode (future enhancement) +- [ ] Export/import configuration (future enhancement) + +### Known Limitations (Acceptable) ⚠️ +- [ ] Configuration doesn't persist across restarts (future) +- [ ] iOS Siri limitation documented +- [ ] No A/B testing for optimal defaults (future) + +--- + +## Final System Validation + +**Complete end-to-end test:** + +1. ✅ **Phase 1:** Volume buttons work on both platforms +2. ✅ **Phase 2:** On-screen button + wake lock functional +3. ✅ **Phase 3:** Settings UI complete and usable + +**Overall System Status:** ⬜ READY FOR PRODUCTION | ⬜ NEEDS WORK + +--- + +## Next Steps After Phase 3 Validation + +### If All Tests Pass: + +**Production Readiness Checklist:** +- [ ] Add configuration persistence (SharedPreferences/UserDefaults) +- [ ] Add analytics to track button usage +- [ ] Create user documentation/tutorial +- [ ] Add first-run onboarding +- [ ] Performance optimization +- [ ] Battery usage optimization +- [ ] Prepare for App Store/Play Store submission + +### Future Enhancements (Post-MVP): +- [ ] Custom button mapping +- [ ] Accessibility Service for background volume buttons (Android) +- [ ] iOS 16+ PushToTalk framework integration +- [ ] Haptic feedback +- [ ] Audio recording and actual PTT functionality +- [ ] WebRTC integration for multi-rider communication + +--- + +## Testing Log + +**Date:** ___________ +**Tester:** ___________ +**Devices Tested:** +- Android: ___________ +- iOS: ___________ + +**Configuration Tested:** +- Mode: ___________ +- Button: ___________ +- Screen Lock: ___________ + +**Overall Result:** PASS / FAIL / NEEDS WORK + +**UI/UX Rating:** ___/5 + +**Notes:** +``` +(Add any observations, issues, or recommendations here) +``` + +--- + +**Phase 3 Status:** ⬜ NOT TESTED | ⬜ IN PROGRESS | ⬜ PASSED | ⬜ FAILED + +**Complete System Status:** ⬜ READY FOR PRODUCTION | ⬜ NEEDS WORK diff --git a/PTT_IMPLEMENTATION_SUMMARY.md b/PTT_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..f3ad00a --- /dev/null +++ b/PTT_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,351 @@ +# Configurable PTT Button System - Implementation Summary + +## 🎉 Implementation Complete + +A complete, configurable push-to-talk (PTT) button system has been implemented with support for multiple input methods, proper voice assistant prevention, and comprehensive settings UI. + +--- + +## 📋 What Was Built + +### Core Features +✅ **8 Button Options** - Volume Down/Up, On-Screen, Headset buttons, Camera (Android), System PTT (iOS 16+) +✅ **2 PTT Modes** - Toggle (tap to start/stop) and Hold (press and hold) +✅ **Platform Intelligence** - Automatic filtering of platform-specific buttons +✅ **Voice Assistant Prevention** - Google Assistant blocking on Android (iOS limitation documented) +✅ **Screen Wake Lock** - Keeps screen on during rides (configurable) +✅ **Settings UI** - Complete configuration interface with visual feedback +✅ **Real-time Updates** - Configuration changes apply immediately + +--- + +## 🏗️ Architecture + +### Flutter/Dart Layer +- **`ptt_state.dart`** - Data models (PTTState, PTTMode, PTTButton, PTTConfiguration) +- **`ptt_service.dart`** - Business logic and state management with Provider +- **`home_screen.dart`** - Main PTT interface with on-screen button +- **`settings_screen.dart`** - Configuration UI + +### Android Layer +- **`PttMediaSessionService.kt`** - Foreground Media3 `MediaSessionService`; owns the media + session so headset play/pause/headsethook events keep arriving backgrounded/screen-off +- **`PttEventBus.kt` / `PttPlayer.kt`** - Event bridge and minimal `Player` stub for the session +- **`MainActivity.kt`** - Starts the service, handles volume-button `onKeyDown`/`onKeyUp` + (activity-scoped, not delivered via MediaSession), routes events to Flutter +- **Wake lock** - FLAG_KEEP_SCREEN_ON support + +### iOS Layer +- **`PTTSystemManager.swift`** - Apple **PushToTalk framework** (iOS 16+) integration — + recommended path (`systemPTT` button); gives background transmit + accessory button events + via `setAccessoryButtonEventsEnabled(true)` +- **`AppDelegate.swift`** - `MPRemoteCommandCenter` (headset next/prev/play-pause options, + foreground only) + `VolumeButtonObserver` (KVO-based volume button capture) + wake lock + (`isIdleTimerDisabled`) +- **Platform tips** - Siri long-press limitation documented (MPRemoteCommandCenter path only) + +--- + +## 🔘 Button Support Matrix + +| Button Type | Android | iOS | Prevents Assistant | Recommended For | +|------------|---------|-----|-------------------|-----------------| +| **Volume Down** | ✅ | ✅ | ✅ (Android only) | **Cycling** - Easy with gloves | +| **Volume Up** | ✅ | ✅ | ✅ (Android only) | Alternative to Volume Down | +| **On-Screen** | ✅ | ✅ | ✅ (N/A) | Universal fallback | +| **Headset Play/Pause** | ✅ | ✅ | ✅ (Android only) | Bluetooth headsets | +| **Headset Next** | ✅ | ✅ | ✅ (Both) | No Siri conflict | +| **Headset Previous** | ✅ | ✅ | ✅ (Both) | No Siri conflict | +| **Camera Button** | ✅ | ❌ | ✅ | Android PTT devices | +| **System PTT (iOS 16+)** | ❌ | ✅ | ✅ | iOS lock screen PTT | + +--- + +## 📱 Platform-Specific Behavior + +### Android +- **Volume buttons:** Fully functional (activity-scoped), Google Assistant prevented on long-press +- **Headset buttons:** Full support via `PttMediaSessionService` (Media3 foreground service) +- **Camera button:** Works on devices with dedicated camera button +- **Wake lock:** FLAG_KEEP_SCREEN_ON via WindowManager +- **Background:** ✅ Solved — foreground `MediaSessionService` keeps receiving headset + events with the app backgrounded or the screen off (no Accessibility Service needed) + +### iOS +- **Volume buttons:** Functional via KVO, volume resets automatically +- **Headset buttons:** Two paths — `MPRemoteCommandCenter` (foreground only, all iOS + versions) or **`systemPTT`** via Apple's PushToTalk framework (iOS 16+, recommended: + works backgrounded, proper accessory button routing) +- **Siri limitation:** Long-press on the `MPRemoteCommandCenter` path CANNOT be prevented + (system restriction); the `systemPTT` path sidesteps this since it isn't a media-key + long-press in the first place +- **Wake lock:** isIdleTimerDisabled via UIApplication +- **Recommended:** `systemPTT` for headset button PTT going forward; toggle mode + quick + taps only when falling back to `MPRemoteCommandCenter` on iOS < 16 + +--- + +## 🧪 Testing Strategy + +Three-phase staged-gate validation approach: + +### Phase 1: Volume Buttons ✅ +**Docs:** `PHASE1_VOLUME_BUTTON_TESTING.md` +**Focus:** Validate volume button capture and Google Assistant prevention +**Critical Tests:** +- Volume Down/Up trigger PTT +- Long-press does NOT activate Google Assistant (Android) +- Screen wake lock functional +- Siri limitation documented (iOS) + +### Phase 2: On-Screen Button ✅ +**Docs:** `PHASE2_ONSCREEN_BUTTON_TESTING.md` +**Focus:** Universal fallback with gesture detection and wake lock +**Critical Tests:** +- Tap/hold gestures work reliably +- Visual feedback immediate +- Wake lock prevents screen sleep +- Usable with cycling gloves + +### Phase 3: Settings UI ✅ +**Docs:** `PHASE3_SETTINGS_UI_TESTING.md` +**Focus:** Complete configuration system validation +**Critical Tests:** +- All button types configurable +- Platform-aware filtering +- Real-time configuration updates +- Intuitive UX + +--- + +## 📁 Files Modified/Created + +### Flutter Code +``` +packages/mobile/ +├── lib/ +│ ├── models/ +│ │ └── ptt_state.dart # MODIFIED - Added enums & config +│ ├── services/ +│ │ └── ptt_service.dart # MODIFIED - Added configuration +│ └── ui/screens/ +│ ├── home_screen.dart # MODIFIED - On-screen button + settings nav +│ └── settings_screen.dart # CREATED - Full settings UI +└── pubspec.yaml # MODIFIED - Added wakelock_plus +``` + +### Android Code +``` +packages/mobile/android/app/src/main/kotlin/com/example/app/ +└── MainActivity.kt # MODIFIED - Multi-button support + wake lock +``` + +### iOS Code +``` +packages/mobile/ios/Runner/ +└── AppDelegate.swift # MODIFIED - Volume observer + wake lock +``` + +### Documentation +``` +/ +├── PTT_IMPLEMENTATION_SUMMARY.md # CREATED - This file +├── PHASE1_VOLUME_BUTTON_TESTING.md # CREATED - Phase 1 testing guide +├── PHASE2_ONSCREEN_BUTTON_TESTING.md # CREATED - Phase 2 testing guide +└── PHASE3_SETTINGS_UI_TESTING.md # CREATED - Phase 3 testing guide +``` + +--- + +## 🚀 How to Build & Test + +### 1. Install Dependencies +```bash +cd packages/mobile +flutter pub get +``` + +### 2. Build for Android +```bash +flutter build apk --debug +# Or run directly: +flutter run --device-id= +``` + +### 3. Build for iOS +```bash +flutter build ios --debug +# Or run directly: +flutter run --device-id= +``` + +### 4. Testing +**Physical devices required** - Emulators don't support Bluetooth/volume buttons properly + +Follow the testing guides: +1. `PHASE1_VOLUME_BUTTON_TESTING.md` - Volume button validation +2. `PHASE2_ONSCREEN_BUTTON_TESTING.md` - On-screen button validation +3. `PHASE3_SETTINGS_UI_TESTING.md` - Complete system validation + +### 5. Check Logs +```bash +# Android +adb logcat | grep PTT + +# iOS +# Use Xcode console +``` + +--- + +## ✅ Success Criteria (All Phases) + +### Must Have - All Implemented ✅ +- [x] Volume Down button works (both platforms) +- [x] Google Assistant prevention (Android) +- [x] On-screen PTT button (universal fallback) +- [x] Screen wake lock (configurable) +- [x] Settings UI (complete configuration) +- [x] Platform-aware button filtering +- [x] Real-time configuration updates +- [x] Multiple button type support +- [x] Toggle and Hold modes +- [x] Visual feedback and instructions + +### Known Limitations (Documented) ⚠️ +- ⚠️ iOS `MPRemoteCommandCenter` path cannot prevent Siri on long-press (system + restriction; use `systemPTT` on iOS 16+ instead) +- ⚠️ Configuration doesn't persist across restarts (see `ROADMAP.md` item 4) +- ⚠️ Headset volume buttons aren't captured — AVRCP absolute volume bypasses `KeyEvent` + delivery entirely (see `ROADMAP.md` item 1, `VolumeProvider` plan) +- ⚠️ Brief volume change on iOS before reset (imperceptible) + +--- + +## 🔮 Future Enhancements + +See `ROADMAP.md` for the actively tracked list with owners/acceptance criteria. Summary: + +### Done since this doc was first written ✅ +- [x] **iOS 16+ PushToTalk Framework** - native `systemPTT` integration (`PTTSystemManager.swift`) +- [x] **Background headset button capture (Android)** - `PttMediaSessionService` foreground + `MediaSessionService`, no Accessibility Service required +- [x] **WebRTC signaling/service layer** - `signaling_client.dart`, `webrtc_service.dart`, call screen + +### Priority 1 (Next Up) +- [ ] **Headset volume-button PTT** - `VolumeProvider` on the Android media session (toggle + mode only; AVRCP absolute volume never delivers hold semantics) — `ROADMAP.md` item 1 +- [ ] **Wire PTT state to WebRTC audio** - replace the local-record POC path with track + mute/unmute against the existing WebRTC peer connection — `ROADMAP.md` item 2 +- [ ] **Configuration Persistence** - SharedPreferences (Android) / UserDefaults (iOS) + +### Priority 2 (Future) +- [ ] **Default iOS headset button to `systemPTT`** - keep `MPRemoteCommandCenter` only as + an iOS <16 fallback +- [ ] **Haptic Feedback** - Vibration on button press +- [ ] **Custom Button Mapping** - User-defined button assignments +- [ ] **Dedicated BLE PTT button support** - true press/release over BLE GATT, bypassing + AVRCP entirely (Zello/ESChat hardware ecosystem) + +### Priority 3 (Nice-to-Have) +- [ ] **Analytics** - Track button usage patterns +- [ ] **A/B Testing** - Optimal default configurations +- [ ] **First-Run Tutorial** - Onboarding flow for new users + +--- + +## 📊 Implementation Stats + +**Development Time:** ~6-8 hours (estimated) +**Lines of Code:** ~1,500+ (Dart + Kotlin + Swift) +**Files Modified:** 7 +**Files Created:** 5 +**Platforms Supported:** Android 10+, iOS 14+ +**Button Types:** 8 unique options +**Testing Phases:** 3 comprehensive stages + +--- + +## 🎓 Key Technical Decisions + +### 1. Why Platform Channels over Plugins? +- Direct control over native button handling +- No external dependencies for core PTT functionality +- Custom implementation for specific use case + +### 2. Why dispatchKeyEvent() (Android)? +- Called BEFORE system handlers (critical for Assistant prevention) +- Single interception point for all button types +- Returns true to consume events + +### 3. Why KVO for Volume Buttons (iOS)? +- AVAudioSession volume observation standard approach +- Volume reset prevents actual volume changes +- Works reliably on all iOS versions + +### 4. Why Provider for State Management? +- Simple, reactive state updates +- Minimal boilerplate for this use case +- Built-in to Flutter ecosystem + +### 5. Why Staged-Gate Testing? +- Validates each layer before building on it +- Catches issues early +- Clear success criteria per phase + +--- + +## 📞 Support & Troubleshooting + +### Common Issues + +**Volume button not working:** +1. Verify physical device (not emulator) +2. Check logs for button events +3. Confirm correct button selected in settings + +**Google Assistant still activating (Android):** +1. Check logs for "Long press detected - consuming" +2. Verify dispatchKeyEvent returns true +3. Test on different Android version + +**Siri activating (iOS):** +1. This is expected on long-press (documented limitation) +2. Recommend toggle mode with quick taps +3. Consider using Headset Next/Previous buttons instead + +**Screen not staying on:** +1. Verify "Prevent Screen Lock" enabled in settings +2. Check logs for "WakeLock enabled" +3. Check device battery saver mode + +--- + +## ✨ Conclusion + +**Status:** ✅ **IMPLEMENTATION COMPLETE - READY FOR TESTING** + +All three phases (Volume Buttons, On-Screen Button, Settings UI) have been implemented with: +- ✅ Comprehensive button support (8 types) +- ✅ Platform intelligence (Android/iOS aware) +- ✅ Voice assistant prevention (Android) +- ✅ Complete configuration UI +- ✅ Detailed testing documentation + +**Next Step:** Run Phase 1, 2, and 3 tests on physical devices to validate the complete system. + +--- + +## 📝 Quick Start Checklist + +- [ ] Run `flutter pub get` +- [ ] Build for your platform +- [ ] Deploy to physical device +- [ ] Open settings, select Volume Down +- [ ] Enable wake lock +- [ ] Return to home +- [ ] Press Volume Down → Should activate PTT +- [ ] Verify Google Assistant does NOT activate (Android) +- [ ] Complete Phase 1-3 testing guides + +**Happy Testing! 🎉** diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 0000000..f248c54 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,118 @@ +# Quick Start Guide - Configurable PTT System + +## 🚀 5-Minute Setup + +### Step 1: Install Dependencies +```bash +cd packages/mobile +flutter pub get +``` + +### Step 2: Deploy to Device +```bash +# Android +flutter run + +# iOS +flutter run + +# Or specify device: +flutter devices +flutter run --device-id= +``` + +### Step 3: Configure PTT +1. Tap **Settings icon** (top-right) +2. Select **Volume Down** button +3. Keep **Toggle Mode** (default) +4. Ensure **Prevent Screen Lock** is ON +5. Tap **Back** + +### Step 4: Test +1. Press **Volume Down** → PTT activates (green) +2. Press **Volume Down** again → PTT deactivates (red) +3. ✅ **Android:** Long-press should NOT trigger Google Assistant +4. ⚠️ **iOS:** Long-press may trigger Siri (known limitation) + +--- + +## 📱 Recommended Configurations + +### For Cycling (Recommended) +``` +Button: Volume Down 🔉 +Mode: Hold (press and hold to talk) +Lock: ON (keep screen on) +``` +**Why:** Easy to press with gloves, hands-free hold mode + +### For Testing +``` +Button: On-Screen Button 📱 +Mode: Toggle (tap to start/stop) +Lock: ON +``` +**Why:** Reliable, works everywhere, no hardware required + +### For Bluetooth Headset +``` +Button: Headset Next Track ⏭️ +Mode: Toggle +Lock: ON +``` +**Why:** No Siri/Assistant conflict, dedicated button + +--- + +## 🔍 Quick Troubleshooting + +| Problem | Solution | +|---------|----------| +| Volume button doesn't work | Use physical device (not emulator) | +| Google Assistant activates | Check Android logs for "consuming" message | +| Siri activates (iOS) | Expected - use toggle mode with quick taps | +| Screen locks | Enable "Prevent Screen Lock" in settings | +| Button not responding | Verify correct button selected in settings | + +--- + +## 📋 Testing Checklist + +Quick validation (5 minutes): +- [ ] Volume Down activates PTT +- [ ] On-screen button works +- [ ] Settings screen accessible +- [ ] Mode switching works +- [ ] Screen stays on with wake lock +- [ ] Long-press doesn't trigger Assistant (Android) + +Full validation (30 minutes): +- [ ] Complete PHASE1_VOLUME_BUTTON_TESTING.md +- [ ] Complete PHASE2_ONSCREEN_BUTTON_TESTING.md +- [ ] Complete PHASE3_SETTINGS_UI_TESTING.md + +--- + +## 📚 Documentation + +- **PTT_IMPLEMENTATION_SUMMARY.md** - Complete overview +- **PHASE1_VOLUME_BUTTON_TESTING.md** - Volume button tests +- **PHASE2_ONSCREEN_BUTTON_TESTING.md** - On-screen button tests +- **PHASE3_SETTINGS_UI_TESTING.md** - Settings UI tests + +--- + +## 🎯 Success in 3 Commands + +```bash +# 1. Install +cd packages/mobile && flutter pub get + +# 2. Run +flutter run + +# 3. Test +# Press Volume Down → PTT should activate +``` + +**That's it! You're ready to test. 🎉** diff --git a/README.md b/README.md index b879c19..600947f 100644 --- a/README.md +++ b/README.md @@ -2,108 +2,235 @@ A cross-platform push-to-talk communication system designed for group activities like cycling, running, and fitness sessions. -## 🏗️ Architecture +## Architecture This is a monorepo containing multiple packages that work together to provide a complete communication solution: ``` ├── packages/ -│ ├── mobile/ # Flutter mobile app (iOS/Android) -│ └── server/ # Go backend API server -├── docs/ # Documentation -├── scripts/ # Build and deployment scripts -└── .github/workflows/ # CI/CD pipelines +│ ├── mobile/ # Flutter mobile app (iOS/Android) +│ ├── server/ # Go backend API server (legacy) +│ ├── services/ +│ │ └── signaling/ # WebRTC signaling service (Go) +│ └── infra/ # Infrastructure configs +│ ├── docker-compose.yaml +│ └── k3s/ # Kubernetes manifests +├── docs/ # Documentation +├── scripts/ # Build and deployment scripts +└── .github/workflows/ # CI/CD pipelines ``` -## 📱 Features +## MVP Status -### Mobile App -- **Push-to-Talk (PTT)** functionality with Bluetooth headset support -- **Dual PTT modes**: Toggle mode and Hold mode -- **Cross-platform**: iOS and Android support -- **Real-time communication** ready infrastructure -- **Modern UI** with state-aware visual feedback +**Goal**: Prove P2P voice connectivity works between phones over cellular networks. -### Backend Server -- **RESTful API** built with Go -- **WebRTC signaling** support (planned) -- **Room management** for group communications -- **OpenAPI/Swagger** documentation +### Test Matrix +- [ ] Android <-> Android (same WiFi) +- [ ] Android <-> Android (cellular) +- [ ] iOS <-> Android +- [ ] iOS <-> iOS +- [ ] Multicast scenarios (3+ devices) + +### Components -## 🚀 Quick Start +| Component | Status | Description | +|-----------|--------|-------------| +| Signaling Service | Implemented | WebSocket server for WebRTC peer coordination | +| Flutter Signaling Client | Implemented | WebSocket client for room/peer management | +| Flutter WebRTC Service | Implemented | Peer connection and audio stream handling | +| PTT Integration | Pending | Connect PTT button events to WebRTC audio | +| NAT Traversal | Pending | STUN/TURN configuration for cellular networks | + +## Quick Start ### Prerequisites -- **Flutter SDK** >= 3.2.0 +- **Flutter SDK** >= 3.16.0 - **Go** >= 1.21 -- **Node.js** >= 18 (for tooling) +- **Docker** (optional, for containerized development) + +### Run Signaling Server (Local) + +```bash +cd packages/services/signaling +go run ./cmd/main.go +``` + +The server starts on `http://localhost:8080` with these endpoints: +- `ws://localhost:8080/ws` - WebSocket signaling +- `GET /health` - Health check +- `GET /stats` - Connection statistics + +### Run with Docker + +```bash +cd packages/infra +docker-compose up +``` ### Mobile App Development + ```bash cd packages/mobile flutter pub get flutter run ``` -### Backend Development -```bash -cd packages/server -go mod tidy -go run main.go +## Signaling Service + +The signaling service handles WebRTC peer coordination via WebSocket. + +### WebSocket Messages + +| Type | Direction | Purpose | +|------|-----------|---------| +| `join_room` | Client -> Server | Join a signaling room | +| `leave_room` | Client -> Server | Leave current room | +| `peers` | Server -> Client | List of online peers | +| `peer_joined` | Server -> Client | New peer came online | +| `peer_left` | Server -> Client | Peer went offline | +| `offer` | Bidirectional | WebRTC SDP offer | +| `answer` | Bidirectional | WebRTC SDP answer | +| `candidate` | Bidirectional | ICE candidate | +| `ptt_start` | Client -> Server | Started transmitting | +| `ptt_end` | Client -> Server | Stopped transmitting | +| `peer_talking` | Server -> Client | Peer is transmitting | + +### Connection Example (Dart) + +```dart +import 'package:app/services/signaling_client.dart'; +import 'package:app/services/webrtc_service.dart'; + +// Create signaling client +final signaling = SignalingClient( + serverUrl: 'ws://localhost:8080', + userId: 'user123', + deviceInfo: 'Android', +); + +// Create WebRTC service +final webrtc = WebRTCService(signaling: signaling); + +// Connect and join room +await signaling.connect(); +await webrtc.initializeLocalStream(); +signaling.joinRoom('default'); + +// Handle remote audio +webrtc.onRemoteStream = (peerId, stream) { + // Play remote audio +}; ``` -## 🛠️ Development +## Project Structure + +### Signaling Service (`packages/services/signaling/`) + +``` +signaling/ +├── cmd/main.go # Entry point +├── internal/ +│ ├── config/config.go # Configuration +│ └── websocket/ +│ ├── hub.go # Room/connection management +│ ├── client.go # WebSocket client handling +│ └── messages.go # Message types +├── Dockerfile +├── go.mod +└── go.sum +``` -### Project Status -- ✅ **Flutter Mobile App**: Core PTT functionality implemented -- ✅ **Android Support**: Full Bluetooth headset button capture -- ⚠️ **iOS Support**: UI complete, button capture limited by system restrictions -- 🚧 **Backend API**: Basic structure in place, needs WebRTC integration -- 📋 **Documentation**: In progress +### Flutter Services (`packages/mobile/lib/services/`) -### Known Limitations -- **iOS**: Media button capture restricted by Apple's security policies -- **Long Press**: Triggers system voice assistants on both platforms -- **Recommendation**: Focus on Android deployment for full functionality +``` +services/ +├── ptt_service.dart # PTT button handling +├── signaling_client.dart # WebSocket signaling +└── webrtc_service.dart # WebRTC peer connections +``` -## 📚 Documentation +## Features -- [Mobile App Documentation](./packages/mobile/README.md) -- [Backend API Documentation](./packages/server/README.md) -- [Architecture Overview](./docs/architecture.md) -- [Development Guide](./docs/development.md) +### Mobile App +- **Push-to-Talk (PTT)** functionality with Bluetooth headset support +- **Dual PTT modes**: Toggle mode and Hold mode +- **Cross-platform**: iOS and Android support +- **WebRTC audio**: P2P voice communication +- **Modern UI** with state-aware visual feedback + +### Signaling Service +- **WebSocket server** for real-time peer coordination +- **Room management** for group communications +- **Stateless design** ready for horizontal scaling +- **Health checks** for container orchestration + +## Development -## 🧪 Testing +### Running Tests -Run all tests: ```bash # Mobile tests cd packages/mobile && flutter test -# Backend tests -cd packages/server && go test ./... +# Signaling service tests +cd packages/services/signaling && go test ./... # Integration tests ./scripts/test-all.sh ``` -## 🚢 Deployment +### Building for Production -### CI/CD -- **GitHub Actions** for automated testing and builds -- **Flutter**: Automated builds for iOS and Android -- **Go**: Automated testing and Docker builds -- **Quality Gates**: Linting, testing, and security checks - -### Production ```bash +# Build signaling service Docker image +cd packages/services/signaling +docker build -t peloton-signaling . + # Build mobile apps -./scripts/build-mobile.sh +cd packages/mobile +flutter build apk --release +flutter build ios --release +``` + +## Deployment + +### Local Development (Docker Compose) + +```bash +cd packages/infra +docker-compose up -d +``` -# Deploy backend -./scripts/deploy-server.sh +### Kubernetes (k3s) + +```bash +kubectl apply -f packages/infra/k3s/signaling-deployment.yaml ``` -## 🤝 Contributing +## Next Steps + +1. **Create Call UI** - Build a screen that uses SignalingClient and WebRTCService +2. **Test Local Connectivity** - Verify Android <-> Android works on same WiFi +3. **Add TURN Server** - Configure coturn for NAT traversal +4. **Test Cellular** - Validate connectivity over mobile networks +5. **Integrate PTT** - Connect button events to WebRTC mute/unmute + +## Known Limitations + +- **iOS**: Long-press triggers Siri (iOS system restriction) +- **iOS Workaround**: Use toggle mode with single-press +- **Android**: Long-press voice assistant prevention implemented +- **NAT Traversal**: Direct P2P may fail without TURN server on cellular + +## Documentation + +- [Mobile App Documentation](./packages/mobile/README.md) +- [Signaling Service](./packages/services/signaling/) +- [Bluetooth PTT Implementation](./docs/bluetooth-ptt-implementation.md) +- [Testing Guide](./TESTING.md) +- [Architecture Overview](./docs/architecture.md) + +## Contributing 1. **Fork** the repository 2. **Create** a feature branch: `git checkout -b feature/amazing-feature` @@ -117,16 +244,10 @@ cd packages/server && go test ./... - **Documentation**: Update docs for all public APIs - **Linting**: All code must pass linting checks -## 📄 License +## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -## 🏆 Acknowledgments - -- **Flutter Team** for the excellent cross-platform framework -- **WebRTC Community** for real-time communication protocols -- **Open Source Contributors** who make projects like this possible - --- -**Built with ❤️ for better group communication** \ No newline at end of file +**Built with care for better group communication** diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..af76919 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,77 @@ +# Roadmap + +## Status: the headset-button blocker is solved + +For a long time this project was stalled on "can we reliably capture Bluetooth headset +play/pause button presses for push-to-talk." As of the `feat/configurable-ptt-buttons` +branch, the answer is yes on both platforms: + +- **Android**: `PttMediaSessionService.kt` is a Media3 `MediaSessionService` running as a + foreground service. It owns the media session and receives BT play/pause/headsethook + key events via `MediaSession.Callback.onMediaButtonEvent`, even when the app is + backgrounded or the screen is off. Events go `PttEventBus` → `MainActivity` → + MethodChannel → `ptt_service.dart`. Volume buttons are captured separately in the + activity (`onKeyDown` / `onKeyUp`), since they aren't routed through MediaSession. +- **iOS**: `PTTSystemManager.swift` integrates Apple's **PushToTalk framework** (iOS 16+), + which is the officially supported way to get background transmit and headset-button + events (`setAccessoryButtonEventsEnabled(true)`). It's offered as the `systemPTT` + button option alongside the existing `MPRemoteCommandCenter`-based handling in + `AppDelegate.swift` (used for the `headsetPlayPause` / `headsetNext` / `headsetPrevious` + options). Recommend `systemPTT` as the default headset option going forward — it gets + background operation and proper accessory event routing that MPRemoteCommandCenter + never had. + +See `docs/bluetooth-ptt-implementation.md` for the full technical writeup. + +## What's left — protocol limits, not missing code + +These are Bluetooth/AVRCP protocol characteristics, confirmed against Android and Apple +documentation and how other PTT apps (Zello, ESChat) handle the same constraints. They +are not bugs to "fix" in application code: + +1. **Hold-to-talk on headset play/pause is unreliable** (observed: button stayed "red" + for ~950ms after release before flashing green). Most headset firmware buffers the + button to disambiguate single/double/long press, so press+release arrive together at + physical release; Android can also suppress `ACTION_UP` for BT devices entirely. The + app already force-switches `playPause` to toggle mode (`ptt_service.dart:132-138`) — + this is correct and matches industry practice. **No further action needed.** +2. **Headset volume up/down buttons aren't captured** (only the phone's physical volume + keys are). With AVRCP absolute volume, the headset sends `SET_ABSOLUTE_VOLUME` + directly to the audio system — no `KeyEvent` ever reaches the app. +3. **Phone button vs. headset button are distinguishable in code today** — both paths + converge on `handleKeyEventForPTT`, so no UX difference; low-priority cleanup only. + +## Next steps + +### 1. Volume-button PTT via `VolumeProvider` (Android) — next up +Attach a [`VolumeProvider`](https://developer.android.com/reference/android/media/VolumeProvider) +to `PttMediaSessionService`'s media session to receive discrete headset volume up/down +callbacks. This unlocks **toggle-mode** PTT on headset volume buttons (not hold — AVRCP +only ever gives discrete steps, never down/up pairs). + +- Owner: mobile/Android +- Files: `PttMediaSessionService.kt`, `PttPlayer.kt` +- Acceptance: pressing headset volume down/up while `PTTButton.volume` selected toggles + PTT state; existing phone-hardware volume button path is unaffected. + +### 2. Wire PTT state to WebRTC audio (currently POC-only) +`ptt_service.dart` currently drives `RecorderService` (local record + local playback). +The WebRTC signaling/service layer already exists (`signaling_client.dart`, +`webrtc_service.dart`, call screen). Replace the local-record POC path with: PTT press → +unmute/enable local audio track → WebRTC peer connection; PTT release → mute/disable +track. This is the core remaining MVP gap per `README.md`'s test matrix. + +### 3. Default iOS headset button to `systemPTT` +Make `systemPTT` the recommended/default option for headset play/pause on iOS 16+, with +`headsetPlayPause` (MPRemoteCommandCenter) retained only as an iOS <16 fallback. + +### 4. Config persistence +`PTTConfiguration` doesn't survive app restart yet (listed in +`PTT_IMPLEMENTATION_SUMMARY.md`). SharedPreferences (Android) / UserDefaults (iOS) via a +Flutter plugin (e.g. `shared_preferences`). + +### 5. (Later / premium path) Dedicated BLE PTT buttons +For riders who want genuine press-and-hold, a handlebar-mounted BLE PTT button (the +Zello/ESChat hardware ecosystem) delivers reliable discrete down/up over BLE GATT, +sidestepping AVRCP entirely. Worth a spike once WebRTC audio is wired up and the MVP +test matrix in `README.md` is passing. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..08dbdb3 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,202 @@ +# Testing Guide: Bluetooth PTT Long-Press Fix + +## What Changed + +We've implemented a fix to prevent the voice assistant (Google Assistant/Siri) from launching when you long-press the Bluetooth headset button. + +### Android Implementation ✅ +- **Added**: `dispatchKeyEvent()` override in MainActivity.kt +- **Effect**: Intercepts media button events BEFORE system voice assistant +- **Result**: Long-press is consumed and won't trigger Google Assistant + +### iOS Implementation ⚠️ +- **Limitation**: iOS does not allow apps to override Siri long-press +- **Recommendation**: Use toggle mode with single-press only +- **Added**: Documentation comments explaining the limitation + +## Quick Test Steps + +### Prerequisites +- Physical Android or iOS device (Bluetooth doesn't work properly in emulators) +- Bluetooth headset/earbuds with media buttons +- Pair and connect the headset to your device + +### Build and Deploy + +```bash +cd packages/mobile + +# For Android +flutter run --release + +# For iOS +flutter run --release +``` + +### Test Scenarios + +#### Android Testing + +1. **Test 1: Single Press (Toggle Mode)** + - Press the headset button once quickly + - **Expected**: PTT should activate/deactivate + - **Check**: UI should show recording state change + +2. **Test 2: Long Press (Toggle Mode)** + - Press and hold the headset button for 1+ seconds + - **Expected**: PTT activates on initial press, Google Assistant should NOT launch + - **Check**: No Google Assistant popup + +3. **Test 3: Hold Mode** + - Switch to Hold mode in the app + - Press and hold the button + - **Expected**: Recording while held, stops on release, no Google Assistant + - **Check**: Recording follows press/release, no assistant + +4. **Test 4: Rapid Presses** + - Press the button multiple times quickly + - **Expected**: Should be debounced (300ms interval) + - **Check**: Only registers presses spaced 300ms+ apart + +#### iOS Testing + +1. **Test 1: Single Press (Toggle Mode)** + - Press the headset button once quickly + - **Expected**: PTT should activate/deactivate + - **Check**: UI shows recording state + +2. **Test 2: Long Press - Known Limitation** + - Press and hold the headset button for 1+ seconds + - **Expected**: Siri WILL launch (this is normal on iOS) + - **Check**: This is expected behavior due to iOS restrictions + +3. **Test 3: Quick Toggle Usage** + - Use only quick single presses + - **Expected**: Reliable toggle PTT without triggering Siri + - **Check**: Works well when avoiding long presses + +### Check Logs + +#### Android +```bash +# Connect device via USB +adb logcat | grep PTT + +# Look for these messages: +# "dispatchKeyEvent: keyCode=XXX, action=XXX" +# "Long press detected (repeat=X) - consuming to prevent voice assistant" +# "Starting recording (toggle/hold mode)" +# "Stopping recording (toggle/hold mode)" +``` + +#### iOS +```bash +# View in Xcode +# Open Xcode → Window → Devices and Simulators +# Select your device → View Device Logs +# Filter for: "PTT" + +# Look for: +# "Toggle play/pause command received" +# "Starting recording (toggle mode)" +# "Stopping recording (toggle mode)" +``` + +## Expected Results + +### ✅ Android Success Criteria +- [x] Single press activates/deactivates PTT +- [x] Long press does NOT trigger Google Assistant +- [x] Hold mode works correctly (press/release) +- [x] Toggle mode works correctly +- [x] Logs show "Long press detected - consuming" message + +### ⚠️ iOS Expected Behavior +- [x] Single press activates/deactivates PTT (toggle mode) +- [x] Long press DOES trigger Siri (cannot be prevented) +- [x] Toggle mode recommended for iOS users +- [x] Hold mode not recommended on iOS + +## Troubleshooting + +### Issue: Button presses not detected at all + +**Android:** +```bash +# Check if app has permissions +adb shell dumpsys package com.example.app | grep permission + +# Reinstall app to ensure permissions requested +flutter clean +flutter run --release +``` + +**iOS:** +- Ensure app is in foreground +- Check Bluetooth connection in Settings +- Verify audio routing to headset + +### Issue: Google Assistant still launches (Android) + +**Possible causes:** +1. App not in foreground +2. OEM-specific Bluetooth stack behavior +3. Specific headset sends non-standard key codes + +**Debug:** +```bash +# Check what key codes your headset sends +adb logcat | grep "keyCode=" +``` + +### Issue: No audio/recording functionality + +This test focuses on **button capture only**. Full audio recording/playback requires: +- Microphone permissions granted +- Audio recording service implemented +- Proper audio routing setup + +## Next Steps After Testing + +1. **If button capture works**: + - ✅ Implement actual audio recording + - ✅ Add WebRTC for real-time communication + - ✅ Implement room/session management + +2. **If issues found**: + - 📋 Document specific device/headset model + - 📋 Collect full logs + - 📋 Note Android/iOS version + - 📋 Share findings for further debugging + +## Quick Reference + +### Files Modified +- `packages/mobile/android/app/src/main/kotlin/com/example/app/MainActivity.kt` - Key event interception +- `packages/mobile/android/app/src/main/AndroidManifest.xml` - Added permission +- `packages/mobile/ios/Runner/AppDelegate.swift` - Documentation comments +- `docs/bluetooth-ptt-implementation.md` - Full technical documentation + +### Key Code Changes +- **Android**: Added `dispatchKeyEvent()` override with long-press detection +- **Android**: Long-press events (repeatCount > 0) are consumed +- **Android**: Added MODIFY_AUDIO_SETTINGS permission +- **iOS**: Added documentation about Siri limitation + +--- + +**Test Date**: _________________ +**Device Model**: _________________ +**Android/iOS Version**: _________________ +**Headset Model**: _________________ + +**Results**: +- [ ] Single press works +- [ ] Long press handled correctly (Android) / Triggers Siri (iOS expected) +- [ ] Toggle mode works +- [ ] Hold mode works (Android only) + +**Notes**: +_________________________________________________________________ +_________________________________________________________________ +_________________________________________________________________ diff --git a/VOICE_ASSISTANT_FIX.md b/VOICE_ASSISTANT_FIX.md new file mode 100644 index 0000000..377a5bf --- /dev/null +++ b/VOICE_ASSISTANT_FIX.md @@ -0,0 +1,154 @@ +# Voice Assistant Long-Press Fix - Summary + +## The Problem You Reported +Long-pressing the Bluetooth headset play/pause button was triggering Google Assistant/Siri instead of your PTT functionality. + +## The Solution + +### ✅ Android: FIXED +We've implemented a `dispatchKeyEvent()` override that intercepts media button events **before** the system voice assistant handler. + +**How it works:** +1. All media button events go through `dispatchKeyEvent()` first +2. We detect long-press by checking `event.repeatCount > 0` +3. We consume the long-press events (return `true`) to prevent propagation +4. This stops Google Assistant from ever seeing the long-press event + +**Code location:** `packages/mobile/android/app/src/main/kotlin/com/example/app/MainActivity.kt:254-280` + +### ⚠️ iOS: CANNOT FIX (System Limitation) +Unfortunately, iOS does not provide any API to override Siri activation on long-press. This is a deliberate security/accessibility feature by Apple. + +**Why it can't be fixed:** +- Long-press → Siri is handled at the CoreAudio/Bluetooth stack level +- Apps only receive events via `MPRemoteCommandCenter` AFTER system processing +- No API exists to intercept or consume these events before Siri + +**Workaround for iOS users:** +- Use **toggle mode** exclusively +- Train users to use quick single-press only +- Avoid holding the button + +## Ready to Test + +### Quick Test (Android) + +```bash +cd packages/mobile +flutter clean +flutter run --release +``` + +1. Connect your Bluetooth headset +2. Open the app +3. **Long-press** the headset button (hold for 1-2 seconds) +4. **Expected**: PTT activates, Google Assistant does NOT launch ✅ + +### Quick Test (iOS) + +```bash +cd packages/mobile +flutter run --release +``` + +1. Connect your Bluetooth headset +2. Open the app +3. **Single-press** the headset button quickly +4. **Expected**: PTT toggles on/off ✅ +5. **Long-press** test +6. **Expected**: Siri WILL launch (this is normal and cannot be prevented) + +## Files Changed + +1. **MainActivity.kt** - Added `dispatchKeyEvent()` and `handleKeyEventForPTT()` +2. **AndroidManifest.xml** - Added `MODIFY_AUDIO_SETTINGS` permission +3. **AppDelegate.swift** - Added documentation comments about iOS limitation +4. **Documentation** - Created comprehensive guides + +## What to Check During Testing + +### Android Checklist +- [ ] Single press toggles PTT correctly +- [ ] Long press (1+ seconds) does NOT trigger Google Assistant +- [ ] Hold mode: Press/release works correctly +- [ ] Toggle mode: Single press toggles state +- [ ] Logs show: "Long press detected - consuming to prevent voice assistant" + +### iOS Checklist +- [ ] Single press toggles PTT correctly +- [ ] Long press triggers Siri (expected limitation) +- [ ] Toggle mode works reliably with single-press +- [ ] User understands to avoid long-press + +## Viewing Debug Logs + +### Android +```bash +adb logcat | grep PTT +``` + +Look for: +- `dispatchKeyEvent: keyCode=XXX, action=XXX, flags=XXX` +- `Long press detected (repeat=X) - consuming to prevent voice assistant` +- `Starting recording (toggle/hold mode)` +- `Stopping recording (toggle/hold mode)` + +### iOS +In Xcode console, look for: +- `Toggle play/pause command received` +- `Starting recording (toggle mode)` +- `Stopping recording (toggle mode)` + +## MVP Readiness + +After successful testing, you'll have: + +### ✅ Working Features +- [x] Bluetooth headset button capture +- [x] Toggle and Hold PTT modes +- [x] Android long-press voice assistant prevention +- [x] iOS single-press PTT (with documented limitations) +- [x] Dual-mode support (toggle/hold) +- [x] Debouncing for accidental double-press + +### 🚧 Still Needed for Full MVP +- [ ] Actual audio recording implementation +- [ ] Audio playback to other users +- [ ] WebRTC or UDP networking for real-time audio +- [ ] Room/session management +- [ ] Backend server integration +- [ ] User authentication +- [ ] Connection status indicators + +## Next Steps After Testing + +1. **Test on your physical device** with Bluetooth headset +2. **Report findings**: + - Does long-press still trigger Google Assistant? (should NOT on Android) + - Any device-specific issues? + - Logs showing unexpected behavior? + +3. **If successful**, proceed to: + - Implement audio recording service + - Set up WebRTC for real-time audio streaming + - Connect to backend server + - Build room management UI + +4. **If issues found**, share: + - Device model and Android/iOS version + - Bluetooth headset model + - Full logcat/Xcode console output + - Specific behavior observed + +## Technical Deep Dive + +For full technical details, see: +- [Bluetooth PTT Implementation Guide](./docs/bluetooth-ptt-implementation.md) +- [Testing Guide](./TESTING.md) + +--- + +**Status**: ✅ Ready for device testing +**Last Updated**: 2025-10-06 +**Android**: Long-press fix implemented +**iOS**: Documented workaround (toggle mode only) diff --git a/WIRELESS_DEBUG_SETUP.md b/WIRELESS_DEBUG_SETUP.md new file mode 100644 index 0000000..3d96c80 --- /dev/null +++ b/WIRELESS_DEBUG_SETUP.md @@ -0,0 +1,412 @@ +# Android Wireless Debugging Setup Guide + +## Quick Setup (3 Methods) + +You can use **any** of these methods. Method 1 is recommended for first-time setup. + +--- + +## Method 1: Wireless Debugging (Android 11+) - RECOMMENDED + +### Step 1: Enable Developer Options on Your Android Device + +1. **Open Settings** on your Android device +2. **Scroll to "About phone"** (or "About device") +3. **Tap "Build number" 7 times** rapidly +4. You'll see a message: "You are now a developer!" + +### Step 2: Enable Wireless Debugging + +1. **Go back to Settings** +2. **Tap "System"** → **"Developer options"** +3. **Toggle ON "Developer options"** (at the top) +4. **Scroll down and toggle ON "Wireless debugging"** +5. **Tap "Wireless debugging"** to enter the submenu + +### Step 3: Pair Your Device + +**On Your Android Device:** +1. In "Wireless debugging", tap **"Pair device with pairing code"** +2. You'll see: + - **6-digit pairing code** (e.g., 123456) + - **IP address and port** (e.g., 192.168.1.100:37853) + +**On Your Mac (Terminal):** +```bash +# Add ADB to your PATH for this session +export PATH="$PATH:/Users/romdj/Library/Android/sdk/platform-tools" + +# Pair with your device (replace with YOUR IP and port from device screen) +adb pair 192.168.1.100:37853 + +# When prompted, enter the 6-digit pairing code from your device +``` + +**Expected output:** +``` +Enter pairing code: 123456 +Successfully paired to 192.168.1.100:37853 [guid=adb-ABCD1234-XYZ789] +``` + +### Step 4: Connect to Your Device + +**On Your Android Device:** +1. Go back to "Wireless debugging" main screen +2. Note the **IP address & port** shown (different from pairing port!) + - Example: `192.168.1.100:40587` + +**On Your Mac:** +```bash +# Connect to device (use the IP:port from "Wireless debugging" screen, NOT pairing) +adb connect 192.168.1.100:40587 +``` + +**Expected output:** +``` +connected to 192.168.1.100:40587 +``` + +### Step 5: Verify Connection + +```bash +adb devices +``` + +**Expected output:** +``` +List of devices attached +192.168.1.100:40587 device +``` + +### Step 6: Test with Flutter + +```bash +flutter devices +``` + +**Expected output:** +``` +Found 3 connected devices: + sdk gphone64 arm64 (mobile) • 192.168.1.100:40587 • android-arm64 • Android 13 (API 33) + macOS (desktop) • macos • darwin-arm64 • macOS 26.1 25B78 darwin-arm64 + Chrome (web) • chrome • web-javascript • Google Chrome 142.0.7444.176 +``` + +✅ **Success!** Your device is now wirelessly connected. + +--- + +## Method 2: USB Debugging First, Then Switch to Wireless + +### Step 1: Connect via USB + +1. **Connect your Android device to Mac via USB cable** +2. **Enable USB debugging:** + - Settings → Developer options → USB debugging → Toggle ON +3. **On device:** Tap "Allow" when prompted "Allow USB debugging?" + +### Step 2: Verify USB Connection + +```bash +export PATH="$PATH:/Users/romdj/Library/Android/sdk/platform-tools" +adb devices +``` + +Should show: +``` +List of devices attached +ABC123XYZ device +``` + +### Step 3: Enable TCP/IP Mode + +```bash +# Switch ADB to wireless mode on port 5555 +adb tcpip 5555 +``` + +### Step 4: Get Device IP Address + +**On Your Android Device:** +- Settings → About phone → Status → IP address +- **OR** Settings → Network & Internet → Wi-Fi → Tap connected network → IP address + +**OR on Mac:** +```bash +adb shell ip route +``` +Look for output like: `192.168.1.100 dev wlan0` + +### Step 5: Disconnect USB and Connect Wirelessly + +```bash +# Disconnect USB cable physically + +# Connect wirelessly (replace with your device's IP) +adb connect 192.168.1.100:5555 +``` + +### Step 6: Verify + +```bash +adb devices +flutter devices +``` + +✅ **Success!** Now wireless. + +--- + +## Method 3: QR Code Pairing (Some Android Devices) + +### Step 1: Enable Wireless Debugging + +Same as Method 1 - Settings → Developer options → Wireless debugging → ON + +### Step 2: Use QR Code + +1. **On device:** Tap "Pair device with QR code" +2. **On Mac:** Generate pairing QR code: + ```bash + # Note: This requires additional setup and may not be available + # Stick with Method 1 or 2 for simplicity + ``` + +--- + +## Troubleshooting + +### Issue: "command not found: adb" + +**Solution:** Add ADB to your PATH permanently + +```bash +# Edit your shell config file +nano ~/.zshrc + +# Add this line at the end: +export PATH="$PATH:/Users/romdj/Library/Android/sdk/platform-tools" + +# Save (Ctrl+O, Enter, Ctrl+X) + +# Reload config +source ~/.zshrc + +# Test +adb devices +``` + +### Issue: "No devices found" + +**Checklist:** +- [ ] Both Mac and Android on **same Wi-Fi network** +- [ ] Wireless debugging is **enabled** on device +- [ ] You used the **correct IP address and port** +- [ ] Firewall isn't blocking connection + +**Solution:** +```bash +# 1. Restart ADB server +adb kill-server +adb start-server + +# 2. Try connecting again +adb connect : +``` + +### Issue: "device offline" + +**Solution:** +```bash +# Disconnect and reconnect +adb disconnect +adb connect : +``` + +### Issue: Connection keeps dropping + +**Reasons:** +- Device goes to sleep (screen off too long) +- Wi-Fi power saving enabled +- Device switches Wi-Fi networks + +**Solution:** +```bash +# Keep device screen on while developing +# Settings → Developer options → Stay awake → Toggle ON + +# OR re-connect when it drops +adb connect : +``` + +### Issue: "Wireless debugging" option not available + +**Reason:** Android version < 11 + +**Solution:** Use Method 2 (USB first, then wireless via `adb tcpip 5555`) + +### Issue: Can't find IP address + +**Solutions:** +```bash +# Method 1: Via ADB shell (if USB connected) +adb shell ip route + +# Method 2: On device +# Settings → About phone → Status → IP address + +# Method 3: Via network settings +# Settings → Network & Internet → Wi-Fi → Tap connected network → Advanced → IP address +``` + +--- + +## Permanent ADB Path Setup (Recommended) + +To avoid typing the PATH export every time: + +```bash +# Open your shell config +nano ~/.zshrc + +# Add this line at the end: +export PATH="$PATH:/Users/romdj/Library/Android/sdk/platform-tools" + +# Save and exit (Ctrl+O, Enter, Ctrl+X) + +# Reload +source ~/.zshrc +``` + +Now `adb` will work in all new terminal sessions! + +--- + +## Quick Reference Commands + +```bash +# Check connected devices +adb devices +flutter devices + +# Pair device (Android 11+) +adb pair : + +# Connect wirelessly +adb connect : + +# Disconnect +adb disconnect + +# Switch to wireless mode (USB connected) +adb tcpip 5555 + +# Switch back to USB mode +adb usb + +# Restart ADB server +adb kill-server +adb start-server + +# View device logs (useful for debugging PTT) +adb logcat | grep PTT +``` + +--- + +## Next Steps After Connection + +Once your device shows in `flutter devices`: + +```bash +# Deploy and run the PTT app +cd packages/mobile +flutter run + +# Or specify the device explicitly +flutter run --device-id= + +# For release builds +flutter run --release +``` + +--- + +## Testing PTT Features + +Once the app is running: + +1. **Open logcat in another terminal:** + ```bash + adb logcat | grep PTT + ``` + +2. **Test volume buttons:** + - Press Volume Down → Should see "PTT State changed to: active" + - Press again → Should see "PTT State changed to: idle" + +3. **Test long-press prevention:** + - Long-press Volume Down (>1s) + - Should see: "Long press detected - consuming to prevent voice assistant" + - Google Assistant should NOT activate ✅ + +4. **Follow testing guides:** + - `PHASE1_VOLUME_BUTTON_TESTING.md` + - `PHASE2_ONSCREEN_BUTTON_TESTING.md` + - `PHASE3_SETTINGS_UI_TESTING.md` + +--- + +## Common Device-Specific Notes + +### Samsung Devices +- Developer options often under "Developer options" directly in main Settings + +### Pixel Devices +- Wireless debugging very reliable +- Settings → System → Developer options → Wireless debugging + +### OnePlus/Oppo Devices +- May need to enable OEM unlocking +- Settings → Developer options → OEM unlocking → Toggle ON + +--- + +## Summary - Choose Your Method + +| Method | Best For | Requirements | +|--------|---------|--------------| +| **Method 1** | Android 11+ | Wi-Fi, no cable needed after pairing | +| **Method 2** | Any Android | USB cable initially, then wireless | +| **Method 3** | QR support | Some devices only | + +**Recommended:** Start with **Method 1** if your device has Android 11+. + +--- + +## Quick Start (Copy-Paste) + +```bash +# 1. Add ADB to PATH (one-time setup) +echo 'export PATH="$PATH:/Users/romdj/Library/Android/sdk/platform-tools"' >> ~/.zshrc +source ~/.zshrc + +# 2. On device: Settings → Developer options → Wireless debugging → Pair device with pairing code + +# 3. Pair (replace IP:PORT and CODE with values from your device) +adb pair 192.168.1.100:XXXXX +# Enter pairing code when prompted + +# 4. Connect (use IP:PORT from main Wireless debugging screen) +adb connect 192.168.1.100:XXXXX + +# 5. Verify +adb devices +flutter devices + +# 6. Run app +cd packages/mobile +flutter run +``` + +**That's it!** 🚀 diff --git a/cicd_plan.md b/cicd_plan.md new file mode 100644 index 0000000..4465e91 --- /dev/null +++ b/cicd_plan.md @@ -0,0 +1,17 @@ +so one thing is that in the build step I'd like to see in parallel: +- All services builds +- All separate components (e.g. front-end component/folder) + +The next stage gate would be unit + component testing for all those same components +- All services unit tests + component tests (separate step) +- All additional separate components (e.g. front-end component/folder): unit tests ; component tests + +The next steps/gates would be shown in a common way since they are shared elements; each line is a separate gate +- Integration tests +- E2E tests +- Security audit + +Please rephrase that (long) request so I know we're on the same page then expose your plan on how you'd do this. + +Any AI Agents we could enable too in the gh pipeline? + diff --git a/docs/bluetooth-ptt-implementation.md b/docs/bluetooth-ptt-implementation.md new file mode 100644 index 0000000..c2d7555 --- /dev/null +++ b/docs/bluetooth-ptt-implementation.md @@ -0,0 +1,176 @@ +# Bluetooth Headset Push-to-Talk Implementation + +## Overview + +This document describes how Bluetooth headset button capture works for Push-to-Talk +(PTT) in Peloton Communicator, and what is and isn't achievable given Bluetooth AVRCP +and platform media-button constraints. **This is no longer an open problem** — both +platforms reliably capture headset button presses. See `ROADMAP.md` for the remaining +work (headset volume buttons, WebRTC wiring). + +## Android Implementation + +### Foreground MediaSessionService + +**File**: `packages/mobile/android/app/src/main/kotlin/com/example/app/PttMediaSessionService.kt` + +A dedicated `MediaSessionService` (AndroidX Media3) owns the PTT media session and runs +as a foreground service, so it keeps receiving media button events even when the app is +backgrounded or the screen is off — this was the missing piece in earlier attempts that +only intercepted key events at the `Activity` level. + +```kotlin +class PttMediaSessionService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = PttPlayer() + mediaSession = MediaSession.Builder(this, player) + .setId("PelotonPTT") + .setCallback(PttSessionCallback()) + .build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + private class PttSessionCallback : MediaSession.Callback { + override fun onMediaButtonEvent( + session: MediaSession, + controllerInfo: MediaSession.ControllerInfo, + intent: Intent + ): Boolean { + val key = intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) ?: return false + return when (key.keyCode) { + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + PttEventBus.emit(key) + true + } + else -> false + } + } + } +} +``` + +`PttEventBus` (a simple in-process listener) forwards the raw `KeyEvent` to +`MainActivity`, which translates it into `pttPressed` / `pttReleased` MethodChannel +calls consumed by `ptt_service.dart`. `PttPlayer.kt` is a minimal `Player` stub the +`MediaSession` needs to exist and stay active. + +`MainActivity.kt` starts this service (`startForegroundService`) once RECORD_AUDIO / +BLUETOOTH_CONNECT / (Android 13+) POST_NOTIFICATIONS permissions are granted, and keeps +`onKeyDown` / `onKeyUp` overrides for **volume buttons only** — those are activity-scoped +and are not delivered through `MediaSession.Callback`. + +### Toggle vs. Hold mode + +`handleKeyEventForPTT` in `MainActivity.kt` implements both modes with a 300ms +double-press debounce. Critically, **`ptt_service.dart` force-switches the play/pause +button to toggle mode** (`setButton()`), because hold mode is not reliable for that +button — see "Known AVRCP Limitations" below. + +### Permissions + +```xml + + + + + +``` + +## iOS Implementation + +Two paths exist side by side, selected per the `button` setting in `PTTConfiguration`: + +### 1. `headsetPlayPause` / `headsetNext` / `headsetPrevious` — `MPRemoteCommandCenter` + +**File**: `packages/mobile/ios/Runner/AppDelegate.swift` (`setupRemoteCommandCenter`) + +Standard `MPRemoteCommandCenter` target registration. Works, but only while the app is +in the foreground, and — like every third-party iOS app — cannot intercept a long-press, +which the system routes to Siri before the app ever sees it. No API exists to change +this; it's a system-level restriction, not a bug in this codebase. + +### 2. `systemPTT` — Apple's PushToTalk framework (iOS 16+, recommended) + +**File**: `packages/mobile/ios/Runner/PTTSystemManager.swift` + +Apple shipped the [PushToTalk framework](https://developer.apple.com/documentation/pushtotalk) +in iOS 16 specifically to give third-party apps proper headset-button PTT, including +**background** transmit. `ptt_service.dart` calls `joinPTTChannel` when this button is +selected; `PTTSystemManager` then: + +```swift +manager.requestJoinChannel(channelUUID: uuid, descriptor: descriptor) { error in + manager.setAccessoryButtonEventsEnabled(true, channelUUID: uuid) { err in ... } +} +``` + +`setAccessoryButtonEventsEnabled(true)` tells the system to map Bluetooth accessory +media events onto the PTT channel's `didBeginTransmittingFrom` / `didEndTransmittingFrom` +delegate callbacks, which `PTTSystemManager` forwards to Flutter as `pttPressed` / +`pttReleased`. This is the officially supported mechanism — prefer it over +`MPRemoteCommandCenter` wherever iOS 16+ can be assumed (requires the +`com.apple.developer.push-to-talk` entitlement; see `Runner.entitlements`). + +Known quirk (from Apple's own developer forums): some A2DP head units send a "play" +event automatically on connect, which the framework will interpret as "begin +transmitting." This is inherent to how the framework maps generic media events onto PTT +semantics and isn't something the app can distinguish. + +## Known AVRCP / Media-Button Limitations + +These apply regardless of platform code quality — they're characteristics of Bluetooth +AVRCP and how OS media-button stacks work, confirmed against Android's own media3 issue +tracker and Apple's PushToTalk documentation: + +1. **Long-press disambiguation swallows the release event.** Headset firmware buffers + the button to tell single/double/long press apart, so the down+up pair is often only + emitted once, at physical release — not at physical press. This is why holding the + button appears to do nothing until you let go. **Mitigation**: use toggle mode for + play/pause (already the default/forced behavior). +2. **Headset volume buttons don't emit `KeyEvent`s at all** once AVRCP absolute volume + is negotiated (Android 6+) — the headset talks directly to the audio HAL via + `SET_ABSOLUTE_VOLUME`. **Mitigation (planned, see `ROADMAP.md`)**: register a + `VolumeProvider` on the media session to receive discrete volume-change callbacks; + this supports toggle mode only, since AVRCP never delivers a true down/up pair for + volume keys. +3. **True press-and-hold with a guaranteed down/up pair** requires bypassing AVRCP + media-button semantics entirely — a dedicated BLE PTT button (GATT characteristic + notifications, not AVRCP) as used by Zello/ESChat hardware accessories. Candidate for + a future "premium hardware" path; see `ROADMAP.md`. + +## Testing + +**Physical devices required** — emulators do not support Bluetooth headset button +delivery. See `TESTING.md` and the `PHASE*_TESTING.md` guides for detailed scripts. + +```bash +# Android +adb logcat | grep PTT + +# iOS +# Xcode console; filter for "PTT" +``` + +## References + +- [Android MediaSession](https://developer.android.com/reference/android/media/session/MediaSession) +- [Media3 MediaSessionService](https://developer.android.com/media/media3/session/background-playback) +- [androidx/media#159 — first BT pause press mishandled](https://github.com/androidx/media/issues/159) +- [Apple PushToTalk framework](https://developer.apple.com/documentation/PushToTalk) +- [Creating a Push to Talk app](https://developer.apple.com/documentation/pushtotalk/creating-a-push-to-talk-app) +- [WWDC22: Enhance voice communication with Push to Talk](https://developer.apple.com/videos/play/wwdc2022/10117/) +- [Android VolumeProvider](https://developer.android.com/reference/android/media/VolumeProvider) +- [AOSP AvrcpVolumeManager](https://android.googlesource.com/platform/packages/apps/Bluetooth/+/master/src/com/android/bluetooth/avrcp/AvrcpVolumeManager.java) + +--- + +**Last Updated**: 2026-07-23 +**Platforms**: Android 12+, iOS 16+ (for `systemPTT`; iOS 14+ for `MPRemoteCommandCenter` fallback) +**Status**: Headset button capture solved and in use; volume-button PTT and WebRTC wiring open (see `ROADMAP.md`) diff --git a/docs/ci-pipeline.md b/docs/ci-pipeline.md new file mode 100644 index 0000000..b94e838 --- /dev/null +++ b/docs/ci-pipeline.md @@ -0,0 +1,93 @@ +# CI Pipeline + +One workflow, `.github/workflows/ci.yml`, gates every change. It runs on pushes to `main` +and to `feat/**`, `fix/**`, `hotfix/**`, `chore/**`, `ci/**` branches, and on pull +requests into `main`. Newer runs on the same branch cancel older ones. + +## Stages + +```mermaid +flowchart LR + QM[Quality • mobile] --> UM[Build & Unit • mobile] + QM --> BA[Build & Unit • android] + QM --> BI[Build & Unit • ios] + QS[Quality • signaling] --> US[Build & Unit • signaling] + US --> IS[Integration • signaling] + UM --> CQ[Security • CodeQL] + US --> CQ + US --> GV[Security • govulncheck] + QM --> SS[Security • gitleaks] + QS --> SS + UM & BA & BI & IS & CQ & GV & SS --> ST[CI Status] +``` + +| Stage | Job | What it checks | Reproduce locally | +|---|---|---|---| +| 1. Quality | `Quality • mobile` | `dart format` clean, `flutter analyze --fatal-infos` | `cd packages/mobile && dart format --output=none --set-exit-if-changed lib test && flutter analyze --fatal-infos` | +| | `Quality • signaling` | `go mod verify`, `go.mod` tidy, `gofmt -s`, `go vet` (both build tags) | `cd packages/services/signaling && gofmt -s -l . && go vet ./... && go vet -tags=integration ./...` | +| 2. Build & Unit | `Build & Unit • mobile` | `flutter test --coverage` (coverage uploaded as an artifact) | `cd packages/mobile && flutter test` | +| | `Build & Unit • android` | `flutter build apk --debug`, which compiles the Kotlin PTT layer | `cd packages/mobile && flutter build apk --debug` (needs JDK 17) | +| | `Build & Unit • ios` | `flutter build ios --debug --no-codesign`, which compiles the Swift PushToTalk layer | `cd packages/mobile && flutter build ios --debug --no-codesign` (macOS) | +| | `Build & Unit • signaling` | `go build`, unit tests with `-race`, container image build | `cd packages/services/signaling && go test -race ./... && docker build .` | +| 3. Integration | `Integration • signaling` | Real WebSocket clients against the real handlers: join, peer list, offer/answer/ICE relay, PTT broadcast, room isolation, disconnect | `cd packages/services/signaling && go test -race -tags=integration ./cmd/...` | +| 4. Security | `Security • CodeQL (go / actions)` | Static analysis of the Go service and of the workflows themselves | GitHub only | +| | `Security • govulncheck` | Known vulnerabilities reachable from the signaling service | `govulncheck ./...` in `packages/services/signaling` | +| | `Security • gitleaks` | Secrets anywhere in git history | `gitleaks git --redact .` | +| 5. Aggregate | `CI Status` | Fails unless every job above succeeded | n/a | + +**`CI Status` is the only required status check on `main`.** New jobs automatically +become merge-blocking once they're added to its `needs:` list, so branch protection never +has to be edited when the pipeline grows. + +## Toolchain pins + +Set once in the workflow `env:` block: + +| Tool | Version | Keep in sync with | +|---|---|---| +| Flutter | `3.47.1` (Dart 3.13) | the version you develop with (`flutter --version`) | +| Go | `1.27.x` | `packages/services/signaling/Dockerfile` builder image | +| Java | `17` | `jvmTarget` / `JavaVersion` in `packages/mobile/android/app/build.gradle` | + +The previous pipeline derived Flutter from `pubspec.yaml`'s *lower bound* (`>=3.16.0`), +which installed a Dart SDK too old for current dependencies and failed at `pub get`. +Always pin explicitly. + +## Local hooks (lefthook) + +`lefthook.yml` runs the fast gates before code leaves your machine: + +- **pre-commit** (only on staged file types): `dart format`, `flutter analyze`, `gofmt`, `go vet` +- **pre-push**: `flutter test`, Go unit + integration tests + +Install once per clone: + +```bash +brew install lefthook +lefthook install +``` + +Don't bypass hooks with `--no-verify`. If a hook is wrong, fix the hook. + +## What is deliberately *not* automated + +- **Mobile end-to-end / headset testing.** Bluetooth headset buttons need physical + devices and vary per headset (AVRCP firmware differences). Use `TESTING.md` and the + Milestone A two-device checklist in `docs/superpowers/plans/2026-08-14-ptt-webrtc-mvp.md`. +- **CodeQL for Dart and Kotlin.** CodeQL doesn't support Dart. Kotlin analysis needs a + full Gradle build inside CodeQL; revisit if the native layer grows. +- **Legacy `packages/server`.** It only contains a placeholder and a skipped test; the + live backend is `packages/services/signaling`. + +## Troubleshooting + +| Failing job | Usual cause | Fix | +|---|---|---| +| Quality • mobile | Unformatted file or analyzer info | `dart format lib test`, then `dart fix --apply` | +| Quality • signaling | `go.mod` not tidy, or `gofmt` | `go mod tidy`, `gofmt -s -w .` | +| Build & Unit • android | Gradle/AGP vs Flutter version drift | Build locally with the pinned Flutter; update AGP/Gradle together | +| Build & Unit • ios | CocoaPods resolution | `cd ios && pod repo update && pod install` locally, commit `Podfile.lock` | +| Integration • signaling | Protocol change broke a relay path | Run the integration command above with `-v` | +| Security • govulncheck | New advisory in a dependency or the Go stdlib | Bump the module (`go get @`) or `GO_VERSION` + Dockerfile | +| Security • gitleaks | Secret committed | Rotate it first, then remove it. Only add to `.gitleaksignore` after review, with a comment saying why | +| CI Status | Any job above failed or was cancelled | Fix the named job; the log lists every job's result | diff --git a/docs/superpowers/plans/2026-08-14-ptt-webrtc-mvp.md b/docs/superpowers/plans/2026-08-14-ptt-webrtc-mvp.md new file mode 100644 index 0000000..a5126e1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-ptt-webrtc-mvp.md @@ -0,0 +1,973 @@ +# PTT → WebRTC 2-Device Walkie-Talkie MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire push-to-talk to a live WebRTC connection so two riders in the same club-BBB group hear each other in real time, replacing the local loopback recorder with mic mute-gating. + +**Architecture:** Introduce one coordinator, `RideSession`, that keeps a persistent WebRTC connection up (mic muted by default) and toggles the local audio track on `PTTService` state changes. A tiny club/group model maps the 7 fixed groups to isolated signaling rooms. A group-picker screen replaces the ad-hoc join dialog. The existing `WebRTCService`/`SignalingClient` are reused behind two narrow interfaces so `RideSession` is unit-testable without the platform. + +**Tech Stack:** Flutter (Dart), `flutter_webrtc`, `web_socket_channel`, `provider`; Go signaling server (unchanged this milestone); `flutter_test` with hand-written fakes (no mockito). + +## Global Constraints + +- Flutter SDK >= 3.16.0; Dart null-safety. +- Mobile package lives in `packages/mobile`; Dart package name is `app` (imports use `package:app/...`). +- Tests use `flutter_test` only — **no** mockito/mocktail; fakes are hand-written classes. +- State management is `provider` (`ChangeNotifierProvider` at app root, single instance). +- Walkie-talkie invariant: the local mic track is **muted by default** whenever connected; PTT only unmutes. +- Overlap is allowed (no one-talker lock). Group selection is **not** persisted across launches. +- Room-id convention: `":"`, e.g. `"BBB:A1"`. +- Do not delete `RecorderService` or its PTT wiring — it remains an optional self-test path. +- Run all commands from `packages/mobile`. Test command: `flutter test`. Analyze: `flutter analyze`. +- Commit after every task. Commit messages: no AI attribution/co-author trailers. + +--- + +## File Structure + +**New files:** +- `lib/models/riding_group.dart` — `RidingGroup`, `Club`, `roomIdFor`, `bbbClub` constant. +- `lib/services/voice_transport.dart` — `VoiceTransport` interface (implemented by `WebRTCService`). +- `lib/services/signaling_channel.dart` — `SignalingChannel` interface (implemented by `SignalingClient`). +- `lib/services/ride_session.dart` — the coordinator. +- `lib/ui/screens/group_picker_screen.dart` — 7-group picker + server URL. +- `test/models/riding_group_test.dart` +- `test/support/fakes.dart` — shared fakes (`FakeVoiceTransport`, `FakeSignaling`, `FakeRecorder`). +- `test/services/ride_session_test.dart` +- `test/ui/group_picker_screen_test.dart` +- `test/ui/call_screen_test.dart` + +**Modified files:** +- `lib/services/webrtc_service.dart` — add `implements VoiceTransport`. +- `lib/services/signaling_client.dart` — add `implements SignalingChannel` + `peerCount` getter. +- `lib/ui/screens/call_screen.dart` — delegate transmit to `RideSession`; remove build-time PTT side-effect; accept optional injected `transport`/`signaling` for testing. +- `lib/ui/screens/home_screen.dart` — the join action navigates to `GroupPickerScreen`. + +--- + +### Task 1: Club/group model + +**Files:** +- Create: `lib/models/riding_group.dart` +- Test: `test/models/riding_group_test.dart` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `class RidingGroup { final String id; final String name; const RidingGroup({required this.id, required this.name}); }` + - `class Club { final String id; final String name; final List groups; const Club({...}); String roomIdFor(RidingGroup group); }` + - `const Club bbbClub` with 7 groups: `A1, A2, A3, A4, B1, B2, B3`. + +- [ ] **Step 1: Write the failing test** + +```dart +// test/models/riding_group_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/models/riding_group.dart'; + +void main() { + group('bbbClub', () { + test('has club id BBB and exactly 7 groups A1..A4, B1..B3', () { + expect(bbbClub.id, 'BBB'); + expect(bbbClub.groups.map((g) => g.id).toList(), + ['A1', 'A2', 'A3', 'A4', 'B1', 'B2', 'B3']); + }); + + test('roomIdFor builds ":"', () { + final a1 = bbbClub.groups.first; + expect(bbbClub.roomIdFor(a1), 'BBB:A1'); + }); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/models/riding_group_test.dart` +Expected: FAIL — `Error: Not found: 'package:app/models/riding_group.dart'`. + +- [ ] **Step 3: Write minimal implementation** + +```dart +// lib/models/riding_group.dart + +/// A riding group within a club — the unit that maps 1:1 to a WebRTC room. +class RidingGroup { + final String id; + final String name; + const RidingGroup({required this.id, required this.name}); +} + +/// A club that owns a fixed set of riding groups. +class Club { + final String id; + final String name; + final List groups; + const Club({required this.id, required this.name, required this.groups}); + + /// Room id for [group], e.g. 'BBB:A1'. Distinct groups => isolated rooms. + String roomIdFor(RidingGroup group) => '$id:${group.id}'; +} + +/// The single MVP club: BBB with 7 fixed groups. +const Club bbbClub = Club( + id: 'BBB', + name: 'BBB', + groups: [ + RidingGroup(id: 'A1', name: 'A1'), + RidingGroup(id: 'A2', name: 'A2'), + RidingGroup(id: 'A3', name: 'A3'), + RidingGroup(id: 'A4', name: 'A4'), + RidingGroup(id: 'B1', name: 'B1'), + RidingGroup(id: 'B2', name: 'B2'), + RidingGroup(id: 'B3', name: 'B3'), + ], +); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/models/riding_group_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add lib/models/riding_group.dart test/models/riding_group_test.dart +git commit -m "feat(mobile): add club BBB / 7-group model with room-id mapping" +``` + +--- + +### Task 2: Transport + signaling interfaces + +Extract the two narrow interfaces `RideSession` depends on, and declare the existing concrete services as implementers. This is what lets Task 3 be tested without WebRTC/WebSocket platform code. + +**Files:** +- Create: `lib/services/voice_transport.dart` +- Create: `lib/services/signaling_channel.dart` +- Modify: `lib/services/webrtc_service.dart` (class declaration + import) +- Modify: `lib/services/signaling_client.dart` (class declaration + import + `peerCount`) +- Test: `test/services/ride_session_test.dart` (interface-satisfaction smoke test only in this task; expanded in Task 3) + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `abstract class VoiceTransport { Future initializeLocalStream(); void setMuted(bool muted); Future connectToAllPeers(); Future closeAllConnections(); Future disposeLocalStream(); }` + - `abstract class SignalingChannel implements Listenable { Future connect(); void joinRoom(String roomId); void leaveRoom(); void startPTT(); void endPTT(); Future disconnect(); int get peerCount; }` + - `WebRTCService implements VoiceTransport` + - `SignalingClient implements SignalingChannel` with `int get peerCount => _peers.length;` + +- [ ] **Step 1: Write the failing test** + +```dart +// test/services/ride_session_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/services/voice_transport.dart'; +import 'package:app/services/signaling_channel.dart'; +import 'package:app/services/webrtc_service.dart'; +import 'package:app/services/signaling_client.dart'; + +void main() { + test('concrete services satisfy the RideSession interfaces', () { + final SignalingChannel signaling = + SignalingClient(serverUrl: 'ws://localhost:8080', userId: 'u1'); + final VoiceTransport transport = + WebRTCService(signaling: signaling as SignalingClient); + + expect(signaling.peerCount, 0); + expect(transport, isA()); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/services/ride_session_test.dart` +Expected: FAIL — `Error: Not found: 'package:app/services/voice_transport.dart'`. + +- [ ] **Step 3: Write the interfaces** + +```dart +// lib/services/voice_transport.dart + +/// Minimal transport surface RideSession needs to bring up, gate, and route +/// audio. Implemented by WebRTCService; faked in tests. +abstract class VoiceTransport { + Future initializeLocalStream(); + void setMuted(bool muted); + Future connectToAllPeers(); + Future closeAllConnections(); + Future disposeLocalStream(); +} +``` + +```dart +// lib/services/signaling_channel.dart +import 'package:flutter/foundation.dart'; + +/// Minimal signaling surface RideSession needs. Implemented by SignalingClient +/// (a ChangeNotifier, hence Listenable); faked in tests. +abstract class SignalingChannel implements Listenable { + Future connect(); + void joinRoom(String roomId); + void leaveRoom(); + void startPTT(); + void endPTT(); + Future disconnect(); + + /// Number of peers currently known in the joined room. + int get peerCount; +} +``` + +- [ ] **Step 4: Declare the concrete implementers** + +In `lib/services/webrtc_service.dart`, add the import near the other imports: + +```dart +import 'voice_transport.dart'; +``` + +Change the class declaration from: + +```dart +class WebRTCService extends ChangeNotifier { +``` + +to: + +```dart +class WebRTCService extends ChangeNotifier implements VoiceTransport { +``` + +In `lib/services/signaling_client.dart`, add the import near the top: + +```dart +import 'signaling_channel.dart'; +``` + +Change the class declaration from: + +```dart +class SignalingClient extends ChangeNotifier { +``` + +to: + +```dart +class SignalingClient extends ChangeNotifier implements SignalingChannel { +``` + +Then add this getter inside `SignalingClient` (next to the other getters such as `List get peers => ...`): + +```dart + @override + int get peerCount => _peers.length; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `flutter test test/services/ride_session_test.dart` +Expected: PASS (1 test). If the compiler reports a missing member on either service, the interface and the concrete class have drifted — align the signature, do not weaken the interface. + +- [ ] **Step 6: Commit** + +```bash +git add lib/services/voice_transport.dart lib/services/signaling_channel.dart \ + lib/services/webrtc_service.dart lib/services/signaling_client.dart \ + test/services/ride_session_test.dart +git commit -m "refactor(mobile): extract VoiceTransport and SignalingChannel interfaces" +``` + +--- + +### Task 3: RideSession coordinator + +**Files:** +- Create: `lib/services/ride_session.dart` +- Create: `test/support/fakes.dart` +- Modify: `test/services/ride_session_test.dart` (add behavior tests alongside the Task 2 smoke test) + +**Interfaces:** +- Consumes: `VoiceTransport`, `SignalingChannel` (Task 2); `PTTService` and `PTTStateExtension.isActive` from `lib/services/ptt_service.dart` / `lib/models/ptt_state.dart`; `RecorderService` (for the fake). +- Produces: + - `class RideSession { RideSession({required PTTService ptt, required VoiceTransport transport, required SignalingChannel signaling}); bool get isJoined; Future join(String roomId); Future leave(); }` + - `test/support/fakes.dart` exporting `FakeVoiceTransport`, `FakeSignaling`, `FakeRecorder`. + +- [ ] **Step 1: Write the shared fakes** + +```dart +// test/support/fakes.dart +import 'package:flutter/foundation.dart'; +import 'package:app/services/voice_transport.dart'; +import 'package:app/services/signaling_channel.dart'; +import 'package:app/services/recorder_service.dart'; + +class FakeVoiceTransport implements VoiceTransport { + final List muteHistory = []; + int initCount = 0; + int connectToAllPeersCount = 0; + int closeCount = 0; + int disposeStreamCount = 0; + + bool? get lastMuted => muteHistory.isEmpty ? null : muteHistory.last; + + @override + Future initializeLocalStream() async => initCount++; + @override + void setMuted(bool muted) => muteHistory.add(muted); + @override + Future connectToAllPeers() async => connectToAllPeersCount++; + @override + Future closeAllConnections() async => closeCount++; + @override + Future disposeLocalStream() async => disposeStreamCount++; +} + +class FakeSignaling extends ChangeNotifier implements SignalingChannel { + int connectCount = 0; + String? joinedRoom; + bool leftRoom = false; + bool disconnected = false; + int startPttCount = 0; + int endPttCount = 0; + int _peerCount = 0; + + @override + int get peerCount => _peerCount; + + /// Simulate the server delivering a peer roster. + void setPeerCount(int value) { + _peerCount = value; + notifyListeners(); + } + + @override + Future connect() async => connectCount++; + @override + void joinRoom(String roomId) => joinedRoom = roomId; + @override + void leaveRoom() => leftRoom = true; + @override + void startPTT() => startPttCount++; + @override + void endPTT() => endPttCount++; + @override + Future disconnect() async => disconnected = true; +} + +class FakeRecorder implements RecorderService { + @override + Future startRecording() async {} + @override + Future stopAndPlayback() async {} + @override + Future dispose() async {} +} +``` + +- [ ] **Step 2: Write the failing behavior tests** + +Append to `test/services/ride_session_test.dart` (keep the existing Task 2 smoke test and its imports; add these imports and this `group`): + +```dart +import 'package:flutter/services.dart'; +import 'package:app/models/ptt_state.dart'; +import 'package:app/services/ptt_service.dart'; +import 'package:app/services/ride_session.dart'; +import '../support/fakes.dart'; + +// Inside main(), add: +group('RideSession', () { + const channel = MethodChannel('com.example.peloton/ptt'); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future drain() async { + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + } + + test('join connects, comes up muted, and joins the room', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = RideSession(ptt: ptt, transport: transport, signaling: signaling); + + await session.join('BBB:A1'); + await drain(); + + expect(signaling.connectCount, 1); + expect(transport.initCount, 1); + expect(transport.lastMuted, true, reason: 'must be muted by default'); + expect(signaling.joinedRoom, 'BBB:A1'); + expect(session.isJoined, true); + + ptt.dispose(); + }); + + test('PTT active unmutes and signals start; idle re-mutes and signals end', + () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + + ptt.manualPress(); + expect(transport.lastMuted, false); + expect(signaling.startPttCount, 1); + + ptt.manualRelease(); + expect(transport.lastMuted, true); + expect(signaling.endPttCount, 1); + + ptt.dispose(); + }); + + test('connects to peers once a roster arrives', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + expect(transport.connectToAllPeersCount, 0); + + signaling.setPeerCount(1); // server delivered a peer + expect(transport.connectToAllPeersCount, 1); + + ptt.dispose(); + }); + + test('leave stops gating and tears everything down', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + + await session.leave(); + + expect(signaling.leftRoom, true); + expect(transport.closeCount, 1); + expect(transport.disposeStreamCount, 1); + expect(signaling.disconnected, true); + expect(session.isJoined, false); + + // After leaving, PTT changes must NOT transmit. + final startsBefore = signaling.startPttCount; + ptt.manualPress(); + expect(signaling.startPttCount, startsBefore); + + ptt.dispose(); + }); +}); +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `flutter test test/services/ride_session_test.dart` +Expected: FAIL — `Error: Not found: 'package:app/services/ride_session.dart'`. + +- [ ] **Step 4: Write the implementation** + +```dart +// lib/services/ride_session.dart +import '../models/ptt_state.dart'; +import 'ptt_service.dart'; +import 'voice_transport.dart'; +import 'signaling_channel.dart'; + +/// Coordinates the three PTT subsystems into a live walkie-talkie session: +/// keeps a persistent, muted WebRTC connection and gates the mic on PTT state. +/// +/// Walkie-talkie invariant: the local mic track is muted whenever joined; +/// holding PTT is the only thing that unmutes it. +class RideSession { + final PTTService _ptt; + final VoiceTransport _transport; + final SignalingChannel _signaling; + + bool _joined = false; + bool get isJoined => _joined; + + RideSession({ + required PTTService ptt, + required VoiceTransport transport, + required SignalingChannel signaling, + }) : _ptt = ptt, + _transport = transport, + _signaling = signaling; + + /// Connect, come up MUTED, join [roomId], and start gating the mic on PTT. + Future join(String roomId) async { + if (_joined) return; + await _signaling.connect(); + await _transport.initializeLocalStream(); + _transport.setMuted(true); // silent until PTT is held + _signaling.joinRoom(roomId); + _signaling.addListener(_onSignalingChanged); + _ptt.addListener(_onPttChanged); + _joined = true; + } + + void _onSignalingChanged() { + // Offer to any peers already in the room when the roster arrives. + // connectToAllPeers is idempotent per peer, so repeat calls are safe. + if (_signaling.peerCount > 0) { + _transport.connectToAllPeers(); + } + } + + void _onPttChanged() { + if (_ptt.state.isActive) { + _transport.setMuted(false); + _signaling.startPTT(); + } else { + _transport.setMuted(true); + _signaling.endPTT(); + } + } + + /// Stop gating, leave the room, and tear down the connection. + Future leave() async { + if (!_joined) return; + _ptt.removeListener(_onPttChanged); + _signaling.removeListener(_onSignalingChanged); + _signaling.leaveRoom(); + await _transport.closeAllConnections(); + await _transport.disposeLocalStream(); + await _signaling.disconnect(); + _joined = false; + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `flutter test test/services/ride_session_test.dart` +Expected: PASS (Task 2 smoke test + 4 behavior tests = 5). + +- [ ] **Step 6: Commit** + +```bash +git add lib/services/ride_session.dart test/support/fakes.dart \ + test/services/ride_session_test.dart +git commit -m "feat(mobile): add RideSession coordinating PTT, WebRTC mute, signaling" +``` + +--- + +### Task 4: Group picker screen + +**Files:** +- Create: `lib/ui/screens/group_picker_screen.dart` +- Test: `test/ui/group_picker_screen_test.dart` + +**Interfaces:** +- Consumes: `bbbClub`, `RidingGroup`, `Club.roomIdFor` (Task 1); `CallScreen` (existing, unchanged signature `CallScreen({required String serverUrl, String roomId})`). +- Produces: + - `class GroupPickerScreen extends StatefulWidget { final void Function(BuildContext context, String serverUrl, String roomId)? onSelect; const GroupPickerScreen({super.key, this.onSelect}); }` + - Each group button carries `Key('group_')`, e.g. `Key('group_A1')`. + +- [ ] **Step 1: Write the failing test** + +```dart +// test/ui/group_picker_screen_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/ui/screens/group_picker_screen.dart'; + +void main() { + testWidgets('tapping group A1 selects room BBB:A1', (tester) async { + String? selectedRoom; + String? selectedServer; + + await tester.pumpWidget(MaterialApp( + home: GroupPickerScreen( + onSelect: (context, serverUrl, roomId) { + selectedServer = serverUrl; + selectedRoom = roomId; + }, + ), + )); + + expect(find.byKey(const Key('group_A1')), findsOneWidget); + expect(find.byKey(const Key('group_B3')), findsOneWidget); + + await tester.tap(find.byKey(const Key('group_A1'))); + await tester.pump(); + + expect(selectedRoom, 'BBB:A1'); + expect(selectedServer, 'ws://localhost:8080'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/ui/group_picker_screen_test.dart` +Expected: FAIL — `Error: Not found: 'package:app/ui/screens/group_picker_screen.dart'`. + +- [ ] **Step 3: Write the implementation** + +```dart +// lib/ui/screens/group_picker_screen.dart +import 'package:flutter/material.dart'; +import '../../models/riding_group.dart'; +import 'call_screen.dart'; + +/// Lets a rider pick one of club BBB's 7 groups and a signaling server URL, +/// then hands the resulting room id to [onSelect]. When [onSelect] is null it +/// opens the CallScreen for that room. Selection is not persisted. +class GroupPickerScreen extends StatefulWidget { + final void Function(BuildContext context, String serverUrl, String roomId)? + onSelect; + + const GroupPickerScreen({super.key, this.onSelect}); + + @override + State createState() => _GroupPickerScreenState(); +} + +class _GroupPickerScreenState extends State { + final _serverController = TextEditingController(text: 'ws://localhost:8080'); + + @override + void dispose() { + _serverController.dispose(); + super.dispose(); + } + + void _select(RidingGroup group) { + final roomId = bbbClub.roomIdFor(group); + final serverUrl = _serverController.text; + (widget.onSelect ?? _openCallScreen)(context, serverUrl, roomId); + } + + void _openCallScreen(BuildContext context, String serverUrl, String roomId) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CallScreen(serverUrl: serverUrl, roomId: roomId), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar(title: Text('Club ${bbbClub.name} — pick a group')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _serverController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Server URL', + labelStyle: TextStyle(color: Colors.grey[400]), + ), + ), + const SizedBox(height: 16), + Expanded( + child: GridView.count( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + for (final group in bbbClub.groups) + ElevatedButton( + key: Key('group_${group.id}'), + onPressed: () => _select(group), + child: Text( + group.name, + style: const TextStyle(fontSize: 24), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/ui/group_picker_screen_test.dart` +Expected: PASS (1 test). + +- [ ] **Step 5: Commit** + +```bash +git add lib/ui/screens/group_picker_screen.dart test/ui/group_picker_screen_test.dart +git commit -m "feat(mobile): add club BBB group picker screen" +``` + +--- + +### Task 5: Wire CallScreen to RideSession + route from HomeScreen + +Delete the build-time PTT side-effect in `CallScreen` and delegate transmit to `RideSession`. Add optional injected `transport`/`signaling` so the screen is testable with fakes. Point the home screen's call action at the group picker. + +**Files:** +- Modify: `lib/ui/screens/call_screen.dart` +- Modify: `lib/ui/screens/home_screen.dart` +- Test: `test/ui/call_screen_test.dart` + +**Interfaces:** +- Consumes: `RideSession` (Task 3); `VoiceTransport`/`SignalingChannel` (Task 2); `GroupPickerScreen` (Task 4); `PTTService` from provider; fakes from `test/support/fakes.dart`. +- Produces: `CallScreen` now accepts optional `VoiceTransport? transport` and `SignalingChannel? signaling` in addition to the existing `serverUrl`/`roomId`. + +- [ ] **Step 1: Write the failing test** + +```dart +// test/ui/call_screen_test.dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:app/services/ptt_service.dart'; +import 'package:app/ui/screens/call_screen.dart'; +import '../support/fakes.dart'; + +void main() { + testWidgets('CallScreen joins the room muted on init and leaves on dispose', + (tester) async { + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider( + create: (_) => PTTService(recorder: FakeRecorder()), + child: CallScreen( + serverUrl: 'ws://localhost:8080', + roomId: 'BBB:A1', + transport: transport, + signaling: signaling, + ), + ), + )); + await tester.pump(); // let initState's async join settle + await tester.pump(const Duration(milliseconds: 10)); + + expect(signaling.joinedRoom, 'BBB:A1'); + expect(transport.lastMuted, true); + + // Replace the screen to trigger dispose -> RideSession.leave(). + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pump(); + + expect(signaling.leftRoom, true); + expect(signaling.disconnected, true); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/ui/call_screen_test.dart` +Expected: FAIL — compile error: `CallScreen` has no named parameter `transport`. + +- [ ] **Step 3: Update imports and constructor in `call_screen.dart`** + +Add these imports after the existing service imports at the top of `lib/ui/screens/call_screen.dart`: + +```dart +import '../../services/voice_transport.dart'; +import '../../services/signaling_channel.dart'; +import '../../services/ride_session.dart'; +``` + +Replace the widget field/constructor block (currently lines ~9-16) with: + +```dart + final String serverUrl; + final String roomId; + + /// Injected for tests; production builds create real services internally. + final VoiceTransport? transport; + final SignalingChannel? signaling; + + const CallScreen({ + super.key, + required this.serverUrl, + this.roomId = 'default', + this.transport, + this.signaling, + }); +``` + +- [ ] **Step 4: Replace the service-init logic and PTT wiring in `_CallScreenState`** + +Change the state fields (currently `late SignalingClient _signaling; late WebRTCService _webrtc;`) to: + +```dart + late final SignalingChannel _signaling; + late final VoiceTransport _transport; + RideSession? _session; + bool _isInitialized = false; + String? _errorMessage; +``` + +Replace `_initializeServices()` with: + +```dart + Future _initializeServices() async { + try { + if (widget.transport != null && widget.signaling != null) { + _signaling = widget.signaling!; + _transport = widget.transport!; + } else { + final client = SignalingClient( + serverUrl: widget.serverUrl, + userId: 'user_${DateTime.now().millisecondsSinceEpoch}', + deviceInfo: Theme.of(context).platform.name, + ); + _signaling = client; + _transport = WebRTCService(signaling: client); + } + + // Rebuild the peer list as signaling state changes. + _signaling.addListener(_onSignalingUpdate); + + _session = RideSession( + ptt: context.read(), + transport: _transport, + signaling: _signaling, + ); + await _session!.join(widget.roomId); + + setState(() => _isInitialized = true); + } catch (e) { + setState(() => _errorMessage = 'Failed to initialize: $e'); + } + } +``` + +Replace `dispose()` with: + +```dart + @override + void dispose() { + _signaling.removeListener(_onSignalingUpdate); + _session?.leave(); + super.dispose(); + } +``` + +- [ ] **Step 5: Remove the build-time PTT side-effect and make the peer list type-safe** + +In `_buildPTTControls`, delete these lines from the `Consumer` builder (they are now owned by `RideSession`): + +```dart + // Sync PTT state with signaling + if (isActive) { + _signaling.startPTT(); + _webrtc.setMuted(false); + } else { + _signaling.endPTT(); + _webrtc.setMuted(true); + } +``` + +The builder keeps using `final isActive = pttService.state.isActive;` for rendering only. + +In `_buildPeersList`, replace the first line `final peers = _signaling.peers;` with a type-guarded read (the interface does not expose the peer list): + +```dart + final peers = + _signaling is SignalingClient ? (_signaling as SignalingClient).peers : const []; +``` + +And replace `final connectionState = _webrtc.getPeerState(peer.id);` with: + +```dart + final connectionState = _transport is WebRTCService + ? (_transport as WebRTCService).getPeerState(peer.id) + : null; +``` + +`_getConnectionColor`/`_getConnectionText` already call `_signaling.connectionState`; since the interface omits it, guard them too — replace `_signaling.connectionState` in both with: + +```dart + final state = _signaling is SignalingClient + ? (_signaling as SignalingClient).connectionState + : SignalingConnectionState.connected; +``` + +and switch on `state`. (`Peer`, `SignalingConnectionState`, and `PeerConnectionState` are already imported via `signaling_client.dart`/`webrtc_service.dart`.) + +- [ ] **Step 6: Run test to verify it passes** + +Run: `flutter test test/ui/call_screen_test.dart` +Expected: PASS (1 test). Then run `flutter analyze` and confirm no new errors. + +- [ ] **Step 7: Route HomeScreen to the group picker** + +In `lib/ui/screens/home_screen.dart`, add the import: + +```dart +import 'group_picker_screen.dart'; +``` + +Delete the entire `_showJoinRoomDialog` method, and change the call `IconButton`'s `onPressed` (currently `() => _showJoinRoomDialog(context)`) to: + +```dart + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const GroupPickerScreen()), + ), +``` + +- [ ] **Step 8: Run the full suite** + +Run: `flutter test` +Expected: PASS — all suites (existing `ptt_service_test.dart`, `widget_test.dart`, plus the four new suites). Then `flutter analyze` clean. + +- [ ] **Step 9: Commit** + +```bash +git add lib/ui/screens/call_screen.dart lib/ui/screens/home_screen.dart \ + test/ui/call_screen_test.dart +git commit -m "feat(mobile): drive CallScreen transmit through RideSession, route via group picker" +``` + +--- + +## Manual verification (Milestone A — two physical devices) + +Not automatable; run once the tasks are green. + +1. On your laptop: `cd packages/services/signaling && go run ./cmd/main.go` (listens on `:8080`). Note the laptop's LAN IP (e.g. `192.168.1.20`). +2. Build/run the app on **two physical phones** on the **same WiFi**: `cd packages/mobile && flutter run`. +3. On both phones: tap the call action → GroupPickerScreen → set Server URL to `ws://:8080` → tap the **same** group (e.g. `A1`). +4. Confirm each phone shows the other in the peer list as `Connected`. +5. Hold PTT on phone 1 → phone 2 hears live audio; release → audio stops. Repeat phone 2 → phone 1. +6. Negative check: put phone 2 in group `B1` instead → the two must **not** hear each other. + +Expected result: bidirectional live audio for same-group phones on LAN; isolation across groups. This satisfies the spec's success criterion for Milestone A. TURN/cellular is Milestone B (separate plan). + +## Self-Review + +- **Spec coverage:** transmit model (mute-gating) → Tasks 2/3/5; `RideSession` coordinator → Task 3; default-muted invariant → Task 3 (`join`) + Task 5 test; group model BBB/7 → Task 1; room isolation → Task 1 + manual step 6; group picker, no persistence → Task 4; overlap allowed → no locking logic added (satisfied by omission); loopback retained as self-test → `RecorderService`/`PTTService` untouched; pre-existing-peer connect bug → Task 3 `_onSignalingChanged`; LAN/STUN test → manual section. TURN/cellular, SFU, auth/store, headset perfection, CI/CD → explicitly deferred, no tasks (correct). +- **Placeholders:** none — every code step contains complete code. +- **Type consistency:** `VoiceTransport`/`SignalingChannel` member names match `WebRTCService`/`SignalingClient` methods used in Task 3 and Task 5; `peerCount`, `roomIdFor`, `RideSession.join/leave/isJoined`, and the `Key('group_')` convention are used consistently across tasks. diff --git a/docs/superpowers/specs/2026-07-31-ptt-webrtc-mvp-design.md b/docs/superpowers/specs/2026-07-31-ptt-webrtc-mvp-design.md new file mode 100644 index 0000000..53590ac --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-ptt-webrtc-mvp-design.md @@ -0,0 +1,159 @@ +# PTT → WebRTC 2-Device Walkie-Talkie MVP — Design + +**Date:** 2026-07-31 +**Status:** Approved for planning +**Branch:** `feat/ptt-webrtc-mvp` (stacked on `feat/configurable-ptt-buttons`) + +## Goal / Success Criterion + +Two phones (any mix of iOS/Android), each rider opens the app, picks a group in +club **BBB** (e.g. both pick `A1`), and when one **holds PTT** (on-screen button, +or iOS system PTT) the other **hears them live** — sub-second latency, both +directions. Proven first on the **same WiFi** (STUN only). Riders in group `A1` +do **not** hear riders in group `B2`. + +This closes the one seam the codebase is missing: today PTT drives a **local +loopback recorder** (record → play back on the same device); the MVP makes PTT +**gate the microphone on a live WebRTC connection to another device**. + +## Background: why this is a small change + +Both halves already exist and are committed; they are simply not wired together: + +| Piece | Status | Role | +|---|---|---| +| `SignalingClient` (`lib/services/signaling_client.dart`) | exists | WebSocket: join room, offer/answer/ICE, `startPTT`/`endPTT`, `peer_talking` | +| `WebRTCService` (`lib/services/webrtc_service.dart`) | exists | persistent peer connections, `setMuted()`, remote audio via `onRemoteStream` | +| `PTTService` (`lib/services/ptt_service.dart`) | exists | emits `active`/`idle` from native triggers (headset / iOS PTT / on-screen / manual) | +| Signaling server (`packages/services/signaling`, Go) | exists | room/peer hub | +| **`RideSession`** | **to build** | the glue that maps PTT state → WebRTC mute and owns call lifecycle | +| **Group model** | **to build** | club BBB + 7 groups → room id | + +WebRTC was an established, sound protocol choice (real-time low-latency P2P +audio, built-in Opus + echo-cancel/noise-suppress/AGC, standard ICE/STUN/TURN, +cross-platform `flutter_webrtc`). The MVP continues that established direction — +README already lists "Integrate PTT → Connect button events to WebRTC +mute/unmute" as the next step. + +## Architecture + +### The new coordinator: `RideSession` + +A single orchestrator (`lib/services/ride_session.dart` or `lib/controllers/`) +ties `PTTService`, `SignalingClient`, and `WebRTCService` together so no widget +has to know about all three. Responsibilities: + +1. **Connect** the signaling client and **initialize the local mic stream muted** + (`WebRTCService.initializeLocalStream()` then `setMuted(true)`). +2. **Join** the selected group's room (`signaling.joinRoom(roomId)`). +3. **Auto-connect** to peers (existing `connectToAllPeers` / `onPeerJoined`). +4. **Map PTT state to transmit:** listen to `PTTService`; on `active` → + `webrtc.setMuted(false)` + `signaling.startPTT()`; on `idle` → + `webrtc.setMuted(true)` + `signaling.endPTT()`. +5. **Surface** connection state, peer list, and who is talking (`peer_talking`) + to the UI. +6. **Tear down** on leave/dispose (close peers, dispose local stream, leave room). + +This **supersedes** the loopback wiring inside `PTTService._setState` (which +currently calls `_recorder.startRecording()` / `stopAndPlayback()`). The recorder +is **retained as an optional "self-test" mode**, not deleted — useful for +verifying mic capture without a peer. + +### Data flow (the press path) + +``` +hold PTT → native → PTTService.active → RideSession + → webrtc.setMuted(false) + signaling.startPTT() + → peer receives live audio; peer UI shows "A1: talking" +release/toggle → PTTService.idle → RideSession + → webrtc.setMuted(true) + signaling.endPTT() +``` + +The local mic track is **disabled by default** for the entire ride; PTT is +literally an unmute gate. Input source (headset / on-screen / iOS system PTT) is +indistinguishable downstream — all paths converge on `PTTService` state. + +### Group model (minimal) + +``` +Club { id: "BBB", name: "BBB", groups: [Group x7] } +Group { id, name } // A1, A2, A3, A4, B1, B2, B3 +``` + +- **Room id convention:** `"BBB:A1"` … `"BBB:B3"`. Client picks a group → joins + that room. Groups in different rooms are fully isolated by the existing hub. +- **Membership** = live peers currently in the room. No persistence, no DB, no + auth for the MVP. +- **Server change: near-zero** — the hub already isolates by room; groups are a + client-side list plus a room-id convention. (Optionally the server validates + the room id is one of the 7 known groups; not required for MVP.) +- **UI:** a simple group picker (7 buttons or a dropdown) shown before entering + the call screen. Selection is **not persisted** — pick fresh each launch. + +### Mute-gating semantics (walkie-talkie behavior) + +- **Default:** local mic track disabled (muted) while connected. +- **Hold mode:** press = unmute, release = mute. +- **Toggle mode:** tap = unmute+lock, tap = mute. (Native already forces toggle + semantics for the BT play/pause key; the settings UI hides hold mode when + play/pause is selected.) +- **Overlap allowed (MVP decision):** if two riders in the same group hold PTT + at once, both are heard. One-talker-at-a-time locking is out of scope; talking + indicators (`peer_talking`) make concurrency visible. + +### Trigger reliability tiers + +- **Tier 1 — must work for the demo:** on-screen button (hold + toggle), iOS + system PTT (PushToTalk framework). +- **Tier 2 — best-effort:** Android BT headset play/pause (toggle mode; AVRCP + protocol limits documented in `docs/bluetooth-ptt-implementation.md`), volume + buttons. Not blocking for MVP. + +## Networking phases + +- **Milestone A (this spec):** laptop signaling server on LAN, STUN only, both + phones on the same WiFi. **Success = Android↔Android *and* iOS↔Android, both + directions.** +- **Milestone B (next spec, out of scope here):** deploy signaling to k3s + (config exists in `packages/infra/k3s`) + coturn TURN server → validate + cellular / cross-network connectivity. + +## Error handling + +- **Signaling disconnect** → auto-reconnect + rejoin group room; UI reflects + `SignalingConnectionState`. +- **Mic permission denied** → surface in UI, disable PTT (do not attempt + transmit). +- **Peer connection failed** (`RTCPeerConnectionStateFailed`) → + `onPeerDisconnected` → show dropped; attempt a single re-offer. +- **Empty group** → PTT still works (stays muted); no listeners, no error. +- **ICE candidate before remote description** → already handled via + `pendingCandidates` buffering in `WebRTCService`. + +## Testing strategy (TDD where it pays) + +- **Unit (Dart):** `RideSession` maps PTT state → `setMuted`/`startPTT`/`endPTT` + correctly (mock `WebRTCService` + `SignalingClient`); group → room-id logic; + default-muted invariant on connect. Extends the existing + `test/services/ptt_service_test.dart` patterns. +- **Widget:** group picker selects a room; PTT button drives `RideSession`; + connection-state UI renders each `SignalingConnectionState`. +- **Go:** hub keeps rooms isolated (a peer in `BBB:A1` never receives traffic + addressed to `BBB:B2`). +- **Manual E2E (the real proof):** two physical devices on the same WiFi against + a laptop-hosted signaling server — cannot be emulated. Covers the README test + matrix rows for same-WiFi. + +## Explicitly out of scope (MVP) + +- TURN / cellular connectivity (Milestone B). +- 3+ rider scaling / SFU (P2P mesh is fine for 2; revisit for groups). +- Auth, store, orders, and the full club/ride model from `documentation.yaml`. +- Android BT headset press/release perfection (AVRCP-limited; tracked, not + blocking). +- The CI/CD pipeline (`cicd_plan.md`) — a separate track, not part of this spec. + +## Open questions + +None blocking. Milestone B (TURN/k3s) gets its own spec once Milestone A is +proven on two physical devices. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..b8f8c99 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,37 @@ +# Local quality gates, mirroring the fast CI stages so problems surface before a push. +# Install once per clone: brew install lefthook && lefthook install +# See docs/ci-pipeline.md for how these map to the CI pipeline. + +pre-commit: + parallel: true + commands: + dart-format: + root: packages/mobile/ + glob: "*.dart" + run: dart format --output=none --set-exit-if-changed {staged_files} + flutter-analyze: + root: packages/mobile/ + glob: "*.dart" + run: flutter analyze --fatal-infos + gofmt: + root: packages/services/signaling/ + glob: "*.go" + run: | + unformatted=$(gofmt -s -l {staged_files}) + if [ -n "$unformatted" ]; then + echo "gofmt -s needed on:"; echo "$unformatted"; exit 1 + fi + go-vet: + root: packages/services/signaling/ + glob: "*.go" + run: go vet ./... && go vet -tags=integration ./... + +pre-push: + parallel: true + commands: + flutter-test: + root: packages/mobile/ + run: flutter test + go-test: + root: packages/services/signaling/ + run: go test -race ./... && go test -race -tags=integration ./cmd/... diff --git a/packages/infra/docker-compose.yaml b/packages/infra/docker-compose.yaml new file mode 100644 index 0000000..944d2c7 --- /dev/null +++ b/packages/infra/docker-compose.yaml @@ -0,0 +1,96 @@ +# Local development environment for Peloton Communicator MVP +# This compose file runs the signaling service for WebRTC peer coordination + +version: '3.8' + +services: + # Signaling Service - WebSocket server for WebRTC peer coordination + signaling: + build: + context: ../services/signaling + dockerfile: Dockerfile + container_name: peloton-signaling + ports: + - "8080:8080" + environment: + - HOST=0.0.0.0 + - PORT=8080 + - LOG_LEVEL=debug + - LOG_JSON=false + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + + # TURN/STUN server for NAT traversal (optional - uses coturn) + # Uncomment when testing over cellular networks or when NAT is blocking direct P2P + # turn: + # image: coturn/coturn:4.6 + # container_name: peloton-turn + # network_mode: host + # volumes: + # - ./turnserver.conf:/etc/turnserver.conf:ro + # restart: unless-stopped + + # Redis for signaling session state (Phase 2) + # redis: + # image: redis:7-alpine + # container_name: peloton-redis + # ports: + # - "6379:6379" + # volumes: + # - redis-data:/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD", "redis-cli", "ping"] + # interval: 10s + # timeout: 5s + # retries: 3 + + # PostgreSQL for Auth/Groups services (Phase 2) + # postgres: + # image: postgres:16-alpine + # container_name: peloton-postgres + # ports: + # - "5432:5432" + # environment: + # - POSTGRES_USER=peloton + # - POSTGRES_PASSWORD=peloton_dev + # - POSTGRES_DB=peloton + # volumes: + # - postgres-data:/var/lib/postgresql/data + # restart: unless-stopped + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U peloton"] + # interval: 10s + # timeout: 5s + # retries: 3 + + # Kafka for event streaming (Phase 2) + # kafka: + # image: confluentinc/cp-kafka:7.5.0 + # container_name: peloton-kafka + # ports: + # - "9092:9092" + # environment: + # - KAFKA_NODE_ID=1 + # - KAFKA_PROCESS_ROLES=broker,controller + # - KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093 + # - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 + # - KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER + # - KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 + # - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 + # - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 + # - KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 + # - KAFKA_AUTO_CREATE_TOPICS_ENABLE=true + # volumes: + # - kafka-data:/var/lib/kafka/data + # restart: unless-stopped + +# volumes: +# redis-data: +# postgres-data: +# kafka-data: diff --git a/packages/infra/k3s/signaling-deployment.yaml b/packages/infra/k3s/signaling-deployment.yaml new file mode 100644 index 0000000..ef395e7 --- /dev/null +++ b/packages/infra/k3s/signaling-deployment.yaml @@ -0,0 +1,135 @@ +--- +# Namespace for Peloton Communicator services +apiVersion: v1 +kind: Namespace +metadata: + name: peloton-communicator + labels: + app.kubernetes.io/name: peloton-communicator + +--- +# ConfigMap for signaling service configuration +apiVersion: v1 +kind: ConfigMap +metadata: + name: signaling-config + namespace: peloton-communicator + labels: + app: signaling +data: + HOST: "0.0.0.0" + PORT: "8080" + LOG_LEVEL: "info" + LOG_JSON: "true" + +--- +# Deployment for the signaling service +apiVersion: apps/v1 +kind: Deployment +metadata: + name: signaling + namespace: peloton-communicator + labels: + app: signaling +spec: + replicas: 2 + selector: + matchLabels: + app: signaling + template: + metadata: + labels: + app: signaling + spec: + containers: + - name: signaling + image: peloton-communicator/signaling:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: signaling-config + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app + operator: In + values: + - signaling + topologyKey: kubernetes.io/hostname + +--- +# Service to expose the signaling deployment +apiVersion: v1 +kind: Service +metadata: + name: signaling + namespace: peloton-communicator + labels: + app: signaling +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: signaling + +--- +# Ingress for external access (WebSocket support) +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: signaling-ingress + namespace: peloton-communicator + labels: + app: signaling + annotations: + # Enable WebSocket support in ingress + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" +spec: + ingressClassName: nginx + rules: + - host: signaling.peloton.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: signaling + port: + number: 8080 diff --git a/packages/mobile/analysis_options.yaml b/packages/mobile/analysis_options.yaml index 61b6c4d..5bee4ff 100644 --- a/packages/mobile/analysis_options.yaml +++ b/packages/mobile/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 8b050c3..c2fc96e 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -24,7 +24,7 @@ if (flutterVersionName == null) { android { namespace 'com.example.app' - compileSdk 34 + compileSdk 36 compileOptions { sourceCompatibility JavaVersion.VERSION_17 @@ -42,7 +42,7 @@ android { defaultConfig { applicationId "com.example.app" minSdkVersion 31 // Android 12+ - targetSdkVersion 34 + targetSdkVersion 36 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -57,6 +57,7 @@ android { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.22" - implementation "androidx.media:media:1.7.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.1.0" + implementation "androidx.media3:media3-session:1.4.1" + implementation "androidx.media3:media3-common:1.4.1" } \ No newline at end of file diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index bf09c82..e217a9e 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -5,8 +5,12 @@ + + + + + + + + + + + + Android communication + channel.setMethodCallHandler { call, result -> when (call.method) { "setPTTMode" -> { @@ -40,209 +35,151 @@ class MainActivity: FlutterActivity() { Log.d("PTT", "PTT mode set to: $pttMode") result.success(null) } + "updatePTTConfiguration" -> { + @Suppress("UNCHECKED_CAST") + val args = call.arguments as? Map + if (args != null) { + pttMode = args["mode"] as? String ?: pttMode + pttButton = args["button"] as? String ?: pttButton + preventScreenLock = args["preventScreenLock"] as? Boolean ?: preventScreenLock + + // Share with the media session so PttPlayer can claim (or release) + // remote volume control for headset volume-button PTT. + PttConfig.update(pttMode, pttButton) + + Log.d("PTT", "Configuration updated: mode=$pttMode, button=$pttButton, preventScreenLock=$preventScreenLock") + + if (preventScreenLock) { + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + result.success(null) + } else { + result.error("INVALID_ARGS", "Invalid arguments", null) + } + } else -> result.notImplemented() } } - - checkPermissionsAndSetupMediaSession() - Log.d("PTT", "Flutter engine configured and permission check initiated") + + PttEventBus.listener = { keyEvent -> handleKeyEventForPTT(keyEvent) } + // Headset volume buttons arrive as discrete steps with no hold duration, so they + // always toggle regardless of the configured mode. + PttEventBus.discreteListener = { toggleRecording() } + + checkPermissionsAndStartService() + Log.d("PTT", "Flutter engine configured") } - - private fun checkPermissionsAndSetupMediaSession() { - val permissions = arrayOf( + + private fun checkPermissionsAndStartService() { + val permissions = mutableListOf( Manifest.permission.RECORD_AUDIO, Manifest.permission.BLUETOOTH_CONNECT ) - - val permissionsNeeded = permissions.filter { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + + val needed = permissions.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED } - - if (permissionsNeeded.isNotEmpty()) { - Log.d("PTT", "Requesting permissions: $permissionsNeeded") - ActivityCompat.requestPermissions(this, permissionsNeeded.toTypedArray(), PERMISSION_REQUEST_CODE) + + if (needed.isNotEmpty()) { + Log.d("PTT", "Requesting permissions: $needed") + ActivityCompat.requestPermissions(this, needed.toTypedArray(), PERMISSION_REQUEST_CODE) } else { - Log.d("PTT", "All permissions granted, setting up MediaSession") - setupMediaSession() + startMediaSessionService() } } - + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == PERMISSION_REQUEST_CODE) { - val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } - Log.d("PTT", "Permissions result - all granted: $allGranted") - if (allGranted) { - setupMediaSession() - } else { - Log.w("PTT", "Some permissions were denied") - // Still try to setup media session as RECORD_AUDIO might not be critical for button events - setupMediaSession() - } + startMediaSessionService() } } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - Log.d("PTT", "onNewIntent called with action: ${intent.action}") - if (Intent.ACTION_MEDIA_BUTTON == intent.action) { - Log.d("PTT", "Media button intent received in onNewIntent") - mediaSession.controller.dispatchMediaButtonEvent( - intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)!! - ) + + private fun startMediaSessionService() { + val intent = Intent(this, PttMediaSessionService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) } + Log.d("PTT", "PttMediaSessionService start requested") } - private fun setupMediaSession() { - Log.d("PTT", "Setting up MediaSession...") - mediaSession = MediaSessionCompat(this, "PelotonPTT") - - // Set flags to make our session more aggressive - mediaSession.setFlags( - MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or - MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS - ) - - // Set playback state to enable media button events with higher priority - val playbackState = PlaybackStateCompat.Builder() - .setActions( - PlaybackStateCompat.ACTION_PLAY or - PlaybackStateCompat.ACTION_PAUSE or - PlaybackStateCompat.ACTION_PLAY_PAUSE or - PlaybackStateCompat.ACTION_STOP or - PlaybackStateCompat.ACTION_SKIP_TO_NEXT or - PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + // Volume buttons are activity-scoped (the Service can't intercept volume keys without + // an Accessibility Service). Keep them here. + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (pttButton == "volume" && + (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP)) { + handleKeyEventForPTT(event.withKeyCode(keyCode)) + return true + } + return super.onKeyDown(keyCode, event) + } + + override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { + if (pttButton == "volume" && + (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || keyCode == KeyEvent.KEYCODE_VOLUME_UP)) { + val synthetic = KeyEvent( + event.downTime, event.eventTime, KeyEvent.ACTION_UP, keyCode, event.repeatCount ) - .setState(PlaybackStateCompat.STATE_PLAYING, 0, 1.0f) // Set to PLAYING for higher priority - .build() - - mediaSession.setPlaybackState(playbackState) - Log.d("PTT", "Playback state set: ${playbackState.state}") - - // Set metadata to make our session more prominent - val metadata = MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Peloton PTT Active") - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, "Push-to-Talk Ready") - .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Peloton Communicator") - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, 1000000) // Long duration - .build() - mediaSession.setMetadata(metadata) - - mediaSession.setCallback(object : MediaSessionCompat.Callback() { - override fun onPlay() { - Log.d("PTT", "Play command received") - channel.invokeMethod("pttPressed", null) - } - - override fun onPause() { - Log.d("PTT", "Pause command received") - channel.invokeMethod("pttReleased", null) - } - - override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) - keyEvent?.let { - Log.d("PTT", "Media button event: keyCode=${it.keyCode}, action=${it.action}") - when (it.keyCode) { - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, - KeyEvent.KEYCODE_MEDIA_PLAY, - KeyEvent.KEYCODE_MEDIA_PAUSE -> { - handlePTTButtonEvent(it) - return true - } - else -> { - Log.d("PTT", "Unhandled key code: ${it.keyCode}") - } - } - } - return super.onMediaButtonEvent(mediaButtonEvent) - } - }) - - // Request audio focus BEFORE activating session - val audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - val result = audioManager.requestAudioFocus( - { focusChange -> - Log.d("PTT", "Audio focus changed: $focusChange") - when (focusChange) { - AudioManager.AUDIOFOCUS_GAIN -> { - Log.d("PTT", "Audio focus gained - our app is now active") - mediaSession.isActive = true - } - AudioManager.AUDIOFOCUS_LOSS -> { - Log.d("PTT", "Audio focus lost permanently") - } - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - Log.d("PTT", "Audio focus lost temporarily") - } - } - }, - AudioManager.STREAM_MUSIC, - AudioManager.AUDIOFOCUS_GAIN - ) - Log.d("PTT", "Audio focus request result: $result") - - // Only activate if we got audio focus - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - mediaSession.isActive = true - Log.d("PTT", "MediaSession activated successfully") - } else { - Log.e("PTT", "Failed to get audio focus, but activating session anyway") - mediaSession.isActive = true + handleKeyEventForPTT(synthetic) + return true } - - Log.d("PTT", "MediaSession setup complete and active") + return super.onKeyUp(keyCode, event) + } + + private fun KeyEvent.withKeyCode(newKeyCode: Int): KeyEvent { + return KeyEvent(this.downTime, this.eventTime, this.action, newKeyCode, this.repeatCount) } - - private fun handlePTTButtonEvent(keyEvent: KeyEvent) { + + private fun handleKeyEventForPTT(keyEvent: KeyEvent) { val currentTime = System.currentTimeMillis() - - when (pttMode) { + + // BT headset play/pause buttons can't reliably express hold duration: most earbuds + // (and many over-ear models) emit ACTION_DOWN+ACTION_UP back-to-back at the moment + // of release. Force toggle semantics for those keycodes regardless of pttMode so + // a single tap = single state flip, instead of a green flash that reverts. + val isMediaKey = when (keyEvent.keyCode) { + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> true + else -> false + } + val effectiveMode = if (isMediaKey) "toggle" else pttMode + + Log.d("PTT", "KeyEvent kc=${keyEvent.keyCode} action=${keyEvent.action} repeat=${keyEvent.repeatCount} mediaKey=$isMediaKey effectiveMode=$effectiveMode isRecording=$isRecording") + + when (effectiveMode) { "toggle" -> { - // Toggle mode: Only handle ACTION_DOWN to avoid duplicate events - if (keyEvent.action == KeyEvent.ACTION_DOWN) { - // Prevent accidental double press - if (currentTime - lastPressTime < DOUBLE_PRESS_INTERVAL) { - Log.d("PTT", "Button press ignored - too soon after last press") - return - } - - lastPressTime = currentTime - - // Toggle recording state - isRecording = !isRecording - - if (isRecording) { - Log.d("PTT", "Starting recording (toggle mode)") - channel.invokeMethod("pttPressed", null) - } else { - Log.d("PTT", "Stopping recording (toggle mode)") - channel.invokeMethod("pttReleased", null) - } + if (keyEvent.action == KeyEvent.ACTION_DOWN && keyEvent.repeatCount == 0) { + toggleRecording() } } "hold" -> { - // Hold mode: Handle both press and release when (keyEvent.action) { KeyEvent.ACTION_DOWN -> { - if (!isRecording) { - // Prevent accidental double press + if (keyEvent.repeatCount == 0 && !isRecording) { if (currentTime - lastPressTime < DOUBLE_PRESS_INTERVAL) { - Log.d("PTT", "Button press ignored - too soon after last press") + Log.d("PTT", "Hold DOWN debounced (Δ=${currentTime - lastPressTime}ms)") return } - lastPressTime = currentTime isRecording = true - Log.d("PTT", "Starting recording (hold mode)") - channel.invokeMethod("pttPressed", null) + Log.d("PTT", "Hold → pttPressed") + runOnUiThread { channel.invokeMethod("pttPressed", null) } } } KeyEvent.ACTION_UP -> { if (isRecording) { isRecording = false - Log.d("PTT", "Stopping recording (hold mode)") - channel.invokeMethod("pttReleased", null) + Log.d("PTT", "Hold → pttReleased") + runOnUiThread { channel.invokeMethod("pttReleased", null) } } } } @@ -250,10 +187,27 @@ class MainActivity: FlutterActivity() { } } + /** + * Single debounced state flip. Shared by key-event toggle mode and by discrete + * sources (headset volume buttons) that never report a press/release pair, so every + * input path lands on the same transition and is indistinguishable downstream. + */ + private fun toggleRecording() { + val currentTime = System.currentTimeMillis() + if (currentTime - lastPressTime < DOUBLE_PRESS_INTERVAL) { + Log.d("PTT", "Toggle debounced (Δ=${currentTime - lastPressTime}ms)") + return + } + lastPressTime = currentTime + isRecording = !isRecording + val method = if (isRecording) "pttPressed" else "pttReleased" + Log.d("PTT", "Toggle → $method") + runOnUiThread { channel.invokeMethod(method, null) } + } + override fun onDestroy() { + PttEventBus.listener = null + PttEventBus.discreteListener = null super.onDestroy() - if (::mediaSession.isInitialized) { - mediaSession.release() - } } } diff --git a/packages/mobile/android/app/src/main/kotlin/com/example/app/PttConfig.kt b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttConfig.kt new file mode 100644 index 0000000..b148e8d --- /dev/null +++ b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttConfig.kt @@ -0,0 +1,28 @@ +package com.example.app + +/** + * PTT configuration shared between [MainActivity] (which receives it over the + * MethodChannel) and [PttMediaSessionService] / [PttPlayer], which run in the same + * process but outlive the activity. + * + * [PttPlayer] reads [button] to decide whether to claim remote volume control, so the + * media session only intercepts volume keys while the user has actually selected the + * volume button for PTT. + */ +object PttConfig { + @Volatile var mode: String = "toggle" + @Volatile var button: String = "volumeDown" + + /** Invoked when [button] changes so the player can re-publish its device info. */ + @Volatile var onButtonChanged: (() -> Unit)? = null + + val volumeButtonSelected: Boolean + get() = button == "volume" + + fun update(mode: String, button: String) { + val buttonChanged = this.button != button + this.mode = mode + this.button = button + if (buttonChanged) onButtonChanged?.invoke() + } +} diff --git a/packages/mobile/android/app/src/main/kotlin/com/example/app/PttEventBus.kt b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttEventBus.kt new file mode 100644 index 0000000..1a125eb --- /dev/null +++ b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttEventBus.kt @@ -0,0 +1,28 @@ +package com.example.app + +import android.view.KeyEvent + +/** + * Bridges PTT input events from the long-lived [PttMediaSessionService] to whichever + * [MainActivity] instance is currently attached to the Flutter engine. + */ +object PttEventBus { + /** Raw key events (BT play/pause, headsethook) that carry a real DOWN/UP action. */ + @Volatile var listener: ((KeyEvent) -> Unit)? = null + + /** + * Discrete "the user pressed something once" events that carry no press/release pair. + * Bluetooth headset volume buttons land here: with AVRCP absolute volume the headset + * sends a volume step to the audio system, so a single callback is all we ever get — + * there is no hold duration to observe. Consumers must treat these as toggles. + */ + @Volatile var discreteListener: (() -> Unit)? = null + + fun emit(event: KeyEvent) { + listener?.invoke(event) + } + + fun emitDiscrete() { + discreteListener?.invoke() + } +} diff --git a/packages/mobile/android/app/src/main/kotlin/com/example/app/PttMediaSessionService.kt b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttMediaSessionService.kt new file mode 100644 index 0000000..fa1a773 --- /dev/null +++ b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttMediaSessionService.kt @@ -0,0 +1,77 @@ +package com.example.app + +import android.content.Intent +import android.util.Log +import android.view.KeyEvent +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** + * Foreground MediaSessionService that owns the PTT media session so Bluetooth headset + * play/pause events keep arriving even when the activity is backgrounded or the screen + * is off. Raw KeyEvents are forwarded to [PttEventBus] for the activity to translate + * into Flutter MethodChannel calls. + */ +class PttMediaSessionService : MediaSessionService() { + + private var mediaSession: MediaSession? = null + + override fun onCreate() { + super.onCreate() + val player = PttPlayer() + mediaSession = MediaSession.Builder(this, player) + .setId("PelotonPTT") + .setCallback(PttSessionCallback()) + .build() + Log.d(TAG, "MediaSession created") + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onDestroy() { + mediaSession?.run { + player.release() + release() + mediaSession = null + } + super.onDestroy() + } + + private class PttSessionCallback : MediaSession.Callback { + override fun onMediaButtonEvent( + session: MediaSession, + controllerInfo: MediaSession.ControllerInfo, + intent: Intent + ): Boolean { + val key: KeyEvent? = @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + if (key == null) return false + + return when (key.keyCode) { + KeyEvent.KEYCODE_HEADSETHOOK, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + Log.d(TAG, "MediaButton intercepted: keyCode=${key.keyCode} action=${key.action}") + PttEventBus.emit(key) + true + } + else -> false + } + } + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): MediaSession.ConnectionResult { + return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build() + } + } + + companion object { + private const val TAG = "PTT" + } +} diff --git a/packages/mobile/android/app/src/main/kotlin/com/example/app/PttPlayer.kt b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttPlayer.kt new file mode 100644 index 0000000..c3d2c5b --- /dev/null +++ b/packages/mobile/android/app/src/main/kotlin/com/example/app/PttPlayer.kt @@ -0,0 +1,113 @@ +package com.example.app + +import android.os.Looper +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.DeviceInfo +import androidx.media3.common.Player +import androidx.media3.common.SimpleBasePlayer +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +/** + * Stub player whose only job is to make the system treat us as an active media app so + * Bluetooth headset media-button events get routed to our [androidx.media3.session.MediaSession]. + * No actual audio is played here — PTT transmission is handled at the Flutter layer. + * + * When the user selects the volume button for PTT, the player additionally advertises + * *remote* device volume. That is the Media3 equivalent of a legacy `VolumeProvider`, and + * it is the only way to observe Bluetooth headset volume buttons: under AVRCP absolute + * volume the headset sends SET_ABSOLUTE_VOLUME straight to the audio system and no + * KeyEvent ever reaches the app. Claiming remote volume makes the framework deliver those + * steps here as [handleIncreaseDeviceVolume] / [handleDecreaseDeviceVolume] instead. + * + * Consequences, by design: + * - Only discrete steps arrive, never a down/up pair, so headset volume PTT is + * toggle-only. This is a protocol limit, not an implementation gap. + * - Volume is pinned to the middle of the range after every step so there is always + * headroom in both directions and the user's real media volume is never changed. + * - Remote volume is claimed only while the volume button is selected, so we don't + * hijack system volume for users who drive PTT from the headset play/pause button. + */ +class PttPlayer : SimpleBasePlayer(Looper.getMainLooper()) { + + private var playWhenReady = true + + private val localDeviceInfo = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_LOCAL).build() + + private val remoteDeviceInfo = DeviceInfo.Builder(DeviceInfo.PLAYBACK_TYPE_REMOTE) + .setMinVolume(MIN_VOLUME) + .setMaxVolume(MAX_VOLUME) + .build() + + init { + // Re-publish state when the selected PTT button changes so remote volume control + // is claimed or released to match the current configuration. + PttConfig.onButtonChanged = { invalidateState() } + } + + override fun getState(): State { + val interceptVolume = PttConfig.volumeButtonSelected + val commands = Player.Commands.Builder() + .add(Player.COMMAND_PLAY_PAUSE) + .add(Player.COMMAND_SET_MEDIA_ITEM) + .apply { + if (interceptVolume) { + add(Player.COMMAND_GET_DEVICE_VOLUME) + add(Player.COMMAND_SET_DEVICE_VOLUME_WITH_FLAGS) + add(Player.COMMAND_ADJUST_DEVICE_VOLUME_WITH_FLAGS) + } + } + .build() + + return State.Builder() + .setAvailableCommands(commands) + .setPlaybackState(Player.STATE_READY) + .setPlayWhenReady(playWhenReady, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setDeviceInfo(if (interceptVolume) remoteDeviceInfo else localDeviceInfo) + .setDeviceVolume(RESTING_VOLUME) + .build() + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + this.playWhenReady = playWhenReady + invalidateState() + return Futures.immediateVoidFuture() + } + + override fun handleIncreaseDeviceVolume(@C.VolumeFlags flags: Int): ListenableFuture<*> { + return handleVolumeStep("up") + } + + override fun handleDecreaseDeviceVolume(@C.VolumeFlags flags: Int): ListenableFuture<*> { + return handleVolumeStep("down") + } + + override fun handleSetDeviceVolume( + deviceVolume: Int, + @C.VolumeFlags flags: Int + ): ListenableFuture<*> { + // Absolute-volume headsets report a target level rather than a step. Any change + // away from our resting level is one physical button press. + return if (deviceVolume == RESTING_VOLUME) { + Futures.immediateVoidFuture() + } else { + handleVolumeStep(if (deviceVolume > RESTING_VOLUME) "up" else "down") + } + } + + private fun handleVolumeStep(direction: String): ListenableFuture<*> { + Log.d(TAG, "Remote volume step ($direction) → discrete PTT toggle") + PttEventBus.emitDiscrete() + // Snap back to the resting level so the next press in either direction is seen. + invalidateState() + return Futures.immediateVoidFuture() + } + + companion object { + private const val TAG = "PTT" + private const val MIN_VOLUME = 0 + private const val MAX_VOLUME = 20 + private const val RESTING_VOLUME = 10 + } +} diff --git a/packages/mobile/android/build.gradle b/packages/mobile/android/build.gradle index 3d03fcb..43620a4 100644 --- a/packages/mobile/android/build.gradle +++ b/packages/mobile/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.9.22' + ext.kotlin_version = '2.1.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.2.1' + classpath 'com.android.tools.build:gradle:8.7.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/packages/mobile/android/gradle.properties b/packages/mobile/android/gradle.properties index 94adc3a..eac5ff9 100644 --- a/packages/mobile/android/gradle.properties +++ b/packages/mobile/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties index fb5eb59..2aaed3a 100644 --- a/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-all.zip diff --git a/packages/mobile/android/settings.gradle b/packages/mobile/android/settings.gradle index b6863b1..8cbd262 100644 --- a/packages/mobile/android/settings.gradle +++ b/packages/mobile/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.2.1" apply false - id "org.jetbrains.kotlin.android" version "1.9.22" apply false + id "com.android.application" version "8.7.0" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/packages/mobile/connect-android.sh b/packages/mobile/connect-android.sh new file mode 100755 index 0000000..38f6c94 --- /dev/null +++ b/packages/mobile/connect-android.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Quick Android Wireless Debug Connection Script + +echo "🔧 Android Wireless Debug Helper" +echo "================================" +echo "" + +# Check if adb is available +if ! command -v adb &> /dev/null; then + echo "❌ ADB not found in PATH" + echo "Run: export PATH=\"\$PATH:/Users/romdj/Library/Android/sdk/platform-tools\"" + exit 1 +fi + +echo "📱 Current connected devices:" +adb devices +echo "" + +# Show menu +echo "Choose an option:" +echo "1) Pair new device (Android 11+)" +echo "2) Connect to device" +echo "3) Disconnect all" +echo "4) Restart ADB server" +echo "5) Check Flutter devices" +echo "6) View PTT logs" +echo "7) Deploy PTT app" +echo "0) Exit" +echo "" +read -p "Enter option (0-7): " option + +case $option in + 1) + echo "" + echo "📱 On your Android device:" + echo " Settings → Developer options → Wireless debugging" + echo " → Tap 'Pair device with pairing code'" + echo "" + read -p "Enter IP:PORT from device (e.g., 192.168.1.100:37853): " pair_address + adb pair "$pair_address" + echo "" + echo "✅ Pairing complete! Now connect using option 2" + ;; + 2) + echo "" + echo "📱 On your Android device:" + echo " Check 'Wireless debugging' screen for IP address & port" + echo " (This is DIFFERENT from the pairing port!)" + echo "" + read -p "Enter IP:PORT (e.g., 192.168.1.100:40587): " connect_address + adb connect "$connect_address" + echo "" + echo "Checking connection..." + sleep 1 + adb devices + ;; + 3) + echo "" + echo "Disconnecting all devices..." + adb disconnect + adb devices + ;; + 4) + echo "" + echo "Restarting ADB server..." + adb kill-server + sleep 1 + adb start-server + echo "✅ ADB server restarted" + ;; + 5) + echo "" + echo "Flutter devices:" + flutter devices + ;; + 6) + echo "" + echo "📋 Watching PTT logs (Ctrl+C to stop)..." + echo "Press Volume Down on your device to see logs" + echo "" + adb logcat | grep --color=always PTT + ;; + 7) + echo "" + echo "🚀 Deploying PTT app..." + cd packages/mobile + flutter run + ;; + 0) + echo "👋 Goodbye!" + exit 0 + ;; + *) + echo "❌ Invalid option" + exit 1 + ;; +esac diff --git a/packages/mobile/ios/Flutter/Debug.xcconfig b/packages/mobile/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/packages/mobile/ios/Flutter/Debug.xcconfig +++ b/packages/mobile/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/packages/mobile/ios/Flutter/Release.xcconfig b/packages/mobile/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/packages/mobile/ios/Flutter/Release.xcconfig +++ b/packages/mobile/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/packages/mobile/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/mobile/ios/Runner/AppDelegate.swift b/packages/mobile/ios/Runner/AppDelegate.swift index 18debf3..0a5fdd4 100644 --- a/packages/mobile/ios/Runner/AppDelegate.swift +++ b/packages/mobile/ios/Runner/AppDelegate.swift @@ -7,10 +7,14 @@ import AVFoundation @objc class AppDelegate: FlutterAppDelegate { private let CHANNEL = "com.example.peloton/ptt" private var pttMode = "toggle" // Current PTT mode (toggle or hold) + private var pttButton = "volumeDown" // Current PTT button configuration + private var preventScreenLock = true // Keep screen on during rides private var isRecording = false private var lastPressTime = 0.0 private let DOUBLE_PRESS_INTERVAL = 0.3 // 300ms to prevent accidental double press - + private var volumeButtonObserver: VolumeButtonObserver? + private var systemPTTManager: Any? // PTTSystemManager (iOS 16+) — typed as Any so file compiles on older SDKs + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -31,6 +35,56 @@ import AVFoundation } else { result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid PTT mode", details: nil)) } + case "updatePTTConfiguration": + if let args = call.arguments as? [String: Any] { + if let mode = args["mode"] as? String { + self?.pttMode = mode + } + if let button = args["button"] as? String { + self?.pttButton = button + // Reconfigure button listeners based on new button type + self?.configureButtonListeners(button: button, channel: pttChannel) + } + if let preventLock = args["preventScreenLock"] as? Bool { + self?.preventScreenLock = preventLock + // Update idle timer based on setting + UIApplication.shared.isIdleTimerDisabled = preventLock + } + print("Configuration updated: mode=\(self?.pttMode ?? ""), button=\(self?.pttButton ?? ""), preventScreenLock=\(self?.preventScreenLock ?? false)") + result(nil) + } else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "Invalid arguments", details: nil)) + } + case "joinPTTChannel": + if #available(iOS 16.0, *) { + let args = call.arguments as? [String: Any] + let name = args?["name"] as? String ?? "Peloton PTT" + let uuid = args?["uuid"] as? String + let manager = (self?.systemPTTManager as? PTTSystemManager) + ?? PTTSystemManager(channel: pttChannel) + self?.systemPTTManager = manager + manager.joinChannel(name: name, uuidString: uuid, result: result) + } else { + result(FlutterError(code: "UNSUPPORTED_OS", message: "PushToTalk requires iOS 16+", details: nil)) + } + case "leavePTTChannel": + if #available(iOS 16.0, *), let manager = self?.systemPTTManager as? PTTSystemManager { + manager.leaveChannel(result: result) + } else { + result(nil) + } + case "beginSystemPTTTransmit": + if #available(iOS 16.0, *), let manager = self?.systemPTTManager as? PTTSystemManager { + manager.beginTransmitting(result: result) + } else { + result(FlutterError(code: "NO_MANAGER", message: "PTT manager not initialised", details: nil)) + } + case "stopSystemPTTTransmit": + if #available(iOS 16.0, *), let manager = self?.systemPTTManager as? PTTSystemManager { + manager.stopTransmitting(result: result) + } else { + result(nil) + } default: result(FlutterMethodNotImplemented) } @@ -38,6 +92,7 @@ import AVFoundation setupAudioSession() setupRemoteCommandCenter(channel: pttChannel) + configureButtonListeners(button: pttButton, channel: pttChannel) // Add notification observers for additional debugging NotificationCenter.default.addObserver( @@ -87,7 +142,7 @@ import AVFoundation private func setupRemoteCommandCenter(channel: FlutterMethodChannel) { let commandCenter = MPRemoteCommandCenter.shared() - + // Disable ALL commands first to clear any existing handlers commandCenter.playCommand.isEnabled = false commandCenter.pauseCommand.isEnabled = false @@ -96,12 +151,16 @@ import AVFoundation commandCenter.previousTrackCommand.isEnabled = false commandCenter.skipForwardCommand.isEnabled = false commandCenter.skipBackwardCommand.isEnabled = false - + // Remove all existing targets commandCenter.playCommand.removeTarget(nil) commandCenter.pauseCommand.removeTarget(nil) commandCenter.togglePlayPauseCommand.removeTarget(nil) - + + // Note: iOS doesn't allow apps to intercept long-press for Siri + // Long-press will still trigger Siri due to system-level handling + // Users should use single-press in toggle mode as workaround + // Now enable only the commands we want with higher priority commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.playCommand.isEnabled = true @@ -202,4 +261,131 @@ import AVFoundation channel.invokeMethod("pttReleased", arguments: nil) } } + + private func configureButtonListeners(button: String, channel: FlutterMethodChannel) { + // Clean up existing volume button observer + volumeButtonObserver?.stopObserving() + volumeButtonObserver = nil + + switch button { + case "volumeDown", "volumeUp": + // Setup volume button observer + volumeButtonObserver = VolumeButtonObserver( + targetButton: button, + onPress: { [weak self] in + self?.handlePTTButtonEvent(channel: channel) + } + ) + volumeButtonObserver?.startObserving() + print("Volume button observer configured for: \(button)") + case "headsetNext": + // Already handled by MPRemoteCommandCenter + print("Headset next track button configured") + case "headsetPrevious": + // Already handled by MPRemoteCommandCenter + print("Headset previous track button configured") + case "headsetPlayPause": + // Already handled by MPRemoteCommandCenter + print("Headset play/pause button configured") + case "systemPTT": + // Handled via PTChannelManager once Flutter calls joinPTTChannel. + // Disable MPRemoteCommandCenter targets so we don't double-fire on headset taps. + let cc = MPRemoteCommandCenter.shared() + cc.togglePlayPauseCommand.isEnabled = false + cc.playCommand.isEnabled = false + cc.pauseCommand.isEnabled = false + print("System PTT selected — accessory events will route through PushToTalk framework") + default: + print("Button type \(button) uses default configuration") + } + } +} + +// Volume Button Observer for iOS +class VolumeButtonObserver { + private let targetButton: String + private let onPress: () -> Void + private var audioSession: AVAudioSession? + private var initialVolume: Float = 0.5 + private var isObserving = false + + init(targetButton: String, onPress: @escaping () -> Void) { + self.targetButton = targetButton + self.onPress = onPress + } + + func startObserving() { + guard !isObserving else { return } + + do { + audioSession = AVAudioSession.sharedInstance() + + // Store initial volume + initialVolume = audioSession?.outputVolume ?? 0.5 + + // Configure audio session to allow volume observation + try audioSession?.setCategory(.ambient, options: [.mixWithOthers]) + try audioSession?.setActive(true) + + // Observe volume changes + audioSession?.addObserver( + self, + forKeyPath: "outputVolume", + options: [.new, .old], + context: nil + ) + + isObserving = true + print("Volume button observer started for: \(targetButton)") + } catch { + print("Failed to setup volume button observer: \(error)") + } + } + + func stopObserving() { + guard isObserving else { return } + + audioSession?.removeObserver(self, forKeyPath: "outputVolume") + isObserving = false + print("Volume button observer stopped") + } + + override func observeValue( + forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey : Any]?, + context: UnsafeMutableRawPointer? + ) { + if keyPath == "outputVolume" { + guard let newValue = change?[.newKey] as? Float, + let oldValue = change?[.oldKey] as? Float else { return } + + let volumeChanged = abs(newValue - oldValue) > 0.001 + + if volumeChanged { + let isVolumeUp = newValue > oldValue + let matchesTarget = (isVolumeUp && targetButton == "volumeUp") || + (!isVolumeUp && targetButton == "volumeDown") + + if matchesTarget { + print("Volume button pressed: \(targetButton)") + onPress() + + // Reset volume to prevent actual volume change + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + if let initialVol = self?.initialVolume { + let volumeView = MPVolumeView(frame: .zero) + if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { + slider.value = initialVol + } + } + } + } + } + } + } + + deinit { + stopObserving() + } } diff --git a/packages/mobile/ios/Runner/Info.plist b/packages/mobile/ios/Runner/Info.plist index 993b80b..99f1f1a 100644 --- a/packages/mobile/ios/Runner/Info.plist +++ b/packages/mobile/ios/Runner/Info.plist @@ -55,6 +55,7 @@ audio bluetooth-central + push-to-talk UIApplicationSupportsIndirectInputEvents diff --git a/packages/mobile/ios/Runner/PTTSystemManager.swift b/packages/mobile/ios/Runner/PTTSystemManager.swift new file mode 100644 index 0000000..1bab389 --- /dev/null +++ b/packages/mobile/ios/Runner/PTTSystemManager.swift @@ -0,0 +1,195 @@ +import Foundation +import Flutter +import AVFoundation +#if canImport(PushToTalk) +import PushToTalk +#endif + +@available(iOS 16.0, *) +final class PTTSystemManager: NSObject { + private let channel: FlutterMethodChannel + #if canImport(PushToTalk) + private var channelManager: PTChannelManager? + private var activeChannelUUID: UUID? + private var activeChannelName: String = "Peloton PTT" + #endif + + init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + } + + func joinChannel(name: String, uuidString: String?, result: @escaping FlutterResult) { + #if canImport(PushToTalk) + guard #available(iOS 16.0, *) else { + result(FlutterError(code: "UNSUPPORTED_OS", message: "PushToTalk requires iOS 16+", details: nil)) + return + } + activeChannelName = name + let uuid = uuidString.flatMap(UUID.init(uuidString:)) ?? UUID() + activeChannelUUID = uuid + + let join: () -> Void = { [weak self] in + guard let self, let manager = self.channelManager else { + result(FlutterError(code: "NO_MANAGER", message: "Channel manager not ready", details: nil)) + return + } + let descriptor = PTChannelDescriptor(name: name, image: nil) + manager.requestJoinChannel(channelUUID: uuid, descriptor: descriptor) { error in + if let error { + result(FlutterError(code: "JOIN_FAILED", message: error.localizedDescription, details: nil)) + } else { + manager.setAccessoryButtonEventsEnabled(true, channelUUID: uuid) { err in + if let err { NSLog("PTT accessory enable failed: \(err.localizedDescription)") } + } + result(nil) + } + } + } + + if channelManager == nil { + PTChannelManager.channelManager(delegate: self, restorationDelegate: self) { [weak self] manager, error in + if let error { + result(FlutterError(code: "MANAGER_INIT_FAILED", message: error.localizedDescription, details: nil)) + return + } + self?.channelManager = manager + join() + } + } else { + join() + } + #else + result(FlutterError(code: "PTT_UNAVAILABLE", message: "PushToTalk framework not available in SDK", details: nil)) + #endif + } + + func leaveChannel(result: @escaping FlutterResult) { + #if canImport(PushToTalk) + guard let manager = channelManager, let uuid = activeChannelUUID else { + result(nil) + return + } + manager.leaveChannel(channelUUID: uuid) { error in + if let error { + result(FlutterError(code: "LEAVE_FAILED", message: error.localizedDescription, details: nil)) + } else { + result(nil) + } + } + activeChannelUUID = nil + #else + result(nil) + #endif + } + + func beginTransmitting(result: @escaping FlutterResult) { + #if canImport(PushToTalk) + guard let manager = channelManager, let uuid = activeChannelUUID else { + result(FlutterError(code: "NO_CHANNEL", message: "No active PTT channel", details: nil)) + return + } + manager.requestBeginTransmitting(channelUUID: uuid) { error in + if let error { + result(FlutterError(code: "TX_BEGIN_FAILED", message: error.localizedDescription, details: nil)) + } else { + result(nil) + } + } + #else + result(nil) + #endif + } + + func stopTransmitting(result: @escaping FlutterResult) { + #if canImport(PushToTalk) + guard let manager = channelManager, let uuid = activeChannelUUID else { + result(nil) + return + } + manager.stopTransmitting(channelUUID: uuid) + result(nil) + #else + result(nil) + #endif + } +} + +#if canImport(PushToTalk) +@available(iOS 16.0, *) +extension PTTSystemManager: PTChannelManagerDelegate { + func channelManager(_ channelManager: PTChannelManager, + receivedEphemeralPushToken pushToken: Data) { + let hex = pushToken.map { String(format: "%02x", $0) }.joined() + NSLog("PTT ephemeral push token: \(hex)") + } + + func channelManager(_ channelManager: PTChannelManager, + didJoinChannel channelUUID: UUID, + reason: PTChannelJoinReason) { + NSLog("PTT joined channel \(channelUUID) reason=\(reason.rawValue)") + } + + func channelManager(_ channelManager: PTChannelManager, + didLeaveChannel channelUUID: UUID, + reason: PTChannelLeaveReason) { + NSLog("PTT left channel \(channelUUID) reason=\(reason.rawValue)") + } + + // Hardware / accessory button → begin transmitting + func channelManager(_ channelManager: PTChannelManager, + channelUUID: UUID, + didBeginTransmittingFrom source: PTChannelTransmitRequestSource) { + NSLog("PTT didBeginTransmitting source=\(source.rawValue)") + channel.invokeMethod("pttPressed", arguments: ["source": "systemPTT"]) + } + + func channelManager(_ channelManager: PTChannelManager, + channelUUID: UUID, + didEndTransmittingFrom source: PTChannelTransmitRequestSource) { + NSLog("PTT didEndTransmitting source=\(source.rawValue)") + channel.invokeMethod("pttReleased", arguments: ["source": "systemPTT"]) + } + + func channelManager(_ channelManager: PTChannelManager, + didActivate audioSession: AVAudioSession) { + NSLog("PTT audio session activated") + } + + func channelManager(_ channelManager: PTChannelManager, + didDeactivate audioSession: AVAudioSession) { + NSLog("PTT audio session deactivated") + } + + func channelManager(_ channelManager: PTChannelManager, + failedToJoinChannel channelUUID: UUID, + error: Error) { + NSLog("PTT failed to join: \(error.localizedDescription)") + } + + func channelManager(_ channelManager: PTChannelManager, + failedToLeaveChannel channelUUID: UUID, + error: Error) { + NSLog("PTT failed to leave: \(error.localizedDescription)") + } + + func channelManager(_ channelManager: PTChannelManager, + failedToBeginTransmittingInChannel channelUUID: UUID, + error: Error) { + NSLog("PTT failed to begin transmitting: \(error.localizedDescription)") + } + + func channelManager(_ channelManager: PTChannelManager, + failedToStopTransmittingInChannel channelUUID: UUID, + error: Error) { + NSLog("PTT failed to stop transmitting: \(error.localizedDescription)") + } +} + +@available(iOS 16.0, *) +extension PTTSystemManager: PTChannelRestorationDelegate { + func channelDescriptor(restoredChannelUUID channelUUID: UUID) -> PTChannelDescriptor { + return PTChannelDescriptor(name: activeChannelName, image: nil) + } +} +#endif diff --git a/packages/mobile/ios/Runner/Runner.entitlements b/packages/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..5f1e1e0 --- /dev/null +++ b/packages/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.push-to-talk + + aps-environment + development + + diff --git a/packages/mobile/lib/models/ptt_state.dart b/packages/mobile/lib/models/ptt_state.dart index 2b2cbf3..1262607 100644 --- a/packages/mobile/lib/models/ptt_state.dart +++ b/packages/mobile/lib/models/ptt_state.dart @@ -8,6 +8,45 @@ enum PTTMode { hold, // Long press and hold to record, release to stop (traditional PTT) } +enum PTTButton { + // On-screen + onScreen, + + // Volume buttons. Device buttons support both modes; Bluetooth headset volume + // buttons arrive as discrete AVRCP steps, so they are toggle-only. + volume, + + // Play/Pause button (Bluetooth headset + wired headset) + playPause, + + // iOS 16+ PushToTalk framework + systemPTT, // iOS 16+ only +} + +class PTTConfiguration { + final PTTMode mode; + final PTTButton button; + final bool preventScreenLock; + + const PTTConfiguration({ + this.mode = PTTMode.toggle, + this.button = PTTButton.volume, + this.preventScreenLock = true, + }); + + PTTConfiguration copyWith({ + PTTMode? mode, + PTTButton? button, + bool? preventScreenLock, + }) { + return PTTConfiguration( + mode: mode ?? this.mode, + button: button ?? this.button, + preventScreenLock: preventScreenLock ?? this.preventScreenLock, + ); + } +} + extension PTTStateExtension on PTTState { bool get isActive => this == PTTState.active; bool get isIdle => this == PTTState.idle; @@ -23,3 +62,53 @@ extension PTTModeExtension on PTTMode { ? 'Press once to start, press again to stop' : 'Hold button to record, release to stop'; } + +extension PTTButtonExtension on PTTButton { + String get displayName { + switch (this) { + case PTTButton.onScreen: + return 'On-Screen Button'; + case PTTButton.volume: + return 'Volume Buttons'; + case PTTButton.playPause: + return 'Play/Pause Button'; + case PTTButton.systemPTT: + return 'System PTT (iOS 16+)'; + } + } + + bool get isAvailableOnAndroid { + return this != PTTButton.systemPTT; + } + + String get description { + switch (this) { + case PTTButton.onScreen: + return 'Large on-screen button (works everywhere)'; + case PTTButton.volume: + return 'Volume buttons on the device, wired headsets, and Bluetooth headsets (Bluetooth volume buttons toggle only)'; + case PTTButton.playPause: + return 'Play/pause button on Bluetooth or wired headsets'; + case PTTButton.systemPTT: + return 'System PTT interface - works from lock screen (iOS 16+)'; + } + } + + String get icon { + switch (this) { + case PTTButton.onScreen: + return '📱'; + case PTTButton.volume: + return '🔊'; + case PTTButton.playPause: + return '🎧'; + case PTTButton.systemPTT: + return '🍎'; + } + } + + bool get requiresBackgroundService { + // Volume buttons need accessibility service for background on Android + return this == PTTButton.volume; + } +} diff --git a/packages/mobile/lib/models/riding_group.dart b/packages/mobile/lib/models/riding_group.dart new file mode 100644 index 0000000..5e54cab --- /dev/null +++ b/packages/mobile/lib/models/riding_group.dart @@ -0,0 +1,32 @@ +/// A riding group within a club — the unit that maps 1:1 to a WebRTC room. +class RidingGroup { + final String id; + final String name; + const RidingGroup({required this.id, required this.name}); +} + +/// A club that owns a fixed set of riding groups. +class Club { + final String id; + final String name; + final List groups; + const Club({required this.id, required this.name, required this.groups}); + + /// Room id for [group], e.g. 'BBB:A1'. Distinct groups => isolated rooms. + String roomIdFor(RidingGroup group) => '$id:${group.id}'; +} + +/// The single MVP club: BBB with 7 fixed groups. +const Club bbbClub = Club( + id: 'BBB', + name: 'BBB', + groups: [ + RidingGroup(id: 'A1', name: 'A1'), + RidingGroup(id: 'A2', name: 'A2'), + RidingGroup(id: 'A3', name: 'A3'), + RidingGroup(id: 'A4', name: 'A4'), + RidingGroup(id: 'B1', name: 'B1'), + RidingGroup(id: 'B2', name: 'B2'), + RidingGroup(id: 'B3', name: 'B3'), + ], +); diff --git a/packages/mobile/lib/services/ptt_service.dart b/packages/mobile/lib/services/ptt_service.dart index 048b6e0..d00d7b7 100644 --- a/packages/mobile/lib/services/ptt_service.dart +++ b/packages/mobile/lib/services/ptt_service.dart @@ -1,18 +1,54 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../models/ptt_state.dart'; +import 'recorder_service.dart'; class PTTService extends ChangeNotifier { static const platform = MethodChannel('com.example.peloton/ptt'); PTTState _state = PTTState.idle; - PTTMode _mode = PTTMode.toggle; // Default to toggle mode (working mode) + PTTConfiguration _config = const PTTConfiguration(); + final RecorderService _recorder; PTTState get state => _state; - PTTMode get mode => _mode; + PTTMode get mode => _config.mode; + PTTButton get button => _config.button; + PTTConfiguration get config => _config; - PTTService() { + PTTService({RecorderService? recorder}) + : _recorder = recorder ?? RecorderService() { _initializeNativeCommunication(); + _initializeWakeLock(); + _pushInitialConfigToNative(); + } + + // The native side keeps its own copy of pttMode/pttButton with defaults that may not + // match the Dart defaults. Sync once at startup so the first headset press is handled + // with the correct configuration instead of native defaults. + void _pushInitialConfigToNative() async { + try { + await platform.invokeMethod('updatePTTConfiguration', { + 'mode': _config.mode.name, + 'button': _config.button.name, + 'preventScreenLock': _config.preventScreenLock, + }); + debugPrint( + 'PTT initial config pushed to native: mode=${_config.mode.name}, button=${_config.button.name}'); + } catch (e) { + debugPrint('Error pushing initial PTT config to native: $e'); + } + } + + void _initializeWakeLock() async { + if (_config.preventScreenLock) { + try { + await WakelockPlus.enable(); + debugPrint('WakeLock enabled'); + } catch (e) { + debugPrint('Failed to enable WakeLock: $e'); + } + } } void _initializeNativeCommunication() { @@ -37,28 +73,91 @@ class PTTService extends ChangeNotifier { void _setState(PTTState newState) { if (_state != newState) { _state = newState; - debugPrint('PTT State changed to: $_state (Mode: ${_mode.displayName})'); + debugPrint( + 'PTT State changed to: $_state (Mode: ${_config.mode.displayName}, Button: ${_config.button.displayName})'); + // Drive recording lifecycle from state transitions so every press/release + // path (system PTT, headset, on-screen, manual) goes through one place. + if (newState == PTTState.active) { + _recorder.startRecording(); + } else { + _recorder.stopAndPlayback(); + } notifyListeners(); } } - void setMode(PTTMode newMode) { - if (_mode != newMode) { - // If switching modes while recording, stop recording + Future updateConfiguration(PTTConfiguration newConfig) async { + if (_config.mode != newConfig.mode || + _config.button != newConfig.button || + _config.preventScreenLock != newConfig.preventScreenLock) { + // If switching modes/buttons while recording, stop recording if (_state == PTTState.active) { _setState(PTTState.idle); } - _mode = newMode; - debugPrint('PTT Mode changed to: ${_mode.displayName}'); + final oldPreventScreenLock = _config.preventScreenLock; + _config = newConfig; + debugPrint( + 'PTT Configuration updated: Mode=${_config.mode.displayName}, Button=${_config.button.displayName}, PreventScreenLock=${_config.preventScreenLock}'); + + // Update WakeLock if the setting changed + if (oldPreventScreenLock != _config.preventScreenLock) { + try { + if (_config.preventScreenLock) { + await WakelockPlus.enable(); + debugPrint('WakeLock enabled'); + } else { + await WakelockPlus.disable(); + debugPrint('WakeLock disabled'); + } + } catch (e) { + debugPrint('Failed to update WakeLock: $e'); + } + } + + // Notify native platform about configuration change + try { + await platform.invokeMethod('updatePTTConfiguration', { + 'mode': _config.mode.name, + 'button': _config.button.name, + 'preventScreenLock': _config.preventScreenLock, + }); + } catch (e) { + debugPrint('Error updating native configuration: $e'); + } - // Notify Android about the mode change - platform.invokeMethod('setPTTMode', _mode.name); + // Manage iOS PushToTalk channel lifecycle when systemPTT is selected/deselected. + try { + if (_config.button == PTTButton.systemPTT) { + await platform.invokeMethod('joinPTTChannel', { + 'name': 'Peloton PTT', + }); + } else { + await platform.invokeMethod('leavePTTChannel'); + } + } catch (e) { + debugPrint('Error toggling system PTT channel: $e'); + } notifyListeners(); } } + void setMode(PTTMode newMode) { + updateConfiguration(_config.copyWith(mode: newMode)); + } + + void setButton(PTTButton newButton) { + // Native code forces toggle semantics for play/pause keycodes regardless of mode, + // so the user can keep hold mode (for volume buttons) without breaking play/pause. + // The settings UI still hides hold mode when play/pause is selected for clarity. + updateConfiguration(_config.copyWith(button: newButton)); + } + + void setPreventScreenLock(bool prevent) { + updateConfiguration(_config.copyWith(preventScreenLock: prevent)); + } + // Manual trigger for testing (fallback when native doesn't work) void manualPress() { _setState(PTTState.active); @@ -68,5 +167,9 @@ class PTTService extends ChangeNotifier { _setState(PTTState.idle); } - // No need to override dispose if we're not doing anything beyond super.dispose() + @override + void dispose() { + _recorder.dispose(); + super.dispose(); + } } diff --git a/packages/mobile/lib/services/recorder_service.dart b/packages/mobile/lib/services/recorder_service.dart new file mode 100644 index 0000000..cb194f4 --- /dev/null +++ b/packages/mobile/lib/services/recorder_service.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +/// Wraps microphone capture + local playback for the PTT loopback POC. +/// Press → [startRecording]. Release → [stopAndPlayback] which stops the +/// recorder and immediately plays the clip back through the device speaker. +class RecorderService { + final AudioRecorder _recorder = AudioRecorder(); + final AudioPlayer _player = AudioPlayer(); + String? _currentPath; + + Future startRecording() async { + if (await _recorder.isRecording()) return; + if (!await _recorder.hasPermission()) { + debugPrint('RecorderService: microphone permission denied'); + return; + } + + final dir = await getTemporaryDirectory(); + final path = '${dir.path}/ptt_${DateTime.now().millisecondsSinceEpoch}.m4a'; + _currentPath = path; + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + bitRate: 64000, + sampleRate: 16000, + numChannels: 1, + ), + path: path, + ); + debugPrint('RecorderService: recording → $path'); + } + + Future stopAndPlayback() async { + if (!await _recorder.isRecording()) return; + final path = await _recorder.stop(); + final clip = path ?? _currentPath; + _currentPath = null; + if (clip == null) return; + + final file = File(clip); + if (!await file.exists() || await file.length() == 0) { + debugPrint('RecorderService: empty clip, skipping playback'); + return; + } + + debugPrint('RecorderService: playing back $clip'); + await _player.stop(); + await _player.play(DeviceFileSource(clip)); + } + + Future dispose() async { + if (await _recorder.isRecording()) { + await _recorder.stop(); + } + await _recorder.dispose(); + await _player.dispose(); + } +} diff --git a/packages/mobile/lib/services/ride_session.dart b/packages/mobile/lib/services/ride_session.dart new file mode 100644 index 0000000..23591a3 --- /dev/null +++ b/packages/mobile/lib/services/ride_session.dart @@ -0,0 +1,68 @@ +import '../models/ptt_state.dart'; +import 'ptt_service.dart'; +import 'voice_transport.dart'; +import 'signaling_channel.dart'; + +/// Coordinates the three PTT subsystems into a live walkie-talkie session: +/// keeps a persistent, muted WebRTC connection and gates the mic on PTT state. +/// +/// Walkie-talkie invariant: the local mic track is muted whenever joined; +/// holding PTT is the only thing that unmutes it. +class RideSession { + final PTTService _ptt; + final VoiceTransport _transport; + final SignalingChannel _signaling; + + bool _joined = false; + bool get isJoined => _joined; + + RideSession({ + required PTTService ptt, + required VoiceTransport transport, + required SignalingChannel signaling, + }) : _ptt = ptt, + _transport = transport, + _signaling = signaling; + + /// Connect, come up MUTED, join [roomId], and start gating the mic on PTT. + Future join(String roomId) async { + if (_joined) return; + await _signaling.connect(); + await _transport.initializeLocalStream(); + _transport.setMuted(true); // silent until PTT is held + _signaling.joinRoom(roomId); + _signaling.addListener(_onSignalingChanged); + _ptt.addListener(_onPttChanged); + _joined = true; + } + + void _onSignalingChanged() { + // Offer to any peers already in the room when the roster arrives. + // connectToAllPeers is idempotent per peer, so repeat calls are safe. + if (_signaling.peerCount > 0) { + _transport.connectToAllPeers(); + } + } + + void _onPttChanged() { + if (_ptt.state.isActive) { + _transport.setMuted(false); + _signaling.startPTT(); + } else { + _transport.setMuted(true); + _signaling.endPTT(); + } + } + + /// Stop gating, leave the room, and tear down the connection. + Future leave() async { + if (!_joined) return; + _ptt.removeListener(_onPttChanged); + _signaling.removeListener(_onSignalingChanged); + _signaling.leaveRoom(); + await _transport.closeAllConnections(); + await _transport.disposeLocalStream(); + await _signaling.disconnect(); + _joined = false; + } +} diff --git a/packages/mobile/lib/services/signaling_channel.dart b/packages/mobile/lib/services/signaling_channel.dart new file mode 100644 index 0000000..f7f4c1a --- /dev/null +++ b/packages/mobile/lib/services/signaling_channel.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Minimal signaling surface RideSession needs. Implemented by SignalingClient +/// (a ChangeNotifier, hence Listenable); faked in tests. +abstract class SignalingChannel implements Listenable { + Future connect(); + void joinRoom(String roomId); + void leaveRoom(); + void startPTT(); + void endPTT(); + Future disconnect(); + + /// Number of peers currently known in the joined room. + int get peerCount; +} diff --git a/packages/mobile/lib/services/signaling_client.dart b/packages/mobile/lib/services/signaling_client.dart new file mode 100644 index 0000000..ea6472a --- /dev/null +++ b/packages/mobile/lib/services/signaling_client.dart @@ -0,0 +1,377 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'signaling_channel.dart'; + +/// Connection state for the signaling client +enum SignalingConnectionState { + disconnected, + connecting, + connected, + error, +} + +/// Represents a peer in the room +class Peer { + final String id; + final String userId; + final String? deviceInfo; + final int joinedAt; + + Peer({ + required this.id, + required this.userId, + this.deviceInfo, + required this.joinedAt, + }); + + factory Peer.fromJson(Map json) { + return Peer( + id: json['id'] as String, + userId: json['userId'] as String, + deviceInfo: json['deviceInfo'] as String?, + joinedAt: json['joinedAt'] as int? ?? 0, + ); + } +} + +/// WebRTC SDP description +class RTCSessionDescriptionData { + final String type; + final String sdp; + + RTCSessionDescriptionData({required this.type, required this.sdp}); + + factory RTCSessionDescriptionData.fromJson(Map json) { + return RTCSessionDescriptionData( + type: json['type'] as String, + sdp: json['sdp'] as String, + ); + } + + Map toJson() => {'type': type, 'sdp': sdp}; +} + +/// ICE candidate data +class RTCIceCandidateData { + final String candidate; + final String sdpMid; + final int sdpMLineIndex; + + RTCIceCandidateData({ + required this.candidate, + required this.sdpMid, + required this.sdpMLineIndex, + }); + + factory RTCIceCandidateData.fromJson(Map json) { + return RTCIceCandidateData( + candidate: json['candidate'] as String, + sdpMid: json['sdpMid'] as String, + sdpMLineIndex: json['sdpMLineIndex'] as int, + ); + } + + Map toJson() => { + 'candidate': candidate, + 'sdpMid': sdpMid, + 'sdpMLineIndex': sdpMLineIndex, + }; +} + +/// Signaling client for WebRTC peer coordination +class SignalingClient extends ChangeNotifier implements SignalingChannel { + WebSocketChannel? _channel; + final String _serverUrl; + final String _userId; + final String? _deviceInfo; + + SignalingConnectionState _connectionState = + SignalingConnectionState.disconnected; + String? _currentRoomId; + final List _peers = []; + final Map _talkingPeers = {}; + + // Callbacks for WebRTC events + Function(String peerId, RTCSessionDescriptionData description)? onOffer; + Function(String peerId, RTCSessionDescriptionData description)? onAnswer; + Function(String peerId, RTCIceCandidateData candidate)? onCandidate; + Function(Peer peer)? onPeerJoined; + Function(String peerId)? onPeerLeft; + Function(String peerId, bool isTalking)? onPeerTalking; + Function(List peers)? onPeersUpdated; + Function(String code, String message)? onError; + + SignalingClient({ + required String serverUrl, + required String userId, + String? deviceInfo, + }) : _serverUrl = serverUrl, + _userId = userId, + _deviceInfo = deviceInfo; + + SignalingConnectionState get connectionState => _connectionState; + String? get currentRoomId => _currentRoomId; + String get userId => _userId; + List get peers => List.unmodifiable(_peers); + @override + int get peerCount => _peers.length; + bool isPeerTalking(String peerId) => _talkingPeers[peerId] ?? false; + + /// Connect to the signaling server + @override + Future connect() async { + if (_connectionState == SignalingConnectionState.connecting || + _connectionState == SignalingConnectionState.connected) { + return; + } + + _setConnectionState(SignalingConnectionState.connecting); + + try { + final uri = Uri.parse( + '$_serverUrl/ws?userId=$_userId&deviceInfo=${Uri.encodeComponent(_deviceInfo ?? '')}'); + debugPrint('Connecting to signaling server: $uri'); + + _channel = WebSocketChannel.connect(uri); + + // Listen for messages + _channel!.stream.listen( + _handleMessage, + onError: (error) { + debugPrint('WebSocket error: $error'); + _setConnectionState(SignalingConnectionState.error); + onError?.call('connection_error', error.toString()); + }, + onDone: () { + debugPrint('WebSocket connection closed'); + _setConnectionState(SignalingConnectionState.disconnected); + _currentRoomId = null; + _peers.clear(); + _talkingPeers.clear(); + }, + ); + + _setConnectionState(SignalingConnectionState.connected); + debugPrint('Connected to signaling server'); + } catch (e) { + debugPrint('Failed to connect to signaling server: $e'); + _setConnectionState(SignalingConnectionState.error); + onError?.call('connection_failed', e.toString()); + } + } + + /// Disconnect from the signaling server + @override + Future disconnect() async { + if (_currentRoomId != null) { + leaveRoom(); + } + await _channel?.sink.close(); + _channel = null; + _setConnectionState(SignalingConnectionState.disconnected); + _peers.clear(); + _talkingPeers.clear(); + notifyListeners(); + } + + /// Join a signaling room + @override + void joinRoom(String roomId) { + _send('join_room', { + 'roomId': roomId, + 'userId': _userId, + 'deviceInfo': _deviceInfo, + }); + _currentRoomId = roomId; + notifyListeners(); + } + + /// Leave the current room + @override + void leaveRoom() { + if (_currentRoomId != null) { + _send('leave_room', {'roomId': _currentRoomId}); + _currentRoomId = null; + _peers.clear(); + _talkingPeers.clear(); + notifyListeners(); + } + } + + /// Send a WebRTC offer to a peer + void sendOffer(String toPeerId, String sessionId, + RTCSessionDescriptionData description) { + _send('offer', { + 'to': toPeerId, + 'sessionId': sessionId, + 'description': description.toJson(), + }); + } + + /// Send a WebRTC answer to a peer + void sendAnswer(String toPeerId, String sessionId, + RTCSessionDescriptionData description) { + _send('answer', { + 'to': toPeerId, + 'sessionId': sessionId, + 'description': description.toJson(), + }); + } + + /// Send an ICE candidate to a peer + void sendCandidate( + String toPeerId, String sessionId, RTCIceCandidateData candidate) { + _send('candidate', { + 'to': toPeerId, + 'sessionId': sessionId, + 'candidate': candidate.toJson(), + }); + } + + /// Signal that PTT is starting (user is talking) + @override + void startPTT() { + _send('ptt_start', {'roomId': _currentRoomId}); + } + + /// Signal that PTT is ending (user stopped talking) + @override + void endPTT() { + _send('ptt_end', {'roomId': _currentRoomId}); + } + + void _send(String type, Map data) { + if (_channel == null) { + debugPrint('Cannot send message: not connected'); + return; + } + + final message = jsonEncode({'type': type, 'data': data}); + debugPrint('Sending: $message'); + _channel!.sink.add(message); + } + + void _handleMessage(dynamic message) { + try { + final decoded = jsonDecode(message as String) as Map; + final type = decoded['type'] as String; + final data = decoded['data'] as Map; + + debugPrint('Received message: $type'); + + switch (type) { + case 'peers': + _handlePeers(data); + break; + case 'peer_joined': + _handlePeerJoined(data); + break; + case 'peer_left': + _handlePeerLeft(data); + break; + case 'offer': + _handleOffer(data); + break; + case 'answer': + _handleAnswer(data); + break; + case 'candidate': + _handleCandidate(data); + break; + case 'peer_talking': + _handlePeerTalking(data); + break; + case 'error': + _handleError(data); + break; + default: + debugPrint('Unknown message type: $type'); + } + } catch (e) { + debugPrint('Error handling message: $e'); + } + } + + void _handlePeers(Map data) { + final peersList = (data['peers'] as List).cast>(); + _peers.clear(); + for (final peerJson in peersList) { + _peers.add(Peer.fromJson(peerJson)); + } + onPeersUpdated?.call(_peers); + notifyListeners(); + } + + void _handlePeerJoined(Map data) { + final peer = Peer.fromJson(data['peer'] as Map); + _peers.add(peer); + onPeerJoined?.call(peer); + notifyListeners(); + } + + void _handlePeerLeft(Map data) { + final peerId = data['peerId'] as String; + _peers.removeWhere((p) => p.id == peerId); + _talkingPeers.remove(peerId); + onPeerLeft?.call(peerId); + notifyListeners(); + } + + void _handleOffer(Map data) { + final from = data['from'] as String; + final description = RTCSessionDescriptionData.fromJson( + data['description'] as Map); + onOffer?.call(from, description); + } + + void _handleAnswer(Map data) { + final from = data['from'] as String; + final description = RTCSessionDescriptionData.fromJson( + data['description'] as Map); + onAnswer?.call(from, description); + } + + void _handleCandidate(Map data) { + final from = data['from'] as String; + final candidate = + RTCIceCandidateData.fromJson(data['candidate'] as Map); + onCandidate?.call(from, candidate); + } + + void _handlePeerTalking(Map data) { + final peerId = data['peerId'] as String; + final isTalking = data['isTalking'] as bool; + _talkingPeers[peerId] = isTalking; + onPeerTalking?.call(peerId, isTalking); + notifyListeners(); + } + + void _handleError(Map data) { + final code = data['code'] as String; + final message = data['message'] as String; + debugPrint('Server error: $code - $message'); + onError?.call(code, message); + } + + void _setConnectionState(SignalingConnectionState state) { + if (_connectionState != state) { + _connectionState = state; + notifyListeners(); + } + } + + @override + void dispose() { + disconnect(); + super.dispose(); + } +} + +/// Utility to generate session IDs +String generateSessionId(String peerId1, String peerId2) { + // Sort to ensure consistent session IDs regardless of who initiates + final ids = [peerId1, peerId2]..sort(); + return '${ids[0]}-${ids[1]}'; +} diff --git a/packages/mobile/lib/services/voice_transport.dart b/packages/mobile/lib/services/voice_transport.dart new file mode 100644 index 0000000..714e850 --- /dev/null +++ b/packages/mobile/lib/services/voice_transport.dart @@ -0,0 +1,9 @@ +/// Minimal transport surface RideSession needs to bring up, gate, and route +/// audio. Implemented by WebRTCService; faked in tests. +abstract class VoiceTransport { + Future initializeLocalStream(); + void setMuted(bool muted); + Future connectToAllPeers(); + Future closeAllConnections(); + Future disposeLocalStream(); +} diff --git a/packages/mobile/lib/services/webrtc_service.dart b/packages/mobile/lib/services/webrtc_service.dart new file mode 100644 index 0000000..11b3780 --- /dev/null +++ b/packages/mobile/lib/services/webrtc_service.dart @@ -0,0 +1,402 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'signaling_client.dart'; +import 'voice_transport.dart'; + +/// Connection state for a peer connection +enum PeerConnectionState { + new_, + connecting, + connected, + disconnected, + failed, + closed, +} + +/// Configuration for WebRTC connections +class WebRTCConfig { + final List> iceServers; + final bool audioOnly; + + const WebRTCConfig({ + this.iceServers = const [ + {'urls': 'stun:stun.l.google.com:19302'}, + {'urls': 'stun:stun1.l.google.com:19302'}, + {'urls': 'stun:stun2.l.google.com:19302'}, + ], + this.audioOnly = true, + }); + + Map toConfiguration() => { + 'iceServers': iceServers, + 'sdpSemantics': 'unified-plan', + }; +} + +/// Represents a WebRTC peer connection with another user +class PeerConnection { + final String peerId; + final String sessionId; + final RTCPeerConnection connection; + final List pendingCandidates = []; + PeerConnectionState state = PeerConnectionState.new_; + MediaStream? remoteStream; + + PeerConnection({ + required this.peerId, + required this.sessionId, + required this.connection, + }); +} + +/// WebRTC service for managing peer connections and audio streams +class WebRTCService extends ChangeNotifier implements VoiceTransport { + final SignalingClient _signaling; + final WebRTCConfig _config; + + MediaStream? _localStream; + final Map _peerConnections = {}; + bool _isMuted = false; + + // Callbacks + Function(String peerId, MediaStream stream)? onRemoteStream; + Function(String peerId)? onPeerDisconnected; + + WebRTCService({ + required SignalingClient signaling, + WebRTCConfig config = const WebRTCConfig(), + }) : _signaling = signaling, + _config = config { + _setupSignalingCallbacks(); + } + + MediaStream? get localStream => _localStream; + bool get isMuted => _isMuted; + List get connectedPeerIds => _peerConnections.keys.toList(); + + void _setupSignalingCallbacks() { + _signaling.onOffer = _handleOffer; + _signaling.onAnswer = _handleAnswer; + _signaling.onCandidate = _handleCandidate; + _signaling.onPeerJoined = _handlePeerJoined; + _signaling.onPeerLeft = _handlePeerLeft; + } + + /// Initialize the local audio stream + @override + Future initializeLocalStream() async { + if (_localStream != null) return; + + final constraints = { + 'audio': { + 'echoCancellation': true, + 'noiseSuppression': true, + 'autoGainControl': true, + }, + 'video': false, + }; + + try { + _localStream = await navigator.mediaDevices.getUserMedia(constraints); + debugPrint('Local audio stream initialized'); + notifyListeners(); + } catch (e) { + debugPrint('Failed to get local stream: $e'); + rethrow; + } + } + + /// Dispose of the local audio stream + @override + Future disposeLocalStream() async { + if (_localStream != null) { + for (final track in _localStream!.getTracks()) { + await track.stop(); + } + await _localStream!.dispose(); + _localStream = null; + notifyListeners(); + } + } + + /// Toggle mute state + void toggleMute() { + if (_localStream != null) { + _isMuted = !_isMuted; + for (final track in _localStream!.getAudioTracks()) { + track.enabled = !_isMuted; + } + notifyListeners(); + } + } + + /// Set mute state + @override + void setMuted(bool muted) { + if (_localStream != null && _isMuted != muted) { + _isMuted = muted; + for (final track in _localStream!.getAudioTracks()) { + track.enabled = !_isMuted; + } + notifyListeners(); + } + } + + /// Connect to all peers in the room + @override + Future connectToAllPeers() async { + for (final peer in _signaling.peers) { + await _createOfferForPeer(peer.id); + } + } + + /// Create a peer connection and send an offer + Future _createOfferForPeer(String peerId) async { + if (_peerConnections.containsKey(peerId)) { + debugPrint('Already connected to peer: $peerId'); + return; + } + + final sessionId = generateSessionId(_signaling.userId, peerId); + final pc = await _createPeerConnection(peerId, sessionId); + + // Add local tracks + if (_localStream != null) { + for (final track in _localStream!.getTracks()) { + await pc.connection.addTrack(track, _localStream!); + } + } + + // Create and send offer + final offer = await pc.connection.createOffer(); + await pc.connection.setLocalDescription(offer); + + _signaling.sendOffer( + peerId, + sessionId, + RTCSessionDescriptionData(type: offer.type!, sdp: offer.sdp!), + ); + + debugPrint('Sent offer to peer: $peerId'); + } + + /// Handle incoming offer + Future _handleOffer( + String peerId, RTCSessionDescriptionData description) async { + debugPrint('Received offer from: $peerId'); + + final sessionId = generateSessionId(_signaling.userId, peerId); + + // Get or create peer connection + PeerConnection pc; + if (_peerConnections.containsKey(peerId)) { + pc = _peerConnections[peerId]!; + } else { + pc = await _createPeerConnection(peerId, sessionId); + + // Add local tracks + if (_localStream != null) { + for (final track in _localStream!.getTracks()) { + await pc.connection.addTrack(track, _localStream!); + } + } + } + + // Set remote description + await pc.connection.setRemoteDescription( + RTCSessionDescription(description.sdp, description.type), + ); + + // Add any pending candidates + for (final candidate in pc.pendingCandidates) { + await pc.connection.addCandidate(candidate); + } + pc.pendingCandidates.clear(); + + // Create and send answer + final answer = await pc.connection.createAnswer(); + await pc.connection.setLocalDescription(answer); + + _signaling.sendAnswer( + peerId, + sessionId, + RTCSessionDescriptionData(type: answer.type!, sdp: answer.sdp!), + ); + + debugPrint('Sent answer to peer: $peerId'); + } + + /// Handle incoming answer + Future _handleAnswer( + String peerId, RTCSessionDescriptionData description) async { + debugPrint('Received answer from: $peerId'); + + final pc = _peerConnections[peerId]; + if (pc == null) { + debugPrint('No peer connection for: $peerId'); + return; + } + + await pc.connection.setRemoteDescription( + RTCSessionDescription(description.sdp, description.type), + ); + + // Add any pending candidates + for (final candidate in pc.pendingCandidates) { + await pc.connection.addCandidate(candidate); + } + pc.pendingCandidates.clear(); + } + + /// Handle incoming ICE candidate + Future _handleCandidate( + String peerId, RTCIceCandidateData candidateData) async { + debugPrint('Received ICE candidate from: $peerId'); + + final pc = _peerConnections[peerId]; + final candidate = RTCIceCandidate( + candidateData.candidate, + candidateData.sdpMid, + candidateData.sdpMLineIndex, + ); + + if (pc == null) { + // Store for later if we don't have a connection yet + debugPrint('Storing candidate for later: $peerId'); + return; + } + + if (pc.connection.signalingState == + RTCSignalingState.RTCSignalingStateStable || + pc.connection.signalingState == + RTCSignalingState.RTCSignalingStateHaveLocalOffer || + pc.connection.signalingState == + RTCSignalingState.RTCSignalingStateHaveRemoteOffer) { + try { + await pc.connection.addCandidate(candidate); + } catch (e) { + debugPrint('Failed to add candidate: $e'); + pc.pendingCandidates.add(candidate); + } + } else { + pc.pendingCandidates.add(candidate); + } + } + + /// Handle peer joined event + void _handlePeerJoined(Peer peer) { + debugPrint('Peer joined: ${peer.id}'); + // Initiate connection to the new peer + _createOfferForPeer(peer.id); + } + + /// Handle peer left event + void _handlePeerLeft(String peerId) { + debugPrint('Peer left: $peerId'); + _closePeerConnection(peerId); + } + + /// Create a new peer connection + Future _createPeerConnection( + String peerId, String sessionId) async { + final connection = await createPeerConnection(_config.toConfiguration()); + + final pc = PeerConnection( + peerId: peerId, + sessionId: sessionId, + connection: connection, + ); + + // Handle ICE candidates + connection.onIceCandidate = (candidate) { + if (candidate.candidate != null) { + _signaling.sendCandidate( + peerId, + sessionId, + RTCIceCandidateData( + candidate: candidate.candidate!, + sdpMid: candidate.sdpMid!, + sdpMLineIndex: candidate.sdpMLineIndex!, + ), + ); + } + }; + + // Handle connection state changes + connection.onConnectionState = (state) { + debugPrint('Connection state for $peerId: $state'); + switch (state) { + case RTCPeerConnectionState.RTCPeerConnectionStateConnecting: + pc.state = PeerConnectionState.connecting; + break; + case RTCPeerConnectionState.RTCPeerConnectionStateConnected: + pc.state = PeerConnectionState.connected; + break; + case RTCPeerConnectionState.RTCPeerConnectionStateDisconnected: + pc.state = PeerConnectionState.disconnected; + onPeerDisconnected?.call(peerId); + break; + case RTCPeerConnectionState.RTCPeerConnectionStateFailed: + pc.state = PeerConnectionState.failed; + onPeerDisconnected?.call(peerId); + break; + case RTCPeerConnectionState.RTCPeerConnectionStateClosed: + pc.state = PeerConnectionState.closed; + break; + default: + break; + } + notifyListeners(); + }; + + // Handle remote tracks + connection.onTrack = (event) { + debugPrint('Received track from $peerId: ${event.track.kind}'); + if (event.streams.isNotEmpty) { + pc.remoteStream = event.streams[0]; + onRemoteStream?.call(peerId, event.streams[0]); + notifyListeners(); + } + }; + + _peerConnections[peerId] = pc; + return pc; + } + + /// Close a peer connection + Future _closePeerConnection(String peerId) async { + final pc = _peerConnections.remove(peerId); + if (pc != null) { + await pc.connection.close(); + pc.remoteStream?.dispose(); + onPeerDisconnected?.call(peerId); + notifyListeners(); + } + } + + /// Close all peer connections + @override + Future closeAllConnections() async { + for (final peerId in _peerConnections.keys.toList()) { + await _closePeerConnection(peerId); + } + } + + /// Get the connection state for a peer + PeerConnectionState? getPeerState(String peerId) { + return _peerConnections[peerId]?.state; + } + + /// Get the remote stream for a peer + MediaStream? getRemoteStream(String peerId) { + return _peerConnections[peerId]?.remoteStream; + } + + @override + void dispose() { + closeAllConnections(); + disposeLocalStream(); + super.dispose(); + } +} diff --git a/packages/mobile/lib/ui/screens/call_screen.dart b/packages/mobile/lib/ui/screens/call_screen.dart new file mode 100644 index 0000000..961749e --- /dev/null +++ b/packages/mobile/lib/ui/screens/call_screen.dart @@ -0,0 +1,469 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/ptt_state.dart'; +import '../../services/ptt_service.dart'; +import '../../services/signaling_client.dart'; +import '../../services/webrtc_service.dart'; +import '../../services/voice_transport.dart'; +import '../../services/signaling_channel.dart'; +import '../../services/ride_session.dart'; + +class CallScreen extends StatefulWidget { + final String serverUrl; + final String roomId; + + /// Injected for tests; production builds create real services internally. + final VoiceTransport? transport; + final SignalingChannel? signaling; + + const CallScreen({ + super.key, + required this.serverUrl, + this.roomId = 'default', + this.transport, + this.signaling, + }); + + @override + State createState() => _CallScreenState(); +} + +class _CallScreenState extends State { + late final SignalingChannel _signaling; + late final VoiceTransport _transport; + RideSession? _session; + bool _isInitialized = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _initializeServices(); + } + + Future _initializeServices() async { + try { + if (widget.transport != null && widget.signaling != null) { + _signaling = widget.signaling!; + _transport = widget.transport!; + } else { + final client = SignalingClient( + serverUrl: widget.serverUrl, + userId: 'user_${DateTime.now().millisecondsSinceEpoch}', + deviceInfo: Theme.of(context).platform.name, + ); + _signaling = client; + _transport = WebRTCService(signaling: client); + } + + // Rebuild the peer list as signaling/peer-connection state changes. + _signaling.addListener(_onSignalingUpdate); + final transport = _transport; + if (transport is WebRTCService) { + transport.addListener(_onSignalingUpdate); + transport.onRemoteStream = (peerId, stream) { + if (mounted) setState(() {}); + }; + transport.onPeerDisconnected = (peerId) { + if (mounted) setState(() {}); + }; + } + + // RideSession owns connect/join, the muted-by-default invariant, and + // gating the mic on PTT state. + _session = RideSession( + ptt: context.read(), + transport: _transport, + signaling: _signaling, + ); + await _session!.join(widget.roomId); + + setState(() { + _isInitialized = true; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to initialize: $e'; + }); + } + } + + void _onSignalingUpdate() { + if (mounted) { + setState(() {}); + } + } + + @override + void dispose() { + _signaling.removeListener(_onSignalingUpdate); + final transport = _transport; + if (transport is WebRTCService) { + transport.removeListener(_onSignalingUpdate); + } + _session?.leave(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey[900], + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + 'Room: ${widget.roomId}', + style: const TextStyle(color: Colors.white), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.pop(context), + ), + actions: [ + // Connection status indicator + Container( + margin: const EdgeInsets.only(right: 16), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getConnectionColor(), + ), + ), + const SizedBox(width: 8), + Text( + _getConnectionText(), + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_errorMessage != null) { + return _buildError(); + } + + if (!_isInitialized) { + return _buildLoading(); + } + + return Column( + children: [ + // Peers list + Expanded(child: _buildPeersList()), + + // PTT controls + _buildPTTControls(), + ], + ); + } + + Widget _buildLoading() { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Colors.deepOrange), + SizedBox(height: 16), + Text( + 'Connecting...', + style: TextStyle(color: Colors.white70, fontSize: 16), + ), + ], + ), + ); + } + + Widget _buildError() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 64), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle(color: Colors.white70, fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + setState(() { + _errorMessage = null; + _isInitialized = false; + }); + _initializeServices(); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + Widget _buildPeersList() { + final signaling = _signaling; + final peers = + signaling is SignalingClient ? signaling.peers : const []; + + if (peers.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 80, + color: Colors.grey[700], + ), + const SizedBox(height: 16), + Text( + 'Waiting for others to join...', + style: TextStyle( + color: Colors.grey[500], + fontSize: 18, + ), + ), + const SizedBox(height: 8), + Text( + 'Share room code: ${widget.roomId}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: peers.length, + itemBuilder: (context, index) { + final peer = peers[index]; + final isTalking = signaling is SignalingClient + ? signaling.isPeerTalking(peer.id) + : false; + final transport = _transport; + final connectionState = + transport is WebRTCService ? transport.getPeerState(peer.id) : null; + + return Card( + color: Colors.grey[850], + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: Stack( + children: [ + CircleAvatar( + backgroundColor: isTalking ? Colors.green : Colors.grey[700], + child: Icon( + isTalking ? Icons.mic : Icons.person, + color: Colors.white, + ), + ), + if (isTalking) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.green, width: 2), + ), + ), + ), + ], + ), + title: Text( + peer.userId, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + peer.deviceInfo ?? 'Unknown device', + style: TextStyle(color: Colors.grey[500]), + ), + trailing: _buildConnectionBadge(connectionState), + ), + ); + }, + ); + } + + Widget _buildConnectionBadge(PeerConnectionState? state) { + Color color; + String text; + + switch (state) { + case PeerConnectionState.connected: + color = Colors.green; + text = 'Connected'; + break; + case PeerConnectionState.connecting: + color = Colors.orange; + text = 'Connecting'; + break; + case PeerConnectionState.disconnected: + case PeerConnectionState.failed: + color = Colors.red; + text = 'Disconnected'; + break; + default: + color = Colors.grey; + text = 'Pending'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color, width: 1), + ), + child: Text( + text, + style: TextStyle(color: color, fontSize: 12), + ), + ); + } + + Widget _buildPTTControls() { + return Consumer( + builder: (context, pttService, child) { + final isActive = pttService.state.isActive; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey[850], + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // PTT Button + GestureDetector( + onTapDown: pttService.button == PTTButton.onScreen && + pttService.mode.isToggle + ? (_) => pttService.manualPress() + : null, + onLongPressStart: pttService.button == PTTButton.onScreen && + pttService.mode.isHold + ? (_) => pttService.manualPress() + : null, + onLongPressEnd: pttService.button == PTTButton.onScreen && + pttService.mode.isHold + ? (_) => pttService.manualRelease() + : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: isActive ? 100 : 80, + height: isActive ? 100 : 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive ? Colors.green : Colors.deepOrange, + boxShadow: [ + BoxShadow( + color: (isActive ? Colors.green : Colors.deepOrange) + .withValues(alpha: 0.4), + blurRadius: isActive ? 30 : 15, + spreadRadius: isActive ? 5 : 2, + ), + ], + ), + child: Icon( + isActive ? Icons.mic : Icons.mic_none, + size: isActive ? 50 : 40, + color: Colors.white, + ), + ), + ), + + const SizedBox(height: 16), + + // Status text + Text( + isActive ? 'TRANSMITTING' : 'PUSH TO TALK', + style: TextStyle( + color: isActive ? Colors.green : Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + ), + ), + + const SizedBox(height: 8), + + // Mode indicator + Text( + pttService.button == PTTButton.onScreen + ? (pttService.mode.isToggle + ? 'Tap to toggle' + : 'Hold to talk') + : 'Using ${pttService.button.displayName}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + ); + }, + ); + } + + SignalingConnectionState get _connectionState { + final signaling = _signaling; + return signaling is SignalingClient + ? signaling.connectionState + : SignalingConnectionState.connected; + } + + Color _getConnectionColor() { + if (!_isInitialized) return Colors.grey; + + switch (_connectionState) { + case SignalingConnectionState.connected: + return Colors.green; + case SignalingConnectionState.connecting: + return Colors.orange; + case SignalingConnectionState.error: + return Colors.red; + case SignalingConnectionState.disconnected: + return Colors.grey; + } + } + + String _getConnectionText() { + if (!_isInitialized) return 'Initializing'; + + switch (_connectionState) { + case SignalingConnectionState.connected: + return 'Connected'; + case SignalingConnectionState.connecting: + return 'Connecting'; + case SignalingConnectionState.error: + return 'Error'; + case SignalingConnectionState.disconnected: + return 'Disconnected'; + } + } +} diff --git a/packages/mobile/lib/ui/screens/group_picker_screen.dart b/packages/mobile/lib/ui/screens/group_picker_screen.dart new file mode 100644 index 0000000..aab01bd --- /dev/null +++ b/packages/mobile/lib/ui/screens/group_picker_screen.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import '../../models/riding_group.dart'; +import 'call_screen.dart'; + +/// Lets a rider pick one of club BBB's 7 groups and a signaling server URL, +/// then hands the resulting room id to [onSelect]. When [onSelect] is null it +/// opens the CallScreen for that room. Selection is not persisted. +class GroupPickerScreen extends StatefulWidget { + final void Function(BuildContext context, String serverUrl, String roomId)? + onSelect; + + const GroupPickerScreen({super.key, this.onSelect}); + + @override + State createState() => _GroupPickerScreenState(); +} + +class _GroupPickerScreenState extends State { + final _serverController = TextEditingController(text: 'ws://localhost:8080'); + + @override + void dispose() { + _serverController.dispose(); + super.dispose(); + } + + void _select(RidingGroup group) { + final roomId = bbbClub.roomIdFor(group); + final serverUrl = _serverController.text; + (widget.onSelect ?? _openCallScreen)(context, serverUrl, roomId); + } + + void _openCallScreen(BuildContext context, String serverUrl, String roomId) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CallScreen(serverUrl: serverUrl, roomId: roomId), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar(title: Text('Club ${bbbClub.name} — pick a group')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _serverController, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + labelText: 'Server URL', + labelStyle: TextStyle(color: Colors.grey[400]), + ), + ), + const SizedBox(height: 16), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + for (final group in bbbClub.groups) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SizedBox( + width: double.infinity, + height: 64, + child: ElevatedButton( + key: Key('group_${group.id}'), + onPressed: () => _select(group), + child: Text( + group.name, + style: const TextStyle(fontSize: 24), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/mobile/lib/ui/screens/home_screen.dart b/packages/mobile/lib/ui/screens/home_screen.dart index d95ccf5..86e258e 100644 --- a/packages/mobile/lib/ui/screens/home_screen.dart +++ b/packages/mobile/lib/ui/screens/home_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/ptt_state.dart'; import '../../services/ptt_service.dart'; +import 'settings_screen.dart'; +import 'group_picker_screen.dart'; class HomeScreen extends StatelessWidget { const HomeScreen({super.key}); @@ -10,7 +12,30 @@ class HomeScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, - body: Center( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.call, color: Colors.white), + onPressed: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const GroupPickerScreen()), + ), + tooltip: 'Join Room', + ), + IconButton( + icon: const Icon(Icons.settings, color: Colors.white), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const SettingsScreen()), + ); + }, + ), + ], + ), + body: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -18,21 +43,64 @@ class HomeScreen extends StatelessWidget { builder: (context, pttService, child) { return Column( children: [ - // Main PTT Icon - Container( - width: 200, - height: 200, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: pttService.state.isActive - ? Colors.green - : Colors.red, - border: Border.all(color: Colors.white, width: 4), - ), - child: const Icon( - Icons.mic, - size: 100, - color: Colors.white, + // Main PTT Button (On-Screen PTT) + GestureDetector( + onTapDown: pttService.button == PTTButton.onScreen && + pttService.mode.isToggle + ? (_) => pttService.manualPress() + : null, + onLongPressStart: + pttService.button == PTTButton.onScreen && + pttService.mode.isHold + ? (_) => pttService.manualPress() + : null, + onLongPressEnd: pttService.button == PTTButton.onScreen && + pttService.mode.isHold + ? (_) => pttService.manualRelease() + : null, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: pttService.state.isActive + ? Colors.green + : Colors.red, + border: Border.all(color: Colors.white, width: 4), + boxShadow: pttService.button == PTTButton.onScreen + ? [ + BoxShadow( + color: pttService.state.isActive + ? Colors.green.withValues(alpha: 0.5) + : Colors.red.withValues(alpha: 0.5), + blurRadius: 20, + spreadRadius: 5, + ), + ] + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.mic, + size: 100, + color: Colors.white, + ), + if (pttService.button == PTTButton.onScreen) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + pttService.mode.isToggle ? 'TAP' : 'HOLD', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), ), ), @@ -50,6 +118,38 @@ class HomeScreen extends StatelessWidget { const SizedBox(height: 20), + // Current button configuration + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white10, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + pttService.button.icon, + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: 8), + Text( + pttService.button.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + Text( _getInstructionText(pttService), style: const TextStyle( @@ -113,10 +213,10 @@ class HomeScreen extends StatelessWidget { value ? PTTMode.hold : PTTMode.toggle, ); }, - activeColor: Colors.orange, + activeThumbColor: Colors.orange, inactiveThumbColor: Colors.green, - inactiveTrackColor: Colors.green.withOpacity( - 0.3, + inactiveTrackColor: Colors.green.withValues( + alpha: 0.3, ), ), ], @@ -160,14 +260,24 @@ class HomeScreen extends StatelessWidget { } String _getInstructionText(PTTService pttService) { + final buttonName = pttService.button == PTTButton.onScreen + ? 'on-screen button' + : pttService.button.displayName.toLowerCase(); + if (pttService.state.isActive) { return pttService.mode.isToggle - ? 'Press button again to stop recording' - : 'Release button to stop recording'; + ? 'Press $buttonName again to stop recording' + : 'Release $buttonName to stop recording'; } else { - return pttService.mode.isToggle - ? 'Press play/pause button to start recording' - : 'Hold play/pause button to record'; + if (pttService.button == PTTButton.onScreen) { + return pttService.mode.isToggle + ? 'Tap the button to start recording' + : 'Press and hold the button to record'; + } else { + return pttService.mode.isToggle + ? 'Press $buttonName to start recording' + : 'Hold $buttonName to record'; + } } } } diff --git a/packages/mobile/lib/ui/screens/settings_screen.dart b/packages/mobile/lib/ui/screens/settings_screen.dart new file mode 100644 index 0000000..9ced117 --- /dev/null +++ b/packages/mobile/lib/ui/screens/settings_screen.dart @@ -0,0 +1,352 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/ptt_state.dart'; +import '../../services/ptt_service.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.grey[900], + title: const Text('PTT Settings'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + ), + body: Consumer( + builder: (context, pttService, child) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // PTT Mode Section + _buildSectionTitle('PTT Mode'), + _buildModeSelector(pttService), + + const SizedBox(height: 32), + + // PTT Button Section + _buildSectionTitle('PTT Button'), + _buildButtonSelector(pttService), + + const SizedBox(height: 32), + + // Screen Lock Section + _buildSectionTitle('Screen Lock'), + _buildScreenLockSwitch(pttService), + + const SizedBox(height: 32), + + // Info Section + _buildInfoCard(), + ], + ); + }, + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + Widget _buildModeSelector(PTTService pttService) { + // Disable hold mode when play/pause button is selected + final isPlayPauseButton = pttService.button == PTTButton.playPause; + + return Container( + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: Column( + children: PTTMode.values.map((mode) { + final isSelected = pttService.mode == mode; + final isDisabled = isPlayPauseButton && mode == PTTMode.hold; + + return InkWell( + onTap: isDisabled ? null : () => pttService.setMode(mode), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? Colors.deepOrange.withValues(alpha: 0.2) + : Colors.transparent, + border: Border( + bottom: mode != PTTMode.values.last + ? const BorderSide(color: Colors.white10) + : BorderSide.none, + ), + ), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isDisabled + ? Colors.white24 + : (isSelected ? Colors.deepOrange : Colors.white54), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mode.displayName, + style: TextStyle( + color: isDisabled + ? Colors.white30 + : (isSelected ? Colors.white : Colors.white70), + fontSize: 16, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + isDisabled + ? 'Not available for play/pause button (use toggle mode)' + : mode.description, + style: TextStyle( + color: isDisabled ? Colors.white30 : Colors.white60, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildButtonSelector(PTTService pttService) { + // Get platform-specific available buttons + final availableButtons = PTTButton.values.where((button) { + if (Platform.isAndroid) { + return button.isAvailableOnAndroid; + } + // iOS: All buttons available except systemPTT handled above + return true; + }).toList(); + + return Container( + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + child: Column( + children: availableButtons.map((button) { + final isSelected = pttService.button == button; + final isLast = button == availableButtons.last; + + return InkWell( + onTap: () => pttService.setButton(button), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected + ? Colors.deepOrange.withValues(alpha: 0.2) + : Colors.transparent, + border: Border( + bottom: !isLast + ? const BorderSide(color: Colors.white10) + : BorderSide.none, + ), + ), + child: Row( + children: [ + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected ? Colors.deepOrange : Colors.white54, + ), + const SizedBox(width: 12), + Text( + button.icon, + style: const TextStyle(fontSize: 24), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + button.displayName, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 16, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + const SizedBox(height: 4), + Text( + button.description, + style: const TextStyle( + color: Colors.white60, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildScreenLockSwitch(PTTService pttService) { + return Container( + decoration: BoxDecoration( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white24), + ), + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon( + Icons.screen_lock_portrait, + color: Colors.white70, + size: 28, + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Prevent Screen Lock', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 4), + Text( + 'Keep screen on during rides', + style: TextStyle( + color: Colors.white60, + fontSize: 12, + ), + ), + ], + ), + ), + Switch( + value: pttService.config.preventScreenLock, + onChanged: (value) => pttService.setPreventScreenLock(value), + activeThumbColor: Colors.deepOrange, + ), + ], + ), + ); + } + + Widget _buildInfoCard() { + return Container( + decoration: BoxDecoration( + color: Colors.blue[900]?.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withValues(alpha: 0.5)), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + color: Colors.blue[300], + size: 24, + ), + const SizedBox(width: 8), + Text( + 'Platform Tips', + style: TextStyle( + color: Colors.blue[100], + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildInfoItem( + Platform.isAndroid ? '🤖 Android' : '🍎 iOS', + Platform.isAndroid + ? 'Volume and play/pause buttons work with device and headsets. Long-press is prevented to avoid Google Assistant. Note: BT headset volume buttons are unsupported (system limitation).' + : 'Volume and play/pause buttons recommended. Note: Long-press may trigger Siri - use toggle mode with quick taps.', + ), + if (Platform.isIOS) ...[ + const SizedBox(height: 8), + _buildInfoItem( + '⚠️ iOS Limitation', + 'Headset long-press cannot prevent Siri. Use toggle mode for best experience.', + ), + ], + ], + ), + ); + } + + Widget _buildInfoItem(String title, String description) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: Colors.blue[200], + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + color: Colors.blue[100], + fontSize: 12, + ), + ), + ], + ), + ); + } +} diff --git a/packages/mobile/linux/flutter/generated_plugin_registrant.cc b/packages/mobile/linux/flutter/generated_plugin_registrant.cc index e71a16d..b16524b 100644 --- a/packages/mobile/linux/flutter/generated_plugin_registrant.cc +++ b/packages/mobile/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,18 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); + flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); } diff --git a/packages/mobile/linux/flutter/generated_plugins.cmake b/packages/mobile/linux/flutter/generated_plugins.cmake index 2e1de87..d03192f 100644 --- a/packages/mobile/linux/flutter/generated_plugins.cmake +++ b/packages/mobile/linux/flutter/generated_plugins.cmake @@ -3,9 +3,13 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + flutter_webrtc + record_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/mobile/macos/Flutter/Flutter-Debug.xcconfig b/packages/mobile/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/packages/mobile/macos/Flutter/Flutter-Debug.xcconfig +++ b/packages/mobile/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/mobile/macos/Flutter/Flutter-Release.xcconfig b/packages/mobile/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/packages/mobile/macos/Flutter/Flutter-Release.xcconfig +++ b/packages/mobile/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..dcb4feb 100644 --- a/packages/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,16 @@ import FlutterMacOS import Foundation +import audioplayers_darwin +import flutter_webrtc +import package_info_plus +import record_darwin +import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/packages/mobile/macos/Podfile b/packages/mobile/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/packages/mobile/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/mobile/pubspec.yaml b/packages/mobile/pubspec.yaml index 293956e..e728090 100644 --- a/packages/mobile/pubspec.yaml +++ b/packages/mobile/pubspec.yaml @@ -33,7 +33,28 @@ dependencies: # State management provider: ^6.1.2 - + + # Wake lock to prevent screen from sleeping during rides + wakelock_plus: ^1.2.5 + + # WebRTC for P2P audio/video communication + flutter_webrtc: ^0.12.6 + + # WebSocket for signaling server communication + web_socket_channel: ^3.0.2 + + # UUID generation + uuid: ^4.5.1 + + # Microphone capture for PTT + record: ^5.1.2 + + # Local playback for the loopback POC (press → record → release → playback) + audioplayers: ^6.1.0 + + # Temp directory for recorded clips + path_provider: ^2.1.4 + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 diff --git a/packages/mobile/test/models/riding_group_test.dart b/packages/mobile/test/models/riding_group_test.dart new file mode 100644 index 0000000..2f0dc80 --- /dev/null +++ b/packages/mobile/test/models/riding_group_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/models/riding_group.dart'; + +void main() { + group('bbbClub', () { + test('has club id BBB and exactly 7 groups A1..A4, B1..B3', () { + expect(bbbClub.id, 'BBB'); + expect(bbbClub.groups.map((g) => g.id).toList(), + ['A1', 'A2', 'A3', 'A4', 'B1', 'B2', 'B3']); + }); + + test('roomIdFor builds ":"', () { + final a1 = bbbClub.groups.first; + expect(bbbClub.roomIdFor(a1), 'BBB:A1'); + }); + }); +} diff --git a/packages/mobile/test/services/ptt_service_test.dart b/packages/mobile/test/services/ptt_service_test.dart new file mode 100644 index 0000000..cf55f99 --- /dev/null +++ b/packages/mobile/test/services/ptt_service_test.dart @@ -0,0 +1,250 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:app/models/ptt_state.dart'; +import 'package:app/services/ptt_service.dart'; +import 'package:app/services/recorder_service.dart'; + +class _FakeRecorderService implements RecorderService { + int startCount = 0; + int stopCount = 0; + int disposeCount = 0; + + @override + Future startRecording() async { + startCount++; + } + + @override + Future stopAndPlayback() async { + stopCount++; + } + + @override + Future dispose() async { + disposeCount++; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('com.example.peloton/ptt'); + late List nativeCalls; + + setUp(() { + nativeCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + nativeCalls.add(call); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + // Drain pending microtasks/Futures so fire-and-forget work (initial config + // push, wakelock init, mode/button update calls) lands before assertions. + Future pumpEventQueue() async { + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + } + + group('PTTService initial state', () { + test('starts in idle state with default configuration', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + expect(service.state, PTTState.idle); + expect(service.mode, PTTMode.toggle); + expect(service.button, PTTButton.volume); + expect(service.config.preventScreenLock, isTrue); + + service.dispose(); + }); + + test('pushes initial configuration to native on construction', () async { + PTTService(recorder: _FakeRecorderService()); + await pumpEventQueue(); + + final configCalls = nativeCalls + .where((c) => c.method == 'updatePTTConfiguration') + .toList(); + expect(configCalls, isNotEmpty, + reason: 'expected initial config push to native'); + + final args = configCalls.first.arguments as Map; + expect(args['mode'], 'toggle'); + expect(args['button'], 'volume'); + expect(args['preventScreenLock'], true); + }); + }); + + group('native callback → state machine', () { + test('pttPressed transitions to active and starts recording', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec() + .encodeMethodCall(const MethodCall('pttPressed')), + (_) {}, + ); + + expect(service.state, PTTState.active); + expect(recorder.startCount, 1); + expect(recorder.stopCount, 0); + + service.dispose(); + }); + + test('pttReleased transitions to idle and stops recording', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + // Drive to active first. + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec() + .encodeMethodCall(const MethodCall('pttPressed')), + (_) {}, + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec() + .encodeMethodCall(const MethodCall('pttReleased')), + (_) {}, + ); + + expect(service.state, PTTState.idle); + expect(recorder.startCount, 1); + expect(recorder.stopCount, 1); + + service.dispose(); + }); + + test('idempotent state transitions do not double-fire recorder', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + // Two pttPressed calls in a row — recorder should only start once. + for (var i = 0; i < 2; i++) { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec() + .encodeMethodCall(const MethodCall('pttPressed')), + (_) {}, + ); + } + + expect(service.state, PTTState.active); + expect(recorder.startCount, 1); + + service.dispose(); + }); + }); + + group('configuration updates', () { + test('setMode updates mode and forwards to native', () async { + final service = PTTService(recorder: _FakeRecorderService()); + await pumpEventQueue(); + nativeCalls.clear(); + + service.setMode(PTTMode.hold); + await pumpEventQueue(); + + expect(service.mode, PTTMode.hold); + final modeCalls = nativeCalls + .where((c) => c.method == 'updatePTTConfiguration') + .toList(); + expect(modeCalls, hasLength(1)); + expect((modeCalls.first.arguments as Map)['mode'], 'hold'); + + service.dispose(); + }); + + test( + 'setButton(playPause) does NOT change mode (regression: native handles toggle semantics)', + () async { + final service = PTTService(recorder: _FakeRecorderService()); + await pumpEventQueue(); + // Set hold mode explicitly with the default (volume) button. + service.setMode(PTTMode.hold); + await pumpEventQueue(); + expect(service.mode, PTTMode.hold); + + // Switching to play/pause must NOT silently flip mode to toggle anymore. + service.setButton(PTTButton.playPause); + await pumpEventQueue(); + + expect(service.button, PTTButton.playPause); + expect(service.mode, PTTMode.hold, + reason: + 'mode should be preserved; native code forces toggle for play/pause keycodes'); + + service.dispose(); + }); + + test('setPreventScreenLock updates configuration', () async { + final service = PTTService(recorder: _FakeRecorderService()); + await pumpEventQueue(); + + service.setPreventScreenLock(false); + await pumpEventQueue(); + + expect(service.config.preventScreenLock, isFalse); + + service.dispose(); + }); + + test('stops active recording when configuration changes', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + // Drive into active state. + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + const StandardMethodCodec() + .encodeMethodCall(const MethodCall('pttPressed')), + (_) {}, + ); + expect(service.state, PTTState.active); + + // Changing mode while active should drop us back to idle and stop recording. + service.setMode(PTTMode.hold); + await pumpEventQueue(); + + expect(service.state, PTTState.idle); + expect(recorder.stopCount, greaterThanOrEqualTo(1)); + + service.dispose(); + }); + }); + + group('lifecycle', () { + test('dispose tears down recorder', () async { + final recorder = _FakeRecorderService(); + final service = PTTService(recorder: recorder); + await pumpEventQueue(); + + service.dispose(); + + expect(recorder.disposeCount, 1); + }); + }); +} diff --git a/packages/mobile/test/services/ride_session_test.dart b/packages/mobile/test/services/ride_session_test.dart new file mode 100644 index 0000000..c19cb70 --- /dev/null +++ b/packages/mobile/test/services/ride_session_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/services/voice_transport.dart'; +import 'package:app/services/signaling_channel.dart'; +import 'package:app/services/webrtc_service.dart'; +import 'package:app/services/signaling_client.dart'; +import 'package:app/services/ptt_service.dart'; +import 'package:app/services/ride_session.dart'; +import '../support/fakes.dart'; + +void main() { + test('concrete services satisfy the RideSession interfaces', () { + final SignalingChannel signaling = + SignalingClient(serverUrl: 'ws://localhost:8080', userId: 'u1'); + final VoiceTransport transport = + WebRTCService(signaling: signaling as SignalingClient); + + expect(signaling.peerCount, 0); + expect(transport, isA()); + }); + + group('RideSession', () { + const channel = MethodChannel('com.example.peloton/ptt'); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => null); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future drain() async { + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + } + + test('join connects, comes up muted, and joins the room', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = + RideSession(ptt: ptt, transport: transport, signaling: signaling); + + await session.join('BBB:A1'); + await drain(); + + expect(signaling.connectCount, 1); + expect(transport.initCount, 1); + expect(transport.lastMuted, true, reason: 'must be muted by default'); + expect(signaling.joinedRoom, 'BBB:A1'); + expect(session.isJoined, true); + + ptt.dispose(); + }); + + test('PTT active unmutes and signals start; idle re-mutes and signals end', + () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = + RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + + ptt.manualPress(); + expect(transport.lastMuted, false); + expect(signaling.startPttCount, 1); + + ptt.manualRelease(); + expect(transport.lastMuted, true); + expect(signaling.endPttCount, 1); + + ptt.dispose(); + }); + + test('connects to peers once a roster arrives', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = + RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + expect(transport.connectToAllPeersCount, 0); + + signaling.setPeerCount(1); // server delivered a peer + expect(transport.connectToAllPeersCount, 1); + + ptt.dispose(); + }); + + test('leave stops gating and tears everything down', () async { + final ptt = PTTService(recorder: FakeRecorder()); + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + final session = + RideSession(ptt: ptt, transport: transport, signaling: signaling); + await session.join('BBB:A1'); + await drain(); + + await session.leave(); + + expect(signaling.leftRoom, true); + expect(transport.closeCount, 1); + expect(transport.disposeStreamCount, 1); + expect(signaling.disconnected, true); + expect(session.isJoined, false); + + // After leaving, PTT changes must NOT transmit. + final startsBefore = signaling.startPttCount; + ptt.manualPress(); + expect(signaling.startPttCount, startsBefore); + + ptt.dispose(); + }); + }); +} diff --git a/packages/mobile/test/support/fakes.dart b/packages/mobile/test/support/fakes.dart new file mode 100644 index 0000000..d98d76d --- /dev/null +++ b/packages/mobile/test/support/fakes.dart @@ -0,0 +1,66 @@ +import 'package:flutter/foundation.dart'; +import 'package:app/services/voice_transport.dart'; +import 'package:app/services/signaling_channel.dart'; +import 'package:app/services/recorder_service.dart'; + +class FakeVoiceTransport implements VoiceTransport { + final List muteHistory = []; + int initCount = 0; + int connectToAllPeersCount = 0; + int closeCount = 0; + int disposeStreamCount = 0; + + bool? get lastMuted => muteHistory.isEmpty ? null : muteHistory.last; + + @override + Future initializeLocalStream() async => initCount++; + @override + void setMuted(bool muted) => muteHistory.add(muted); + @override + Future connectToAllPeers() async => connectToAllPeersCount++; + @override + Future closeAllConnections() async => closeCount++; + @override + Future disposeLocalStream() async => disposeStreamCount++; +} + +class FakeSignaling extends ChangeNotifier implements SignalingChannel { + int connectCount = 0; + String? joinedRoom; + bool leftRoom = false; + bool disconnected = false; + int startPttCount = 0; + int endPttCount = 0; + int _peerCount = 0; + + @override + int get peerCount => _peerCount; + + /// Simulate the server delivering a peer roster. + void setPeerCount(int value) { + _peerCount = value; + notifyListeners(); + } + + @override + Future connect() async => connectCount++; + @override + void joinRoom(String roomId) => joinedRoom = roomId; + @override + void leaveRoom() => leftRoom = true; + @override + void startPTT() => startPttCount++; + @override + void endPTT() => endPttCount++; + @override + Future disconnect() async => disconnected = true; +} + +class FakeRecorder implements RecorderService { + @override + Future startRecording() async {} + @override + Future stopAndPlayback() async {} + @override + Future dispose() async {} +} diff --git a/packages/mobile/test/ui/call_screen_test.dart b/packages/mobile/test/ui/call_screen_test.dart new file mode 100644 index 0000000..04a9cd0 --- /dev/null +++ b/packages/mobile/test/ui/call_screen_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:app/services/ptt_service.dart'; +import 'package:app/ui/screens/call_screen.dart'; +import '../support/fakes.dart'; + +void main() { + testWidgets('CallScreen joins the room muted on init and leaves on dispose', + (tester) async { + final transport = FakeVoiceTransport(); + final signaling = FakeSignaling(); + + await tester.pumpWidget(MaterialApp( + home: ChangeNotifierProvider( + create: (_) => PTTService(recorder: FakeRecorder()), + child: CallScreen( + serverUrl: 'ws://localhost:8080', + roomId: 'BBB:A1', + transport: transport, + signaling: signaling, + ), + ), + )); + await tester.pump(); // let initState's async join settle + await tester.pump(const Duration(milliseconds: 10)); + + expect(signaling.joinedRoom, 'BBB:A1'); + expect(transport.lastMuted, true); + + // Replace the screen to trigger dispose -> RideSession.leave(). + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + await tester.pump(); + + expect(signaling.leftRoom, true); + expect(signaling.disconnected, true); + }); +} diff --git a/packages/mobile/test/ui/group_picker_screen_test.dart b/packages/mobile/test/ui/group_picker_screen_test.dart new file mode 100644 index 0000000..414d47a --- /dev/null +++ b/packages/mobile/test/ui/group_picker_screen_test.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:app/ui/screens/group_picker_screen.dart'; + +void main() { + testWidgets('tapping group A1 selects room BBB:A1', (tester) async { + String? selectedRoom; + String? selectedServer; + + await tester.pumpWidget(MaterialApp( + home: GroupPickerScreen( + onSelect: (context, serverUrl, roomId) { + selectedServer = serverUrl; + selectedRoom = roomId; + }, + ), + )); + + expect(find.byKey(const Key('group_A1')), findsOneWidget); + expect(find.byKey(const Key('group_B3')), findsOneWidget); + + await tester.tap(find.byKey(const Key('group_A1'))); + await tester.pump(); + + expect(selectedRoom, 'BBB:A1'); + expect(selectedServer, 'ws://localhost:8080'); + }); +} diff --git a/packages/mobile/windows/flutter/generated_plugin_registrant.cc b/packages/mobile/windows/flutter/generated_plugin_registrant.cc index 8b6d468..dbef9b8 100644 --- a/packages/mobile/windows/flutter/generated_plugin_registrant.cc +++ b/packages/mobile/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,15 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + FlutterWebRTCPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); } diff --git a/packages/mobile/windows/flutter/generated_plugins.cmake b/packages/mobile/windows/flutter/generated_plugins.cmake index b93c4c3..5620431 100644 --- a/packages/mobile/windows/flutter/generated_plugins.cmake +++ b/packages/mobile/windows/flutter/generated_plugins.cmake @@ -3,9 +3,13 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + flutter_webrtc + record_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/services/signaling/Dockerfile b/packages/services/signaling/Dockerfile new file mode 100644 index 0000000..c599d54 --- /dev/null +++ b/packages/services/signaling/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +# Keep in sync with GO_VERSION in .github/workflows/ci.yml so the shipped binary is built +# with the same (vulnerability-scanned) standard library that CI tests. +FROM golang:1.27-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -o signaling ./cmd/main.go + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +# Copy binary from builder +COPY --from=builder /app/signaling . + +# Expose the default port +EXPOSE 8080 + +# Run the binary +CMD ["./signaling"] diff --git a/packages/services/signaling/cmd/main.go b/packages/services/signaling/cmd/main.go new file mode 100644 index 0000000..35d1e93 --- /dev/null +++ b/packages/services/signaling/cmd/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/peloton-communicator/signaling/internal/config" + ws "github.com/peloton-communicator/signaling/internal/websocket" +) + +var ( + upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // For MVP, allow all origins. In production, restrict this. + CheckOrigin: func(r *http.Request) bool { + return true + }, + } +) + +func main() { + // Load configuration + cfg := config.Load() + + // Setup logging + setupLogging(cfg) + + // Create the WebSocket hub + hub := ws.NewHub() + go hub.Run() + + // Setup HTTP routes + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + handleWebSocket(hub, w, r) + }) + + http.HandleFunc("/health", handleHealth) + + http.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) { + handleStats(hub, w, r) + }) + + // Start server + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + log.Info(). + Str("address", addr). + Msg("Starting signaling server") + + server := &http.Server{ + Addr: addr, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if err := server.ListenAndServe(); err != nil { + log.Fatal().Err(err).Msg("Server failed to start") + } +} + +// setupLogging configures the zerolog logger +func setupLogging(cfg *config.Config) { + // Set log level + level, err := zerolog.ParseLevel(cfg.LogLevel) + if err != nil { + level = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(level) + + // Configure output format + if cfg.LogJSON { + // JSON output for production + log.Logger = zerolog.New(os.Stdout).With().Timestamp().Logger() + } else { + // Pretty console output for development + log.Logger = zerolog.New(zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + }).With().Timestamp().Logger() + } +} + +// handleWebSocket upgrades HTTP connections to WebSocket +func handleWebSocket(hub *ws.Hub, w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to upgrade connection") + return + } + + // Extract client identifiers from query params or headers + // For MVP, we generate simple IDs. In production, use JWT tokens. + userID := r.URL.Query().Get("userId") + if userID == "" { + userID = fmt.Sprintf("user_%d", time.Now().UnixNano()) + } + + deviceInfo := r.URL.Query().Get("deviceInfo") + if deviceInfo == "" { + deviceInfo = r.UserAgent() + } + + clientID := fmt.Sprintf("client_%d", time.Now().UnixNano()) + + // Create client + client := ws.NewClient(clientID, userID, deviceInfo, hub, conn) + + // Register client with hub + hub.Register(client) + + log.Info(). + Str("clientID", clientID). + Str("userID", userID). + Str("remoteAddr", r.RemoteAddr). + Msg("WebSocket connection established") + + // Start client goroutines + go client.WritePump() + go client.ReadPump() +} + +// handleHealth returns a simple health check response +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy"}`)) +} + +// handleStats returns server statistics +func handleStats(hub *ws.Hub, w http.ResponseWriter, r *http.Request) { + clients, rooms := hub.Stats() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf(`{"clients":%d,"rooms":%d}`, clients, rooms))) +} diff --git a/packages/services/signaling/cmd/main_integration_test.go b/packages/services/signaling/cmd/main_integration_test.go new file mode 100644 index 0000000..8d786a9 --- /dev/null +++ b/packages/services/signaling/cmd/main_integration_test.go @@ -0,0 +1,228 @@ +//go:build integration + +package main + +import ( + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + + ws "github.com/peloton-communicator/signaling/internal/websocket" +) + +// Integration tests run the real HTTP handlers and hub over real WebSocket connections, +// exercising the same signaling path two phones use during a ride. +// +// Run with: go test -tags=integration ./... + +func startServer(t *testing.T) *httptest.Server { + t.Helper() + hub := ws.NewHub() + go hub.Run() + + mux := http.NewServeMux() + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWebSocket(hub, w, r) }) + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/stats", func(w http.ResponseWriter, r *http.Request) { handleStats(hub, w, r) }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server +} + +type rider struct { + t *testing.T + name string + conn *websocket.Conn +} + +func connect(t *testing.T, server *httptest.Server, name string) *rider { + t.Helper() + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws?userId=" + name + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatalf("%s: dial: %v", name, err) + } + t.Cleanup(func() { conn.Close() }) + return &rider{t: t, name: name, conn: conn} +} + +func (r *rider) send(msgType ws.MessageType, data any) { + r.t.Helper() + msg, err := ws.NewMessage(msgType, data) + if err != nil { + r.t.Fatalf("%s: build %s: %v", r.name, msgType, err) + } + if err := r.conn.WriteJSON(msg); err != nil { + r.t.Fatalf("%s: send %s: %v", r.name, msgType, err) + } +} + +func (r *rider) expect(msgType ws.MessageType, into any) { + r.t.Helper() + r.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + var msg ws.Message + if err := r.conn.ReadJSON(&msg); err != nil { + r.t.Fatalf("%s: waiting for %s: %v", r.name, msgType, err) + } + if msg.Type != msgType { + r.t.Fatalf("%s: got %s (%s), want %s", r.name, msg.Type, msg.Data, msgType) + } + if into != nil { + if err := json.Unmarshal(msg.Data, into); err != nil { + r.t.Fatalf("%s: parse %s: %v", r.name, msgType, err) + } + } +} + +func (r *rider) expectSilence() { + r.t.Helper() + r.conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + var msg ws.Message + err := r.conn.ReadJSON(&msg) + var netErr net.Error + if err == nil { + r.t.Fatalf("%s: unexpected %s (%s)", r.name, msg.Type, msg.Data) + } + if !errors.As(err, &netErr) || !netErr.Timeout() { + r.t.Fatalf("%s: expected read timeout, got %v", r.name, err) + } +} + +func TestHealthEndpoint(t *testing.T) { + server := startServer(t) + + resp, err := http.Get(server.URL + "/health") + if err != nil { + t.Fatalf("GET /health: %v", err) + } + defer resp.Body.Close() + + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.StatusCode != http.StatusOK || body["status"] != "healthy" { + t.Errorf("GET /health = %d %v, want 200 healthy", resp.StatusCode, body) + } +} + +// Two riders in the same group negotiate a WebRTC session and signal push-to-talk, while +// a rider in a different group hears none of it. +func TestTwoRidersSignalAWalkieTalkieSession(t *testing.T) { + server := startServer(t) + alice := connect(t, server, "alice") + bob := connect(t, server, "bob") + carol := connect(t, server, "carol") + + alice.send(ws.MsgJoinRoom, ws.JoinRoomData{RoomID: "BBB:A1"}) + var alicePeers ws.PeersData + alice.expect(ws.MsgPeers, &alicePeers) + if len(alicePeers.Peers) != 0 { + t.Fatalf("alice joined an empty room but saw peers %v", alicePeers.Peers) + } + + carol.send(ws.MsgJoinRoom, ws.JoinRoomData{RoomID: "BBB:B1"}) + carol.expect(ws.MsgPeers, nil) + + bob.send(ws.MsgJoinRoom, ws.JoinRoomData{RoomID: "BBB:A1"}) + var bobPeers ws.PeersData + bob.expect(ws.MsgPeers, &bobPeers) + if len(bobPeers.Peers) != 1 || bobPeers.Peers[0].UserID != "alice" { + t.Fatalf("bob saw peers %v, want [alice]", bobPeers.Peers) + } + aliceID := bobPeers.Peers[0].ID + + var joined ws.PeerJoinedData + alice.expect(ws.MsgPeerJoined, &joined) + if joined.Peer.UserID != "bob" { + t.Fatalf("alice notified of %+v, want bob", joined.Peer) + } + bobID := joined.Peer.ID + + // Offer / answer / ICE are relayed peer-to-peer, with the server stamping the sender. + alice.send(ws.MsgOffer, ws.OfferData{To: bobID, From: "spoofed", SessionID: "s1", + Description: ws.SDPDescription{Type: "offer", SDP: "v=0 offer"}}) + var offer ws.OfferData + bob.expect(ws.MsgOffer, &offer) + if offer.From != aliceID || offer.Description.SDP != "v=0 offer" { + t.Fatalf("bob got offer %+v, want from %s with alice's SDP", offer, aliceID) + } + + bob.send(ws.MsgAnswer, ws.AnswerData{To: aliceID, SessionID: "s1", + Description: ws.SDPDescription{Type: "answer", SDP: "v=0 answer"}}) + var answer ws.AnswerData + alice.expect(ws.MsgAnswer, &answer) + if answer.From != bobID { + t.Fatalf("alice got answer from %q, want %q", answer.From, bobID) + } + + alice.send(ws.MsgCandidate, ws.CandidateData{To: bobID, Candidate: ws.ICECandidate{Candidate: "candidate:1"}}) + var candidate ws.CandidateData + bob.expect(ws.MsgCandidate, &candidate) + if candidate.Candidate.Candidate != "candidate:1" { + t.Fatalf("bob got candidate %+v", candidate) + } + + // Push-to-talk start/end reaches the group, not the talker, and not other groups. + alice.send(ws.MsgPTTStart, ws.PTTStartData{RoomID: "BBB:A1"}) + var talking ws.PeerTalkingData + bob.expect(ws.MsgPeerTalking, &talking) + if talking.PeerID != aliceID || !talking.IsTalking { + t.Fatalf("bob got %+v, want alice talking", talking) + } + + alice.send(ws.MsgPTTEnd, ws.PTTEndData{RoomID: "BBB:A1"}) + bob.expect(ws.MsgPeerTalking, &talking) + if talking.IsTalking { + t.Fatalf("bob got %+v, want alice stopped talking", talking) + } + + // Dropping a connection tells the rest of the group. + bob.conn.Close() + var left ws.PeerLeftData + alice.expect(ws.MsgPeerLeft, &left) + if left.PeerID != bobID { + t.Fatalf("alice notified %q left, want %q", left.PeerID, bobID) + } + + // Silence checks go last: gorilla/websocket treats a read timeout as permanent, so a + // connection can't be read again after expectSilence. + alice.expectSilence() + carol.expectSilence() +} + +func TestPTTBeforeJoiningARoomIsRejected(t *testing.T) { + server := startServer(t) + rider := connect(t, server, "early") + + rider.send(ws.MsgPTTStart, ws.PTTStartData{}) + + var errData ws.ErrorData + rider.expect(ws.MsgError, &errData) + if errData.Code != "not_in_room" { + t.Errorf("error code = %q, want not_in_room", errData.Code) + } +} + +func TestUnknownMessageTypeIsRejected(t *testing.T) { + server := startServer(t) + rider := connect(t, server, "confused") + + if err := rider.conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"teleport","data":{}}`)); err != nil { + t.Fatalf("write: %v", err) + } + + var errData ws.ErrorData + rider.expect(ws.MsgError, &errData) + if errData.Code != "unknown_type" { + t.Errorf("error code = %q, want unknown_type", errData.Code) + } +} diff --git a/packages/services/signaling/go.mod b/packages/services/signaling/go.mod new file mode 100644 index 0000000..20f293f --- /dev/null +++ b/packages/services/signaling/go.mod @@ -0,0 +1,14 @@ +module github.com/peloton-communicator/signaling + +go 1.21 + +require ( + github.com/gorilla/websocket v1.5.3 + github.com/rs/zerolog v1.31.0 +) + +require ( + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.15.0 // indirect +) diff --git a/packages/services/signaling/go.sum b/packages/services/signaling/go.sum new file mode 100644 index 0000000..4ce193e --- /dev/null +++ b/packages/services/signaling/go.sum @@ -0,0 +1,19 @@ +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A= +github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/packages/services/signaling/internal/config/config.go b/packages/services/signaling/internal/config/config.go new file mode 100644 index 0000000..38c76b9 --- /dev/null +++ b/packages/services/signaling/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +// Config holds the application configuration +type Config struct { + // Server settings + Host string + Port int + + // CORS settings + AllowedOrigins []string + + // Logging + LogLevel string + LogJSON bool +} + +// Load loads configuration from environment variables with defaults +func Load() *Config { + return &Config{ + Host: getEnv("HOST", "0.0.0.0"), + Port: getEnvInt("PORT", 8080), + AllowedOrigins: getEnvList("ALLOWED_ORIGINS", []string{"*"}), + LogLevel: getEnv("LOG_LEVEL", "info"), + LogJSON: getEnvBool("LOG_JSON", false), + } +} + +// getEnv returns an environment variable or a default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvInt returns an environment variable as int or a default value +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + +// getEnvBool returns an environment variable as bool or a default value +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolVal, err := strconv.ParseBool(value); err == nil { + return boolVal + } + } + return defaultValue +} + +// getEnvList returns an environment variable as a comma-separated list, trimming +// whitespace and dropping empty entries +func getEnvList(key string, defaultValue []string) []string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + + items := make([]string, 0) + for _, item := range strings.Split(value, ",") { + if item = strings.TrimSpace(item); item != "" { + items = append(items, item) + } + } + if len(items) == 0 { + return defaultValue + } + return items +} diff --git a/packages/services/signaling/internal/config/config_test.go b/packages/services/signaling/internal/config/config_test.go new file mode 100644 index 0000000..a805a6b --- /dev/null +++ b/packages/services/signaling/internal/config/config_test.go @@ -0,0 +1,68 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestLoadDefaults(t *testing.T) { + for _, key := range []string{"HOST", "PORT", "ALLOWED_ORIGINS", "LOG_LEVEL", "LOG_JSON"} { + t.Setenv(key, "") + } + + cfg := Load() + + if cfg.Host != "0.0.0.0" { + t.Errorf("Host = %q, want %q", cfg.Host, "0.0.0.0") + } + if cfg.Port != 8080 { + t.Errorf("Port = %d, want 8080", cfg.Port) + } + if !reflect.DeepEqual(cfg.AllowedOrigins, []string{"*"}) { + t.Errorf("AllowedOrigins = %v, want [*]", cfg.AllowedOrigins) + } + if cfg.LogLevel != "info" { + t.Errorf("LogLevel = %q, want %q", cfg.LogLevel, "info") + } + if cfg.LogJSON { + t.Error("LogJSON = true, want false") + } +} + +func TestLoadFromEnvironment(t *testing.T) { + t.Setenv("HOST", "127.0.0.1") + t.Setenv("PORT", "9090") + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("LOG_JSON", "true") + + cfg := Load() + + if cfg.Host != "127.0.0.1" || cfg.Port != 9090 || cfg.LogLevel != "debug" || !cfg.LogJSON { + t.Errorf("Load() = %+v, want values from environment", cfg) + } +} + +func TestLoadFallsBackOnMalformedValues(t *testing.T) { + t.Setenv("PORT", "not-a-port") + t.Setenv("LOG_JSON", "maybe") + + cfg := Load() + + if cfg.Port != 8080 { + t.Errorf("Port = %d, want default 8080 for malformed value", cfg.Port) + } + if cfg.LogJSON { + t.Error("LogJSON = true, want default false for malformed value") + } +} + +func TestAllowedOriginsIsCommaSeparated(t *testing.T) { + t.Setenv("ALLOWED_ORIGINS", "https://a.example, https://b.example,,") + + cfg := Load() + + want := []string{"https://a.example", "https://b.example"} + if !reflect.DeepEqual(cfg.AllowedOrigins, want) { + t.Errorf("AllowedOrigins = %q, want %q", cfg.AllowedOrigins, want) + } +} diff --git a/packages/services/signaling/internal/websocket/client.go b/packages/services/signaling/internal/websocket/client.go new file mode 100644 index 0000000..b707b96 --- /dev/null +++ b/packages/services/signaling/internal/websocket/client.go @@ -0,0 +1,362 @@ +package websocket + +import ( + "encoding/json" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/rs/zerolog/log" +) + +const ( + // Time allowed to write a message to the peer + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer + pongWait = 60 * time.Second + + // Send pings to peer with this period (must be less than pongWait) + pingPeriod = (pongWait * 9) / 10 + + // Maximum message size allowed from peer + maxMessageSize = 65536 +) + +// Client represents a WebSocket client connection +type Client struct { + ID string + UserID string + DeviceInfo string + RoomID string + hub *Hub + conn *websocket.Conn + send chan []byte + mu sync.RWMutex +} + +// NewClient creates a new WebSocket client +func NewClient(id, userID, deviceInfo string, hub *Hub, conn *websocket.Conn) *Client { + return &Client{ + ID: id, + UserID: userID, + DeviceInfo: deviceInfo, + hub: hub, + conn: conn, + send: make(chan []byte, 256), + } +} + +// GetRoomID returns the client's current room ID +func (c *Client) GetRoomID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.RoomID +} + +// SetRoomID sets the client's current room ID +func (c *Client) SetRoomID(roomID string) { + c.mu.Lock() + defer c.mu.Unlock() + c.RoomID = roomID +} + +// ReadPump pumps messages from the WebSocket connection to the hub +func (c *Client) ReadPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + _, message, err := c.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Error().Err(err).Str("clientID", c.ID).Msg("WebSocket read error") + } + break + } + + // Parse the message + var msg Message + if err := json.Unmarshal(message, &msg); err != nil { + log.Error().Err(err).Str("clientID", c.ID).Msg("Failed to parse message") + c.sendError("invalid_message", "Failed to parse message") + continue + } + + // Handle the message + c.handleMessage(&msg) + } +} + +// WritePump pumps messages from the hub to the WebSocket connection +func (c *Client) WritePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // The hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + w, err := c.conn.NextWriter(websocket.TextMessage) + if err != nil { + return + } + w.Write(message) + + if err := w.Close(); err != nil { + return + } + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// handleMessage processes incoming WebSocket messages +func (c *Client) handleMessage(msg *Message) { + switch msg.Type { + case MsgJoinRoom: + c.handleJoinRoom(msg) + case MsgLeaveRoom: + c.handleLeaveRoom(msg) + case MsgOffer: + c.handleOffer(msg) + case MsgAnswer: + c.handleAnswer(msg) + case MsgCandidate: + c.handleCandidate(msg) + case MsgPTTStart: + c.handlePTTStart(msg) + case MsgPTTEnd: + c.handlePTTEnd(msg) + default: + log.Warn().Str("type", string(msg.Type)).Str("clientID", c.ID).Msg("Unknown message type") + c.sendError("unknown_type", "Unknown message type: "+string(msg.Type)) + } +} + +// handleJoinRoom processes a join room request +func (c *Client) handleJoinRoom(msg *Message) { + var data JoinRoomData + if err := msg.ParseData(&data); err != nil { + c.sendError("invalid_data", "Invalid join room data") + return + } + + // For MVP, we use a hardcoded room - no auth required + // In Phase 2, we'll validate room membership via Group Service + if data.RoomID == "" { + data.RoomID = "default" // MVP: default room for testing + } + + c.hub.joinRoom <- &JoinRoomRequest{ + Client: c, + RoomID: data.RoomID, + } + + log.Info(). + Str("clientID", c.ID). + Str("userID", c.UserID). + Str("roomID", data.RoomID). + Msg("Client joining room") +} + +// handleLeaveRoom processes a leave room request +func (c *Client) handleLeaveRoom(msg *Message) { + var data LeaveRoomData + if err := msg.ParseData(&data); err != nil { + c.sendError("invalid_data", "Invalid leave room data") + return + } + + roomID := data.RoomID + if roomID == "" { + roomID = c.GetRoomID() + } + + if roomID != "" { + c.hub.leaveRoom <- &LeaveRoomRequest{ + Client: c, + RoomID: roomID, + } + } +} + +// handleOffer forwards a WebRTC offer to the target peer +func (c *Client) handleOffer(msg *Message) { + var data OfferData + if err := msg.ParseData(&data); err != nil { + c.sendError("invalid_data", "Invalid offer data") + return + } + + // Set the from field to this client's ID + data.From = c.ID + + c.hub.forwardToPeer <- &ForwardRequest{ + From: c, + TargetID: data.To, + MsgType: MsgOffer, + Data: data, + } + + log.Debug(). + Str("from", c.ID). + Str("to", data.To). + Str("sessionID", data.SessionID). + Msg("Forwarding offer") +} + +// handleAnswer forwards a WebRTC answer to the target peer +func (c *Client) handleAnswer(msg *Message) { + var data AnswerData + if err := msg.ParseData(&data); err != nil { + c.sendError("invalid_data", "Invalid answer data") + return + } + + // Set the from field to this client's ID + data.From = c.ID + + c.hub.forwardToPeer <- &ForwardRequest{ + From: c, + TargetID: data.To, + MsgType: MsgAnswer, + Data: data, + } + + log.Debug(). + Str("from", c.ID). + Str("to", data.To). + Str("sessionID", data.SessionID). + Msg("Forwarding answer") +} + +// handleCandidate forwards an ICE candidate to the target peer +func (c *Client) handleCandidate(msg *Message) { + var data CandidateData + if err := msg.ParseData(&data); err != nil { + c.sendError("invalid_data", "Invalid candidate data") + return + } + + // Set the from field to this client's ID + data.From = c.ID + + c.hub.forwardToPeer <- &ForwardRequest{ + From: c, + TargetID: data.To, + MsgType: MsgCandidate, + Data: data, + } + + log.Debug(). + Str("from", c.ID). + Str("to", data.To). + Msg("Forwarding ICE candidate") +} + +// handlePTTStart broadcasts that this client started talking +func (c *Client) handlePTTStart(msg *Message) { + roomID := c.GetRoomID() + if roomID == "" { + c.sendError("not_in_room", "You must join a room first") + return + } + + c.hub.broadcastToRoom <- &BroadcastRequest{ + RoomID: roomID, + ExcludeIDs: []string{c.ID}, + MsgType: MsgPeerTalking, + Data: PeerTalkingData{ + RoomID: roomID, + PeerID: c.ID, + IsTalking: true, + }, + } + + log.Debug(). + Str("clientID", c.ID). + Str("roomID", roomID). + Msg("PTT started") +} + +// handlePTTEnd broadcasts that this client stopped talking +func (c *Client) handlePTTEnd(msg *Message) { + roomID := c.GetRoomID() + if roomID == "" { + c.sendError("not_in_room", "You must join a room first") + return + } + + c.hub.broadcastToRoom <- &BroadcastRequest{ + RoomID: roomID, + ExcludeIDs: []string{c.ID}, + MsgType: MsgPeerTalking, + Data: PeerTalkingData{ + RoomID: roomID, + PeerID: c.ID, + IsTalking: false, + }, + } + + log.Debug(). + Str("clientID", c.ID). + Str("roomID", roomID). + Msg("PTT ended") +} + +// sendError sends an error message to the client +func (c *Client) sendError(code, message string) { + errMsg, err := NewMessage(MsgError, ErrorData{ + Code: code, + Message: message, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to create error message") + return + } + + c.SendMessage(errMsg) +} + +// SendMessage sends a message to the client +func (c *Client) SendMessage(msg *Message) { + data, err := json.Marshal(msg) + if err != nil { + log.Error().Err(err).Msg("Failed to marshal message") + return + } + + select { + case c.send <- data: + default: + log.Warn().Str("clientID", c.ID).Msg("Client send buffer full, dropping message") + } +} + +// ToPeer converts the client to a Peer struct +func (c *Client) ToPeer() Peer { + return NewPeer(c.ID, c.UserID, c.DeviceInfo) +} diff --git a/packages/services/signaling/internal/websocket/hub.go b/packages/services/signaling/internal/websocket/hub.go new file mode 100644 index 0000000..9942164 --- /dev/null +++ b/packages/services/signaling/internal/websocket/hub.go @@ -0,0 +1,429 @@ +package websocket + +import ( + "sync" + + "github.com/rs/zerolog/log" +) + +// Room represents a signaling room where peers can communicate +type Room struct { + ID string + clients map[string]*Client + mu sync.RWMutex +} + +// NewRoom creates a new Room +func NewRoom(id string) *Room { + return &Room{ + ID: id, + clients: make(map[string]*Client), + } +} + +// AddClient adds a client to the room +func (r *Room) AddClient(client *Client) { + r.mu.Lock() + defer r.mu.Unlock() + r.clients[client.ID] = client +} + +// RemoveClient removes a client from the room +func (r *Room) RemoveClient(clientID string) { + r.mu.Lock() + defer r.mu.Unlock() + delete(r.clients, clientID) +} + +// GetClient gets a client by ID +func (r *Room) GetClient(clientID string) (*Client, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + client, ok := r.clients[clientID] + return client, ok +} + +// GetClients returns all clients in the room +func (r *Room) GetClients() []*Client { + r.mu.RLock() + defer r.mu.RUnlock() + clients := make([]*Client, 0, len(r.clients)) + for _, c := range r.clients { + clients = append(clients, c) + } + return clients +} + +// GetPeers returns all clients as Peer structs (excluding the given ID) +func (r *Room) GetPeers(excludeID string) []Peer { + r.mu.RLock() + defer r.mu.RUnlock() + peers := make([]Peer, 0, len(r.clients)) + for _, c := range r.clients { + if c.ID != excludeID { + peers = append(peers, c.ToPeer()) + } + } + return peers +} + +// IsEmpty returns true if the room has no clients +func (r *Room) IsEmpty() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.clients) == 0 +} + +// JoinRoomRequest is a request to join a room +type JoinRoomRequest struct { + Client *Client + RoomID string +} + +// LeaveRoomRequest is a request to leave a room +type LeaveRoomRequest struct { + Client *Client + RoomID string +} + +// ForwardRequest is a request to forward a message to a specific peer +type ForwardRequest struct { + From *Client + TargetID string + MsgType MessageType + Data interface{} +} + +// BroadcastRequest is a request to broadcast a message to all peers in a room +type BroadcastRequest struct { + RoomID string + ExcludeIDs []string + MsgType MessageType + Data interface{} +} + +// Hub maintains the set of active clients and rooms, and broadcasts messages +type Hub struct { + // Registered clients + clients map[string]*Client + + // Rooms for group communication + rooms map[string]*Room + + // Channel for registering clients + register chan *Client + + // Channel for unregistering clients + unregister chan *Client + + // Channel for joining rooms + joinRoom chan *JoinRoomRequest + + // Channel for leaving rooms + leaveRoom chan *LeaveRoomRequest + + // Channel for forwarding messages to specific peers + forwardToPeer chan *ForwardRequest + + // Channel for broadcasting to rooms + broadcastToRoom chan *BroadcastRequest + + // Mutex for thread-safe access + mu sync.RWMutex +} + +// NewHub creates a new Hub +func NewHub() *Hub { + return &Hub{ + clients: make(map[string]*Client), + rooms: make(map[string]*Room), + register: make(chan *Client), + unregister: make(chan *Client), + joinRoom: make(chan *JoinRoomRequest), + leaveRoom: make(chan *LeaveRoomRequest), + forwardToPeer: make(chan *ForwardRequest), + broadcastToRoom: make(chan *BroadcastRequest), + } +} + +// Run starts the hub's main event loop +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.handleRegister(client) + + case client := <-h.unregister: + h.handleUnregister(client) + + case req := <-h.joinRoom: + h.handleJoinRoom(req) + + case req := <-h.leaveRoom: + h.handleLeaveRoom(req) + + case req := <-h.forwardToPeer: + h.handleForwardToPeer(req) + + case req := <-h.broadcastToRoom: + h.handleBroadcastToRoom(req) + } + } +} + +// handleRegister registers a new client +func (h *Hub) handleRegister(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + h.clients[client.ID] = client + log.Info(). + Str("clientID", client.ID). + Str("userID", client.UserID). + Int("totalClients", len(h.clients)). + Msg("Client registered") +} + +// handleUnregister unregisters a client and removes from any rooms +func (h *Hub) handleUnregister(client *Client) { + h.mu.Lock() + defer h.mu.Unlock() + + if _, ok := h.clients[client.ID]; !ok { + return + } + + // Remove from current room if any + roomID := client.GetRoomID() + if roomID != "" { + if room, ok := h.rooms[roomID]; ok { + room.RemoveClient(client.ID) + + // Notify other peers in the room + h.broadcastToRoomUnlocked(roomID, []string{client.ID}, MsgPeerLeft, PeerLeftData{ + RoomID: roomID, + PeerID: client.ID, + }) + + // Clean up empty rooms + if room.IsEmpty() { + delete(h.rooms, roomID) + log.Info().Str("roomID", roomID).Msg("Room removed (empty)") + } + } + } + + close(client.send) + delete(h.clients, client.ID) + + log.Info(). + Str("clientID", client.ID). + Int("totalClients", len(h.clients)). + Msg("Client unregistered") +} + +// handleJoinRoom adds a client to a room +func (h *Hub) handleJoinRoom(req *JoinRoomRequest) { + h.mu.Lock() + defer h.mu.Unlock() + + client := req.Client + roomID := req.RoomID + + // Leave current room if in one + currentRoom := client.GetRoomID() + if currentRoom != "" && currentRoom != roomID { + if room, ok := h.rooms[currentRoom]; ok { + room.RemoveClient(client.ID) + + // Notify peers in old room + h.broadcastToRoomUnlocked(currentRoom, []string{client.ID}, MsgPeerLeft, PeerLeftData{ + RoomID: currentRoom, + PeerID: client.ID, + }) + + // Clean up empty rooms + if room.IsEmpty() { + delete(h.rooms, currentRoom) + log.Info().Str("roomID", currentRoom).Msg("Room removed (empty)") + } + } + } + + // Get or create the room + room, ok := h.rooms[roomID] + if !ok { + room = NewRoom(roomID) + h.rooms[roomID] = room + log.Info().Str("roomID", roomID).Msg("Room created") + } + + // Get existing peers before adding new client + existingPeers := room.GetPeers("") + + // Add client to room + room.AddClient(client) + client.SetRoomID(roomID) + + // Send list of existing peers to the joining client + peersMsg, err := NewMessage(MsgPeers, PeersData{ + RoomID: roomID, + Peers: existingPeers, + }) + if err == nil { + client.SendMessage(peersMsg) + } + + // Notify existing peers about the new client + h.broadcastToRoomUnlocked(roomID, []string{client.ID}, MsgPeerJoined, PeerJoinedData{ + RoomID: roomID, + Peer: client.ToPeer(), + }) + + log.Info(). + Str("clientID", client.ID). + Str("roomID", roomID). + Int("peersInRoom", len(existingPeers)+1). + Msg("Client joined room") +} + +// handleLeaveRoom removes a client from a room +func (h *Hub) handleLeaveRoom(req *LeaveRoomRequest) { + h.mu.Lock() + defer h.mu.Unlock() + + client := req.Client + roomID := req.RoomID + + room, ok := h.rooms[roomID] + if !ok { + return + } + + room.RemoveClient(client.ID) + client.SetRoomID("") + + // Notify other peers + h.broadcastToRoomUnlocked(roomID, []string{client.ID}, MsgPeerLeft, PeerLeftData{ + RoomID: roomID, + PeerID: client.ID, + }) + + // Clean up empty rooms + if room.IsEmpty() { + delete(h.rooms, roomID) + log.Info().Str("roomID", roomID).Msg("Room removed (empty)") + } + + log.Info(). + Str("clientID", client.ID). + Str("roomID", roomID). + Msg("Client left room") +} + +// handleForwardToPeer forwards a message to a specific peer +func (h *Hub) handleForwardToPeer(req *ForwardRequest) { + h.mu.RLock() + defer h.mu.RUnlock() + + // Find the target client - first check if they're in the same room + roomID := req.From.GetRoomID() + if roomID == "" { + log.Warn(). + Str("fromID", req.From.ID). + Str("targetID", req.TargetID). + Msg("Cannot forward: sender not in a room") + return + } + + room, ok := h.rooms[roomID] + if !ok { + log.Warn(). + Str("roomID", roomID). + Msg("Room not found") + return + } + + target, ok := room.GetClient(req.TargetID) + if !ok { + log.Warn(). + Str("targetID", req.TargetID). + Str("roomID", roomID). + Msg("Target peer not found in room") + return + } + + msg, err := NewMessage(req.MsgType, req.Data) + if err != nil { + log.Error().Err(err).Msg("Failed to create forward message") + return + } + + target.SendMessage(msg) +} + +// handleBroadcastToRoom broadcasts a message to all clients in a room +func (h *Hub) handleBroadcastToRoom(req *BroadcastRequest) { + h.mu.RLock() + defer h.mu.RUnlock() + + h.broadcastToRoomUnlocked(req.RoomID, req.ExcludeIDs, req.MsgType, req.Data) +} + +// broadcastToRoomUnlocked broadcasts to a room (caller must hold lock) +func (h *Hub) broadcastToRoomUnlocked(roomID string, excludeIDs []string, msgType MessageType, data interface{}) { + room, ok := h.rooms[roomID] + if !ok { + return + } + + excludeSet := make(map[string]bool) + for _, id := range excludeIDs { + excludeSet[id] = true + } + + msg, err := NewMessage(msgType, data) + if err != nil { + log.Error().Err(err).Msg("Failed to create broadcast message") + return + } + + for _, client := range room.GetClients() { + if !excludeSet[client.ID] { + client.SendMessage(msg) + } + } +} + +// GetClient returns a client by ID +func (h *Hub) GetClient(clientID string) (*Client, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + client, ok := h.clients[clientID] + return client, ok +} + +// GetRoom returns a room by ID +func (h *Hub) GetRoom(roomID string) (*Room, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + room, ok := h.rooms[roomID] + return room, ok +} + +// Stats returns hub statistics +func (h *Hub) Stats() (clients int, rooms int) { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients), len(h.rooms) +} + +// Register registers a new client with the hub +func (h *Hub) Register(client *Client) { + h.register <- client +} + +// Unregister unregisters a client from the hub +func (h *Hub) Unregister(client *Client) { + h.unregister <- client +} diff --git a/packages/services/signaling/internal/websocket/hub_test.go b/packages/services/signaling/internal/websocket/hub_test.go new file mode 100644 index 0000000..5eedace --- /dev/null +++ b/packages/services/signaling/internal/websocket/hub_test.go @@ -0,0 +1,259 @@ +package websocket + +import ( + "encoding/json" + "testing" + "time" +) + +// These tests drive the hub's handlers directly (no Run loop, no network) so each +// routing rule is checked in isolation. End-to-end behaviour over real WebSocket +// connections lives in cmd/main_integration_test.go. + +func newTestClient(hub *Hub, id string) *Client { + return NewClient(id, "user-"+id, "test-device", hub, nil) +} + +func registered(hub *Hub, ids ...string) []*Client { + clients := make([]*Client, 0, len(ids)) + for _, id := range ids { + c := newTestClient(hub, id) + hub.handleRegister(c) + clients = append(clients, c) + } + return clients +} + +func join(hub *Hub, c *Client, roomID string) { + hub.handleJoinRoom(&JoinRoomRequest{Client: c, RoomID: roomID}) +} + +func nextMessage(t *testing.T, c *Client) Message { + t.Helper() + select { + case raw, ok := <-c.send: + if !ok { + t.Fatalf("client %s: send channel closed, wanted a message", c.ID) + } + var msg Message + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("client %s: invalid message %s: %v", c.ID, raw, err) + } + return msg + case <-time.After(time.Second): + t.Fatalf("client %s: no message received", c.ID) + return Message{} + } +} + +func expectNoMessage(t *testing.T, c *Client) { + t.Helper() + select { + case raw := <-c.send: + t.Fatalf("client %s: unexpected message %s", c.ID, raw) + default: + } +} + +func parse[T any](t *testing.T, msg Message, want MessageType) T { + t.Helper() + if msg.Type != want { + t.Fatalf("message type = %s, want %s", msg.Type, want) + } + var data T + if err := msg.ParseData(&data); err != nil { + t.Fatalf("parse %s data: %v", want, err) + } + return data +} + +func TestJoinRoomSendsExistingPeersAndNotifiesThem(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b") + a, b := c[0], c[1] + + join(hub, a, "BBB:A1") + first := parse[PeersData](t, nextMessage(t, a), MsgPeers) + if len(first.Peers) != 0 { + t.Errorf("first joiner got peers %v, want none", first.Peers) + } + + join(hub, b, "BBB:A1") + second := parse[PeersData](t, nextMessage(t, b), MsgPeers) + if len(second.Peers) != 1 || second.Peers[0].ID != "a" { + t.Errorf("second joiner got peers %v, want [a]", second.Peers) + } + + joined := parse[PeerJoinedData](t, nextMessage(t, a), MsgPeerJoined) + if joined.Peer.ID != "b" || joined.RoomID != "BBB:A1" { + t.Errorf("existing peer notified of %+v, want b joining BBB:A1", joined) + } + expectNoMessage(t, b) +} + +func TestSwitchingRoomsNotifiesOldRoomAndDropsEmptyRoom(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b") + a, b := c[0], c[1] + join(hub, a, "BBB:A1") + join(hub, b, "BBB:A1") + nextMessage(t, a) // peers + nextMessage(t, b) // peers + nextMessage(t, a) // peer_joined b + + join(hub, a, "BBB:B1") + + left := parse[PeerLeftData](t, nextMessage(t, b), MsgPeerLeft) + if left.PeerID != "a" || left.RoomID != "BBB:A1" { + t.Errorf("old room notified %+v, want a left BBB:A1", left) + } + parse[PeersData](t, nextMessage(t, a), MsgPeers) + + if a.GetRoomID() != "BBB:B1" { + t.Errorf("a room = %q, want BBB:B1", a.GetRoomID()) + } + + join(hub, b, "BBB:B1") + if _, ok := hub.GetRoom("BBB:A1"); ok { + t.Error("empty room BBB:A1 still exists") + } +} + +func TestLeaveRoomNotifiesPeersAndDeletesEmptyRoom(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b") + a, b := c[0], c[1] + join(hub, a, "BBB:A1") + join(hub, b, "BBB:A1") + nextMessage(t, a) + nextMessage(t, b) + nextMessage(t, a) + + hub.handleLeaveRoom(&LeaveRoomRequest{Client: b, RoomID: "BBB:A1"}) + + left := parse[PeerLeftData](t, nextMessage(t, a), MsgPeerLeft) + if left.PeerID != "b" { + t.Errorf("peer_left for %q, want b", left.PeerID) + } + if b.GetRoomID() != "" { + t.Errorf("b room = %q after leaving, want empty", b.GetRoomID()) + } + + hub.handleLeaveRoom(&LeaveRoomRequest{Client: a, RoomID: "BBB:A1"}) + if _, rooms := hub.Stats(); rooms != 0 { + t.Errorf("rooms = %d after everyone left, want 0", rooms) + } +} + +func TestLeaveUnknownRoomIsNoOp(t *testing.T) { + hub := NewHub() + a := registered(hub, "a")[0] + + hub.handleLeaveRoom(&LeaveRoomRequest{Client: a, RoomID: "nope"}) + + expectNoMessage(t, a) +} + +func TestUnregisterRemovesClientNotifiesRoomAndClosesSend(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b") + a, b := c[0], c[1] + join(hub, a, "BBB:A1") + join(hub, b, "BBB:A1") + nextMessage(t, a) + nextMessage(t, b) + nextMessage(t, a) + + hub.handleUnregister(b) + + parse[PeerLeftData](t, nextMessage(t, a), MsgPeerLeft) + if _, ok := hub.GetClient("b"); ok { + t.Error("unregistered client still in hub") + } + if _, ok := <-b.send; ok { + t.Error("unregistered client's send channel is still open") + } + + // Unregistering twice must not panic on a double close. + hub.handleUnregister(b) +} + +func TestForwardDeliversOnlyToTargetInSameRoom(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b", "outsider") + a, b, outsider := c[0], c[1], c[2] + join(hub, a, "BBB:A1") + join(hub, b, "BBB:A1") + join(hub, outsider, "BBB:B1") + nextMessage(t, a) + nextMessage(t, b) + nextMessage(t, a) + nextMessage(t, outsider) + + offer := OfferData{From: "a", To: "b", SessionID: "s1", Description: SDPDescription{Type: "offer", SDP: "v=0"}} + hub.handleForwardToPeer(&ForwardRequest{From: a, TargetID: "b", MsgType: MsgOffer, Data: offer}) + + got := parse[OfferData](t, nextMessage(t, b), MsgOffer) + if got.SessionID != "s1" || got.Description.SDP != "v=0" { + t.Errorf("forwarded offer = %+v, want session s1", got) + } + expectNoMessage(t, a) + + // Room isolation: a peer in another room is unreachable even by ID. + hub.handleForwardToPeer(&ForwardRequest{From: a, TargetID: "outsider", MsgType: MsgOffer, Data: offer}) + expectNoMessage(t, outsider) +} + +func TestForwardFromClientOutsideAnyRoomIsDropped(t *testing.T) { + hub := NewHub() + c := registered(hub, "loner", "b") + loner, b := c[0], c[1] + join(hub, b, "BBB:A1") + nextMessage(t, b) + + hub.handleForwardToPeer(&ForwardRequest{From: loner, TargetID: "b", MsgType: MsgOffer, Data: OfferData{}}) + + expectNoMessage(t, b) +} + +func TestBroadcastSkipsExcludedClients(t *testing.T) { + hub := NewHub() + c := registered(hub, "a", "b", "c") + for _, client := range c { + join(hub, client, "BBB:A1") + } + for _, client := range c { + for len(client.send) > 0 { + <-client.send + } + } + + hub.handleBroadcastToRoom(&BroadcastRequest{ + RoomID: "BBB:A1", + ExcludeIDs: []string{"a"}, + MsgType: MsgPeerTalking, + Data: PeerTalkingData{RoomID: "BBB:A1", PeerID: "a", IsTalking: true}, + }) + + expectNoMessage(t, c[0]) + for _, listener := range c[1:] { + talking := parse[PeerTalkingData](t, nextMessage(t, listener), MsgPeerTalking) + if talking.PeerID != "a" || !talking.IsTalking { + t.Errorf("%s got %+v, want a talking", listener.ID, talking) + } + } +} + +func TestSendMessageDropsWhenBufferFull(t *testing.T) { + hub := NewHub() + a := registered(hub, "a")[0] + msg, _ := NewMessage(MsgError, ErrorData{Code: "x"}) + + for i := 0; i < cap(a.send)+10; i++ { + a.SendMessage(msg) // must never block the hub + } + + if len(a.send) != cap(a.send) { + t.Errorf("buffered %d messages, want %d", len(a.send), cap(a.send)) + } +} diff --git a/packages/services/signaling/internal/websocket/messages.go b/packages/services/signaling/internal/websocket/messages.go new file mode 100644 index 0000000..96f3af8 --- /dev/null +++ b/packages/services/signaling/internal/websocket/messages.go @@ -0,0 +1,158 @@ +package websocket + +import ( + "encoding/json" + "time" +) + +// MessageType defines the type of WebSocket message +type MessageType string + +const ( + // Client -> Server messages + MsgJoinRoom MessageType = "join_room" + MsgLeaveRoom MessageType = "leave_room" + MsgOffer MessageType = "offer" + MsgAnswer MessageType = "answer" + MsgCandidate MessageType = "candidate" + MsgPTTStart MessageType = "ptt_start" + MsgPTTEnd MessageType = "ptt_end" + + // Server -> Client messages + MsgPeers MessageType = "peers" + MsgPeerJoined MessageType = "peer_joined" + MsgPeerLeft MessageType = "peer_left" + MsgPeerTalking MessageType = "peer_talking" + MsgError MessageType = "error" +) + +// Message is the base message structure for all WebSocket communication +type Message struct { + Type MessageType `json:"type"` + Data json.RawMessage `json:"data"` +} + +// JoinRoomData is sent when a client wants to join a signaling room +type JoinRoomData struct { + RoomID string `json:"roomId"` + UserID string `json:"userId"` + DeviceInfo string `json:"deviceInfo,omitempty"` +} + +// LeaveRoomData is sent when a client wants to leave a room +type LeaveRoomData struct { + RoomID string `json:"roomId"` +} + +// Peer represents information about a connected peer +type Peer struct { + ID string `json:"id"` + UserID string `json:"userId"` + DeviceInfo string `json:"deviceInfo,omitempty"` + JoinedAt int64 `json:"joinedAt"` +} + +// PeersData is sent to a client when they join a room, listing all current peers +type PeersData struct { + RoomID string `json:"roomId"` + Peers []Peer `json:"peers"` +} + +// PeerJoinedData is broadcast when a new peer joins the room +type PeerJoinedData struct { + RoomID string `json:"roomId"` + Peer Peer `json:"peer"` +} + +// PeerLeftData is broadcast when a peer leaves the room +type PeerLeftData struct { + RoomID string `json:"roomId"` + PeerID string `json:"peerId"` +} + +// OfferData contains WebRTC SDP offer +type OfferData struct { + From string `json:"from"` + To string `json:"to"` + SessionID string `json:"sessionId"` + Description SDPDescription `json:"description"` +} + +// AnswerData contains WebRTC SDP answer +type AnswerData struct { + From string `json:"from"` + To string `json:"to"` + SessionID string `json:"sessionId"` + Description SDPDescription `json:"description"` +} + +// SDPDescription represents WebRTC SDP +type SDPDescription struct { + Type string `json:"type"` // "offer" or "answer" + SDP string `json:"sdp"` +} + +// CandidateData contains ICE candidate information +type CandidateData struct { + From string `json:"from"` + To string `json:"to"` + SessionID string `json:"sessionId"` + Candidate ICECandidate `json:"candidate"` +} + +// ICECandidate represents a WebRTC ICE candidate +type ICECandidate struct { + Candidate string `json:"candidate"` + SDPMid string `json:"sdpMid"` + SDPMLineIndex int `json:"sdpMLineIndex"` +} + +// PTTStartData is sent when a user starts transmitting +type PTTStartData struct { + RoomID string `json:"roomId"` +} + +// PTTEndData is sent when a user stops transmitting +type PTTEndData struct { + RoomID string `json:"roomId"` +} + +// PeerTalkingData is broadcast to notify others that a peer is talking +type PeerTalkingData struct { + RoomID string `json:"roomId"` + PeerID string `json:"peerId"` + IsTalking bool `json:"isTalking"` +} + +// ErrorData is sent when an error occurs +type ErrorData struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// NewMessage creates a new message with the given type and data +func NewMessage(msgType MessageType, data interface{}) (*Message, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, err + } + return &Message{ + Type: msgType, + Data: jsonData, + }, nil +} + +// ParseData parses the message data into the provided struct +func (m *Message) ParseData(v interface{}) error { + return json.Unmarshal(m.Data, v) +} + +// NewPeer creates a new Peer with the current timestamp +func NewPeer(id, userID, deviceInfo string) Peer { + return Peer{ + ID: id, + UserID: userID, + DeviceInfo: deviceInfo, + JoinedAt: time.Now().Unix(), + } +} diff --git a/packages/services/signaling/internal/websocket/messages_test.go b/packages/services/signaling/internal/websocket/messages_test.go new file mode 100644 index 0000000..ddbb03e --- /dev/null +++ b/packages/services/signaling/internal/websocket/messages_test.go @@ -0,0 +1,59 @@ +package websocket + +import ( + "encoding/json" + "testing" + "time" +) + +func TestNewMessageRoundTripsData(t *testing.T) { + msg, err := NewMessage(MsgJoinRoom, JoinRoomData{RoomID: "BBB:A1", UserID: "rider-1"}) + if err != nil { + t.Fatalf("NewMessage: %v", err) + } + + var got JoinRoomData + if err := msg.ParseData(&got); err != nil { + t.Fatalf("ParseData: %v", err) + } + + if msg.Type != MsgJoinRoom || got.RoomID != "BBB:A1" || got.UserID != "rider-1" { + t.Errorf("round trip = %s %+v, want join_room BBB:A1 rider-1", msg.Type, got) + } +} + +// The Flutter client depends on these exact JSON field names; renaming a Go struct tag +// silently breaks signaling on the phones. +func TestWireFormatMatchesFlutterClient(t *testing.T) { + msg, err := NewMessage(MsgPeerTalking, PeerTalkingData{RoomID: "r", PeerID: "p", IsTalking: true}) + if err != nil { + t.Fatalf("NewMessage: %v", err) + } + raw, err := json.Marshal(msg) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + want := `{"type":"peer_talking","data":{"roomId":"r","peerId":"p","isTalking":true}}` + if string(raw) != want { + t.Errorf("wire format = %s, want %s", raw, want) + } +} + +func TestNewMessageRejectsUnmarshalableData(t *testing.T) { + if _, err := NewMessage(MsgError, make(chan int)); err == nil { + t.Error("NewMessage with a channel payload returned nil error") + } +} + +func TestNewPeerStampsJoinTime(t *testing.T) { + before := time.Now().Unix() + peer := NewPeer("client_1", "rider-1", "Pixel") + + if peer.ID != "client_1" || peer.UserID != "rider-1" || peer.DeviceInfo != "Pixel" { + t.Errorf("NewPeer = %+v, want fields copied", peer) + } + if peer.JoinedAt < before { + t.Errorf("JoinedAt = %d, want >= %d", peer.JoinedAt, before) + } +}