Version: 2.0 (Hardened)
Date: March 14, 2026
Status: Draft
Package ID: com.port80.app
App Name: StreamCaster
StreamCaster is a free, open-source native Android application that captures video and/or audio from the device camera and microphone and streams it in real-time to a user-configured RTMP/RTMPS ingestion endpoint.
Distributed via Google Play Store, F-Droid, and direct APK download.
- Native Android app to live-stream camera and/or microphone to a single RTMP/RTMPS ingestion endpoint.
- Optional concurrent local MP4 recording.
- Basic streaming HUD (bitrate, fps, resolution, duration, connection state).
- Multiple saved endpoint profiles with encrypted credential storage.
- Adaptive bitrate with device-capability-aware quality ladder.
- Background-capable via foreground service.
The following are explicitly out of scope for the current version:
- Multi-destination streaming.
- Overlay rendering beyond a no-op architectural hook.
- H.265 encoding (deferred).
- SRT protocol (deferred).
- Stream scheduling.
- Analytics or tracking SDKs.
- Ads or in-app purchases.
- GMS dependencies in the
fossbuild flavor. - Tablet or Chromebook-optimized UI.
| Component | Choice | Rationale |
|---|---|---|
| Language | Kotlin | Modern, concise, null-safe. RootEncoder is 65% Kotlin-native, ensuring seamless interop. |
| Streaming Library | RootEncoder v2.7.x (Apache 2.0) | Actively maintained (daily commits as of March 2026). Supports RTMP, RTMPS, RTSP, SRT. Provides Camera2 integration, adaptive bitrate, H.264/H.265/AAC encoding. |
| Camera Framework | RootEncoder RtmpCamera2 (Camera2 internally) |
RootEncoder's built-in camera class is the sole camera owner. No CameraX or Camera1 layering. See §5.3. |
| Build System | Gradle (Kotlin DSL) with Android Gradle Plugin 8.x | Standard toolchain, compatible with VS Code + Gradle extension. |
| Min SDK | API 23 (Android 6.0 Marshmallow) | Required for EncryptedSharedPreferences (Android Keystore-backed), runtime permissions model, and modern MediaCodec behavior. Covers ~97% of active Android devices. |
| Target SDK | API 35 (Android 15) | Required for Google Play Store submission in 2026. |
| Compile SDK | 35 | Access to latest platform APIs. |
| Architecture | MVVM with Android ViewModel + StateFlow | Clean separation, lifecycle-aware, testable. |
| DI | Hilt | Standard Jetpack DI, minimal boilerplate. |
| UI | Jetpack Compose + Material 3 | Modern declarative UI; the camera preview surface uses an AndroidView wrapper around the RootEncoder preview. |
| Persistence | DataStore (Preferences) | For storing non-sensitive settings (default camera, resolution, etc.). |
| Credential Storage | EncryptedSharedPreferences (Keystore-backed) | For stream keys, passwords. Requires API ≥ 23. |
| Background Service | Foreground Service (type camera + microphone) |
Required for background streaming. Displays a persistent notification. |
| Crash Reporting | ACRA (Apache 2.0) | Open-source, privacy-respecting, F-Droid compatible. Reports via email or self-hosted HTTP endpoint. No third-party cloud dependencies. |
| Library | Min API | RTMPS | Active | Verdict |
|---|---|---|---|---|
| RootEncoder | 16 (23 for Camera2 path) | Yes | Yes (March 2026) | Selected — widest compat, full feature set |
| Larix SDK | 24 | Yes | Yes | Rejected — higher min API, proprietary |
| libstreaming | 14 | No | No (EOL) | Rejected — dead project, no RTMP |
| HaishinKit Android | N/A | N/A | No | Rejected — no maintained Android port |
| Dimension | Assumption |
|---|---|
| Min SDK | API 23 (Android 6.0). Required for EncryptedSharedPreferences, runtime permissions, and modern MediaCodec behavior. |
| Target SDK | API 35 (Android 15). Required for Google Play Store submission in 2026. |
| Compile SDK | 35. |
| Device class | Phones only. Tablets and Chromebooks are not guaranteed to work and are not tested against. |
| Camera/encoder | Hardware H.264 (Baseline/Main) + AAC-LC expected. H.265 is deferred. Devices without a hardware H.264 encoder are unsupported. |
| Network | Hostile networks assumed. RTMPS is the preferred transport. RTMP is permitted only with explicit user consent (see §9). |
| OEM posture | Aggressive battery/FGS restrictions (Samsung, Xiaomi, Huawei, etc.) assumed active. Doze and app-standby are assumed active. The app must not rely on behavior that only works with battery optimizations disabled. |
| App Standby Buckets | API 28+ may classify infrequently used apps into the RARE bucket, restricting background network access. A user-initiated FGS start from the foreground exempts the app for that session. Connection retry logic must tolerate one initial network failure and retry on ConnectivityManager.NetworkCallback.onAvailable() rather than assuming network availability at FGS start. |
| ID | Requirement | Priority |
|---|---|---|
| MC-01 | Stream video only, audio only, or both — user selects before or during stream. Mid-session video→audio downgrade is permitted; audio→video upgrade requires camera reacquire and encoder re-init. | Must |
| MC-02 | Default to back camera. User can switch to front camera before or during stream. | Must |
| MC-03 | Live camera preview displayed before and during streaming. Preview must rebind after process death or activity recreation if the service is still alive. | Must |
| MC-04 | Orientation (portrait / landscape) selected by user before stream start; locked for the duration of the active session; unlocked only when idle. The lock must be applied in Activity.onCreate() before setContentView() using the persisted orientation preference — not as a post-stream-start action. When a stream is active, the lock is applied unconditionally on every onCreate() to prevent a re-orientation race during Activity recreation that would trigger a second configuration-change cycle and corrupt the Surface/preview state. |
Must |
| MC-05 | Local recording — optional toggle to save a local MP4 copy simultaneously. On API 29+, the user selects a storage target via SAF or the app writes to MediaStore; enabling the toggle must immediately trigger an ACTION_OPEN_DOCUMENT_TREE SAF picker and persist the resulting URI via takePersistableUriPermission() — recording must not be activatable without a valid persisted storage grant. Local recording must tee encoded output buffers from the single hardware encoder into both the RTMP muxer and the MP4 muxer; no second encoder instance may be opened. On API 23–28, the app writes to app-specific external storage (getExternalFilesDir) with an export/share flow. If storage permission is denied or unavailable, recording must fail fast with a user prompt; streaming must not be blocked. |
Must |
| ID | Requirement | Default | Priority |
|---|---|---|---|
| VS-01 | Resolution selectable from device-supported list, capped to codec-supported profiles/levels via MediaCodecInfo. |
720p (1280×720) | Must |
| VS-02 | Frame rate selectable: 24, 25, 30, 60 fps — shown only if the device encoder advertises support. | 30 fps | Must |
| VS-03 | Video codec: H.264 (Baseline/Main profile). H.265 deferred. | H.264 | Must |
| VS-04 | Video bitrate selectable or auto. Range: 500 kbps – 8 Mbps, capped to encoder capability. | 2.5 Mbps (for 720p30) | Must |
| VS-05 | Keyframe interval configurable (1–5 seconds). | 2 seconds | Should |
| ID | Requirement | Default | Priority |
|---|---|---|---|
| AS-01 | Audio codec: AAC-LC. | AAC-LC | Must |
| AS-02 | Sample rate: 44100 Hz or 48000 Hz. | 44100 Hz | Must |
| AS-03 | Audio bitrate: 64 / 96 / 128 / 192 kbps. | 128 kbps | Must |
| AS-04 | Channels: Mono / Stereo. | Stereo | Should |
| AS-05 | Mute toggle during active stream (stops sending audio data). | — | Must |
| ID | Requirement | Priority |
|---|---|---|
| EP-01 | User can enter an RTMP URL (e.g., rtmp://ingest.example.com/live). |
Must |
| EP-02 | Support RTMPS (RTMP over TLS/SSL) endpoints. | Must |
| EP-03 | Optional stream key field (appended to URL or sent separately, per convention). | Must |
| EP-04 | Optional username / password authentication fields. | Must |
| EP-05 | Save as default — persists the last-used endpoint + key so the user doesn't re-enter it. Credentials stored only via EncryptedSharedPreferences. | Must |
| EP-06 | Multiple saved endpoint profiles (name + URL + key + auth). | Should |
| EP-07 | Connection test button — validates connectivity before going live. Must obey the same transport security rules as live streaming (see §9.2). | Should |
| ID | Requirement | Priority |
|---|---|---|
| AB-01 | Toggle to enable/disable adaptive bitrate. | Must |
| AB-02 | When enabled, dynamically lower video bitrate and/or resolution within a device-capability-aware ABR ladder on network congestion. | Must |
| AB-03 | Automatically recover bitrate when bandwidth improves. | Must |
| AB-04 | Display current effective bitrate on the streaming HUD. | Should |
| ID | Requirement | Priority |
|---|---|---|
| SL-01 | Start / Stop stream via prominent button. | Must |
| SL-02 | Auto-reconnect on network drop — configurable retry count (default: unlimited) and interval using exponential backoff with jitter (3 s, 6 s, 12 s, …, cap 60 s). Reconnect must operate within an already-running FGS; no new FGS starts from background. Reconnect attempts must be driven by ConnectivityManager.NetworkCallback.onAvailable() events in addition to the backoff timer; timer-based retries are suppressed while the device is in Doze to avoid burning backoff steps against Doze-blocked sockets — reconnect fires on onAvailable(), which already aligns with Doze maintenance windows. |
Must |
| SL-03 | Background streaming — continues via foreground service when app is backgrounded or screen is off. The FGS may only be started from a user-initiated action (in-app button) while the activity is in the foreground (API 31+ FGS start restrictions). Notification actions cannot start a new FGS; see §7.1. | Must |
| SL-04 | Notification controls — start/stop/mute accessible from the persistent notification. A stop action must cancel any in-flight reconnect and leave the stream fully stopped. Actions must be debounced to prevent double-toggle races. | Must |
| SL-05 | Graceful shutdown on low battery (configurable threshold, default 5%). Auto-stop and finalize local recording at critical (≤ 2%). | Should |
| SL-06 | Background camera revocation handling — when the OS revokes camera access in the background, cleanly stop the video track, keep the audio-only RTMP session alive (or send a static placeholder frame if video-only mode). Show "Camera paused" in the notification. On return to foreground, re-acquire camera, re-init video encoder, and send an IDR frame to resume video. | Must |
| SL-07 | Thermal throttling response — on API 29+, register PowerManager.OnThermalStatusChangedListener. On THERMAL_STATUS_MODERATE: show HUD warning. On THERMAL_STATUS_SEVERE: step down the ABR ladder (e.g., 720p→480p, 30→15 fps), performing a controlled encoder restart if resolution/fps change requires it. On THERMAL_STATUS_CRITICAL: stop stream and recording gracefully and show the user the reason. Enforce a minimum 60-second cooldown between thermal-triggered step changes to avoid rapid oscillation. Restore quality when thermals return to normal. On API 23–28 (where OnThermalStatusChangedListener is unavailable), register a BroadcastReceiver for Intent.ACTION_BATTERY_CHANGED and monitor BatteryManager.EXTRA_TEMPERATURE; apply the same degradation progression when temperature exceeds 38°C (warn / bitrate reduction), with graceful stream stop at ≥ 43°C. |
Must |
| SL-08 | Audio focus / interruption handling — on incoming call or audio focus loss, mute the microphone and show a muted indicator. Resume sending audio only on explicit user action (unmute). | Must |
| ID | Requirement | Priority |
|---|---|---|
| OV-01 | Architecture supports an overlay pipeline (text, timestamps, watermarks) that can be rendered onto the video frame before encoding. | Must (arch) |
| OV-02 | Actual overlay rendering implementation. | Deferred |
Implementation note: RootEncoder supports
GlStreamInterfacefor custom OpenGL filters. The architecture will include a pluggableOverlayManagerinterface with a no-op default implementation. Future overlays will implement this interface and render via OpenGL shaders.
| ID | Requirement | Target |
|---|---|---|
| NF-01 | Startup to preview in < 2 seconds on mid-range devices. | Must |
| NF-02 | Streaming latency (glass-to-glass) ≤ 3 seconds over stable LTE. Surface as a debug metric if exceeded. | Should |
| NF-03 | Battery drain ≤ 15% per hour of streaming at 720p30 on the reference device class (mid-range, 4000 mAh, API 28+). Low-end API 23 devices are not bound by this target; measured drain on low-end hardware must be documented in the test report. | Should |
| NF-04 | Crash-free rate ≥ 99.5%. | Must |
| NF-05 | APK size < 15 MB (before Play Store optimization). | Should |
| NF-06 | No third-party analytics or tracking SDKs. | Must |
| NF-07 | All sensitive data (stream keys, passwords) must be stored encrypted via Android Keystore-backed EncryptedSharedPreferences. The app must never fall back to plaintext storage. | Must |
| NF-08 | No custom SSL bypass. RTMPS connections must use the system default TrustManager. No X509TrustManager that accepts all certificates. Users with self-signed certs install them via Android system settings. |
Must |
| NF-09 | Thermal awareness. On API 29+, register OnThermalStatusChangedListener. On API 23–28, fall back to BatteryManager.EXTRA_TEMPERATURE via ACTION_BATTERY_CHANGED broadcasts. Progressively degrade stream quality to prevent device overheating and OS-forced frame drops. See SL-07. |
Must |
┌─────────────────────────────────────────────────────────┐
│ UI Layer │
│ ┌──────────┐ ┌──────────┐ ┌───────────────────────┐ │
│ │ Preview │ │ Controls │ │ Settings Screens │ │
│ │ (Compose)│ │ (Compose)│ │ (Compose + Navigation)│ │
│ └────┬─────┘ └────┬─────┘ └──────────┬────────────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼───────────────────▼────────────┐ │
│ │ ViewModels (MVVM) │ │
│ │ StreamViewModel · SettingsViewModel │ │
│ └────────────────────┬──────────────────────────────┘ │
│ │ binds to service │
├───────────────────────┼──────────────────────────────────┤
│ Domain / Service Layer │
│ ┌────────────────────▼──────────────────────────────┐ │
│ │ StreamingService (Foreground Service) │ │
│ │ ← authoritative source of stream state → │ │
│ │ ┌─────────────┐ ┌────────────┐ ┌───────────┐ │ │
│ │ │ RtmpCamera2 │ │ AudioSource│ │OverlayMgr │ │ │
│ │ │ (Camera2 │ │ (Mic) │ │ (No-op) │ │ │
│ │ │ internally)│ │ │ │ │ │ │
│ │ └──────┬──────┘ └─────┬──────┘ └─────┬─────┘ │ │
│ │ │ │ │ │ │
│ │ ┌──────▼───────────────▼───────────────▼──────┐ │ │
│ │ │ RootEncoder Streaming Engine │ │ │
│ │ │ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │ │ │
│ │ │ │H.264 Enc │ │ AAC Enc │ │ RTMP/S Conn │ │ │ │
│ │ │ └──────────┘ └──────────┘ └─────────────┘ │ │ │
│ │ │ ┌──────────────┐ ┌───────────────────────┐ │ │ │
│ │ │ │Adaptive Rate │ │ Local Muxer (opt MP4) │ │ │ │
│ │ │ └──────────────┘ └───────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────┤
│ Data Layer │
│ ┌────────────────┐ ┌──────────────────────────────┐ │
│ │ SettingsRepo │ │ EndpointProfileRepo │ │
│ │ (DataStore) │ │ (EncryptedSharedPreferences) │ │
│ └────────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
| Component | Responsibility |
|---|---|
StreamViewModel |
Binds to StreamingService. Reads authoritative streaming state modelled as a sealed class StreamState: Idle, Connecting, Live(cameraActive: Boolean), Reconnecting, Stopping, Stopped(reason: StopReason) where StopReason is USER_REQUEST, ERROR_ENCODER, ERROR_AUTH, ERROR_CAMERA, THERMAL_CRITICAL, or BATTERY_CRITICAL. Exposes preview surface, stream stats, and control actions. All start/stop/mute commands are idempotent. |
SettingsViewModel |
Reads/writes user preferences. Queries device for supported resolutions, frame rates, and codec profiles via DeviceCapabilityQuery. |
StreamingService |
Android Foreground Service (camera + microphone types). Owns the RootEncoder instance. Is the single source of truth for stream state. Manages lifecycle independently of the Activity so streaming survives backgrounding. Exposes state via StateFlow to bound clients. |
DeviceCapabilityQuery |
Queries CameraManager and MediaCodecList for available cameras, resolutions, frame rates, and codec profiles/levels. Used by settings UI only — does NOT own the camera or open it. |
AudioSourceManager |
Configures microphone via RootEncoder's MicrophoneManager. |
OverlayManager |
Interface with fun onDrawFrame(canvas: GlCanvas). Default no-op. Future overlays plug in here. |
SettingsRepository |
Persists non-sensitive settings via Jetpack DataStore. |
EndpointProfileRepository |
CRUD for saved RTMP endpoint profiles. Credentials encrypted via EncryptedSharedPreferences backed by Android Keystore. |
ConnectionManager |
Handles RTMP connect/disconnect, auto-reconnect logic with exponential backoff + jitter, connection health monitoring. Cancels retries on explicit user stop. |
Design decision: RootEncoder provides optimized, battle-tested camera management classes (
RtmpCamera2) that tightly couple camera capture with hardware encoding and RTMP muxing. Layering CameraX or a separate Camera2 session on top would risk surface contention, double camera ownership, and pipeline desynchronization.Therefore, the app uses
RtmpCamera2exclusively for camera ownership.
- No CameraX dependency.
- No Camera1 path (minSdk is 23; Camera2 is universally available).
DeviceCapabilityQueryonly readsCameraCharacteristicsandMediaCodecInfo; it never opens the camera.
┌───────────────────────────────┐
│ RootEncoder Camera Classes │
│ (sole camera owner) │
├───────────────────────────────┤
│ API ≥ 23 │
│ → RtmpCamera2 (Camera2) │
└───────────────────────────────┘
Camera switching and preview attachment are delegated directly to RtmpCamera2.switchCamera() and RtmpCamera2.startPreview(surfaceView).
- The foreground service must declare
android:foregroundServiceType="camera|microphone"in the manifest<service>element. This attribute is required from API 30 to legally access the camera or microphone from an FGS. Additionally,<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />andFOREGROUND_SERVICE_MICROPHONEare required as explicit permissions from API 34. - The FGS may only be started from a user-initiated action: the in-app Start button while the Activity is in the foreground. This satisfies API 31+ FGS start restrictions.
- Notification actions cannot start a new FGS. On API 34+, calling
startForegroundService()with camera/microphone types from aBroadcastReceiverPendingIntentwhile the app is not in the foreground throwsBackgroundServiceStartNotAllowedException. The notification "Start" action must deep-link to the Activity via an explicitIntent; it must never callstartForegroundService()directly. Notification Stop and Mute/Unmute remain valid controls on an already-running FGS. - Auto-reconnect operates within an already-running FGS. The app must never attempt to start a new FGS from the background without a user affordance.
- If the OS kills the FGS, the app must not silently restart it. On next activity launch, display a notification or in-app message indicating the session ended and require the user to start a new session.
StreamViewModelbinds toStreamingServiceviaServiceConnection.- The service exposes authoritative state via
StateFlow<StreamState>. The UI layer is a read-only observer of this state. - On activity recreation (config change, process death with surviving service), the ViewModel must rebind, restore the preview surface to the existing RootEncoder instance, and reflect current stats.
- If the service has been killed by the time the activity restarts, the ViewModel must show
Stopped(USER_REQUEST)state and clear any stale reconnect state. - The ViewModel must hold the preview surface reference as a
WeakReference<SurfaceHolder>or use aSurfaceRequest-style signal. The Activity/Composable sets this reference onsurfaceCreated()and clears it onsurfaceDestroyed(). The ViewModel must never retain a strong reference to aVieworSurfaceacross Activity lifecycle boundaries.
- If the service is alive and the activity process is recreated, the preview surface must be re-attached to RootEncoder's existing camera session.
- When rebinding after process death,
RtmpCamera2.startPreview()must not be called untilSurfaceHolder.Callback.surfaceCreated()has fired on the newSurfaceView. The ViewModel must gate the preview attach using aCompletableDeferred<SurfaceHolder>or equivalent surface-ready signal resolved by theAndroidViewcomposable. CallingstartPreview()before the Surface is attached causes the camera HAL to throwIllegalArgumentException: invalid surface. - If both activity and service are dead, the app starts in the default idle state. No automatic stream resumption occurs.
- The persistent FGS notification shows current state: Live, Reconnecting, Paused (camera revoked), or Stopped.
- Notification actions: Start, Stop, Mute/Unmute.
- A Stop action must immediately cancel any pending reconnect attempts and transition to stopped state. No zombie notifications may persist after the service stops.
- Actions must be debounced (≥ 500 ms) to prevent double-toggle races between notification and in-app UI.
- Before starting a stream, validate the chosen resolution, frame rate, and profile against
MediaCodecInfo.CodecCapabilitiesandVideoCapabilities. If the device cannot support the requested configuration, fail fast with an actionable error message and suggest a supported configuration. - Pre-flight: attempt
MediaCodec.configure()with the chosen parameters before connecting to the RTMP endpoint to catch encoder failures early. - During streaming, monitor the actual encoded output frame rate (frames delivered from the
MediaCodecoutput buffer) against the configured input fps. If measured output fps falls below 80% of configured fps for more than 5 consecutive seconds, treat this as a backpressure event and trigger the ABR step-down path. This detects hardware-level encoder throttling thatMediaCodecInfo.VideoCapabilities.isSizeAndRateSupported()cannot predict under sustained thermal load (particularly on MediaTek/Unisoc SoCs).
- Define a per-device quality ladder based on encoder capabilities, e.g.:
- 1080p30 → 720p30 → 540p30 → 480p30 (resolution steps)
- 30 fps → 24 fps → 15 fps (frame rate steps)
- Bitrate scales proportionally to resolution × fps.
- ABR first reduces bitrate only. If insufficient, step down resolution/fps via controlled encoder restart.
- Prefer bitrate reduction before frame skipping. If encoder backpressure is detected, drop non-keyframes first.
- All quality-change requests from both the ABR system and the thermal throttling system (SL-07) must be serialized through a single
EncoderControllercomponent using a coroutineMutexorChannel. This prevents concurrentMediaCodec.release()/configure()/start()sequences from racing and throwingIllegalStateExceptionwhen both systems fire within the same window (e.g., network degrades while device heats). - The 60-second thermal cooldown (SL-07) applies only to thermal-triggered resolution/fps changes that require an encoder restart. ABR bitrate-only reductions and recoveries do not require an encoder restart and bypass the cooldown entirely. ABR resolution/fps changes that do require an encoder restart are subject to the cooldown timer.
- Resolution or frame rate changes during a live stream require a controlled re-init sequence:
- Stop the preview and video track.
- Release the encoder.
- Reconfigure with the new profile.
- Restart the encoder and preview.
- Send an IDR frame immediately.
- Target: stream gap ≤ 3 seconds during a quality change.
- Expose a
droppedFrameCountmetric (see §14). - Prefer bitrate reduction over frame dropping.
- If backpressure forces drops, drop B/P-frames before keyframes.
- Target glass-to-glass ≤ 3 seconds on stable LTE.
- Surface current measured latency as a debug metric if it exceeds target.
- Stream keys and passwords must be stored using EncryptedSharedPreferences backed by Android Keystore.
- The app must never fall back to plaintext storage under any circumstance.
- MinSdk 23 guarantees Keystore availability. No API < 23 fallback path exists or is needed.
- The FGS must never receive stream keys or RTMP URLs with embedded credentials via
Intentextras. The FGS startIntentcarries only a non-sensitive profile ID (StringorLong); the service retrieves credentials directly fromEndpointProfileRepositoryat runtime. This prevents key exfiltration viaadb shell dumpsys activity serviceor crash-report Intent capture. - The manifest must declare
android:allowBackup="false"in the<application>element, or configure aBackupAgentwith rules that exclude the EncryptedSharedPreferences files from cloud and device-to-device backup. When the app is installed on a new device after a restore and the Keystore key is absent, it must display an explicit prompt informing the user that credentials must be re-entered.
- If a profile includes authentication (username/password) or a stream key, the app must enforce RTMPS.
- If the user has configured auth and enters an
rtmp://URL, the app must display a warning dialog explaining the risk of sending credentials over plaintext and require explicit per-attempt opt-in before proceeding. - The connection test button must obey the same transport rules: it must not send credentials over plaintext RTMP without explicit user consent.
- RTMPS must use the system default
TrustManager. No customX509TrustManagerthat accepts all certificates. Users needing self-signed certs must install them into the Android system trust store.
- The app must never log RTMP URLs containing stream keys, auth headers, passwords, or tokens in any log level.
- All sensitive fields must be masked in logs and metrics (e.g.,
rtmp://host/app/****). - ACRA crash reports must:
- Exclude or redact RTMP URLs, stream keys, and auth fields from all
ReportFieldentries. - Fully exclude
ReportField.SHARED_PREFERENCESandReportField.LOGCATfrom all report configurations. - Disable automatic logcat attachment unconditionally in release builds. RootEncoder logs full RTMP URLs (including stream key path segments) at
Log.d/Log.ilevel internally; these cannot be safely scrubbed after they enter Logcat. - Apply a URL-sanitization transformation to all remaining string-valued fields before any field is serialized: replace key path segments using the pattern
rtmp[s]?://([^/\s]+/[^/\s]+)/\S+→rtmp[s]://<host>/<app>/****. - Include a unit test verifying that a synthetic crash report containing a known stream key string produces zero occurrences of that string after the sanitization pass.
- Send reports only to user-configured endpoints. ACRA HTTP transport must enforce HTTPS. If the user configures a plain
http://endpoint, the app must display a warning and require explicit opt-in. Plaintext crash report transmission must never occur silently.
- Exclude or redact RTMP URLs, stream keys, and auth fields from all
| Permission | When Requested | Required For |
|---|---|---|
CAMERA |
Stream start (video modes) | Video capture |
RECORD_AUDIO |
Stream start (audio modes) | Audio capture |
FOREGROUND_SERVICE |
Manifest (auto-granted) | Background streaming |
FOREGROUND_SERVICE_CAMERA |
Manifest (API 34+) | FGS type permission (<uses-permission>). Distinct from android:foregroundServiceType attribute, which is required from API 30. |
FOREGROUND_SERVICE_MICROPHONE |
Manifest (API 34+) | FGS type permission (<uses-permission>). Distinct from android:foregroundServiceType attribute, which is required from API 30. |
POST_NOTIFICATIONS |
Runtime (API 33+) | FGS notification display |
INTERNET |
Manifest (auto-granted) | RTMP connection |
WAKE_LOCK |
Manifest (auto-granted) | Keep CPU alive during background stream |
Removed:
WRITE_EXTERNAL_STORAGEis not needed with minSdk 23. Local recording uses app-specific external storage (API 23–28) or MediaStore/SAF (API 29+).
App Launch
│
├─ API ≥ 33? → Request POST_NOTIFICATIONS
│
└─ User taps "Start Stream"
│
├─ Video enabled? → Check CAMERA permission
│ └─ Denied? → Show rationale → Re-request or disable video
│
├─ Audio enabled? → Check RECORD_AUDIO permission
│ └─ Denied? → Show rationale → Re-request or disable audio
│
└─ All required permissions granted → Start StreamingService → Connect RTMP
- The camera and microphone must only be accessed while the foreground service is active with the corresponding type declarations.
- Camera/mic use indicators are shown per OS defaults (API 31+ privacy indicators).
- No audio or video capture may occur without an active FGS.
| Screen | Description |
|---|---|
| Main / Stream | Camera preview (full-screen), start/stop button, mute button, camera-switch button, stream status badge, recording indicator. Minimal HUD overlay showing: bitrate, FPS, duration, connection status. |
| Endpoint Setup | RTMP(S) URL field, stream key field, optional username/password, "Test Connection" button, "Save as Default" toggle, saved profiles list. |
| Video/Audio Settings | Resolution picker (filtered by device), frame rate picker, video bitrate slider, audio bitrate picker, mono/stereo toggle, ABR enable/disable, keyframe interval, local recording toggle. |
| General Settings | Default camera (front/back), orientation mode (landscape default, explicit portrait toggle), auto-reconnect toggle + retry settings, battery threshold, media stream selection (video+audio / video-only / audio-only). |
Main (Stream) ──┬── Endpoint Setup
├── Video/Audio Settings
└── General Settings
Single-activity architecture with Compose Navigation.
Since landscape is the primary UX (see §19 Decision 9), the landscape layout is the normative reference. Portrait is a secondary option explicitly toggled by the user.
┌──────────────────────────────────────────────────┐
│ ● LIVE 00:12:34 ⇕ 2.4 Mbps 30fps 720p 🔴 REC │ ← status bar
│ ┌────────┐ │
│ │[🔇 Mute]│ │
│ [Camera Preview] │[⏺ START]│ │
│ (fills width, 16:9 aspect ratio) │[🔄 Cam ]│ │
│ └────────┘ │
└──────────────────────────────────────────────────┘
Controls are at the right edge in landscape to remain within thumb reach. All UI elements must respect WindowInsets.displayCutout and WindowInsets.navigationBars to avoid occlusion by system UI or camera cutouts.
┌──────────────────────────────────────┐
│ ● LIVE 00:12:34 🔴 REC │ ← status bar
│ │
│ │
│ [Camera Preview] │
│ │
│ │
│ ↕ 2.4 Mbps 30fps 720p │ ← stats bar
├──────────────────────────────────────┤
│ [🔇 Mute] [⏺ START] [🔄 Cam] │ ← controls
└──────────────────────────────────────┘
Note: Permissions and permissions flow are covered in §9.4 and §9.5.
| Scenario | Behavior |
|---|---|
| Network drop | Pause send. Reconnect with exponential backoff + jitter (3 s, 6 s, 12 s, …, cap 60 s). In Doze, retries must not exceed one per minute unless the user has exempted the app from battery optimizations. Show "Reconnecting…" badge. Resume on success. |
| RTMP auth failure | Stop stream, show error with option to edit credentials. |
| Encoder error | Attempt one re-init. If it fails, stop stream and show explicit error identifying the failure cause. |
| Camera unavailable | Try alternate camera. If none available, offer audio-only mode. |
| Camera revoked (background) | Cleanly stop video track. Keep audio-only RTMP session alive (or send static placeholder frame if video-only). Show "Camera paused" in notification. On return to foreground: re-acquire camera, re-init video encoder, send IDR to resume video. |
| Microphone revoked mid-stream | Stop stream entirely and surface an error. Audio track loss cannot be gracefully degraded. |
| Thermal throttle | On THERMAL_STATUS_MODERATE: warn user via HUD badge. On THERMAL_STATUS_SEVERE: step down ABR ladder with controlled encoder restart if needed (minimum 60 s between steps). On THERMAL_STATUS_CRITICAL: stop stream and recording gracefully, display reason to user. |
| FGS killed by OS | Do not silently restart. On next activity launch, display notification/toast indicating session ended. Require user to start a new session. |
| Low battery | Below configured threshold: show warning. Below critical (≤ 2%): auto-stop stream and finalize local recording. |
| Prolonged session | On low-end devices (ActivityManager.isLowRamDevice() == true or available RAM < 2 GB at stream start), app monitors session duration. After a configurable default of 90 minutes (adjustable in General Settings), show a notification recommending stopping to prevent heat/battery risk. Suppressed if BatteryManager.isCharging() is true at recommendation time. |
| Insufficient storage | Stop recording, continue streaming, notify user. |
| Audio focus loss / incoming call | Mute microphone and show muted indicator. Resume sending audio only on explicit user action (unmute button). |
The following metrics must be tracked internally for HUD display and debug diagnostics. They must not contain PII or credentials.
- Current and target bitrate (video/audio).
- Current fps and dropped frame count.
- Encoder init success/failure count.
- Reconnect attempt count and success/failure ratio.
- Thermal level transitions.
- Storage write errors.
- FGS start success/failure events.
- Permission denial events.
The streaming HUD must display: live bitrate, fps, resolution, session duration, connection state (live/reconnecting/stopped), recording state (on/off), and a thermal warning badge when quality has been degraded.
- Structured logging via Logcat only in debug builds.
- All secrets must be redacted in every log level (debug and release).
- Production logs must be minimal and rate-limited.
- The connection test endpoint should use a lightweight probe (e.g., RTMP handshake only, or HEAD/OPTIONS where applicable).
- Timeouts for connection test must be capped (default: 10 seconds).
- Test result must be surfaced to the user with actionable messaging (success, timeout, auth failure, TLS error).
app/
├── build.gradle.kts
├── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── kotlin/com/port80/app/
│ │ │ ├── App.kt // Application class + Hilt
│ │ │ ├── MainActivity.kt // Single activity
│ │ │ ├── navigation/
│ │ │ │ └── AppNavGraph.kt
│ │ │ ├── ui/
│ │ │ │ ├── stream/
│ │ │ │ │ ├── StreamScreen.kt
│ │ │ │ │ └── StreamViewModel.kt
│ │ │ │ ├── settings/
│ │ │ │ │ ├── EndpointScreen.kt
│ │ │ │ │ ├── VideoAudioSettingsScreen.kt
│ │ │ │ │ ├── GeneralSettingsScreen.kt
│ │ │ │ │ └── SettingsViewModel.kt
│ │ │ │ └── components/
│ │ │ │ ├── CameraPreview.kt // AndroidView wrapper
│ │ │ │ ├── StreamHud.kt
│ │ │ │ └── PermissionHandler.kt
│ │ │ ├── service/
│ │ │ │ ├── StreamingService.kt // Foreground service
│ │ │ │ └── ConnectionManager.kt
│ │ │ ├── camera/
│ │ │ │ ├── DeviceCapabilityQuery.kt // Interface: resolution/FPS enumeration
│ │ │ │ └── Camera2CapabilityQuery.kt // Implementation via CameraManager
│ │ │ ├── audio/
│ │ │ │ └── AudioSourceManager.kt
│ │ │ ├── overlay/
│ │ │ │ ├── OverlayManager.kt // Interface
│ │ │ │ └── NoOpOverlayManager.kt
│ │ │ ├── data/
│ │ │ │ ├── SettingsRepository.kt
│ │ │ │ ├── EndpointProfileRepository.kt
│ │ │ │ └── model/
│ │ │ │ ├── StreamConfig.kt
│ │ │ │ ├── EndpointProfile.kt
│ │ │ │ └── StreamState.kt
│ │ │ └── di/
│ │ │ ├── AppModule.kt
│ │ │ └── StreamModule.kt
│ │ └── res/
│ │ ├── values/
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ └── drawable/
│ └── test/ // Unit tests
│ └── androidTest/ // Instrumented tests
├── gradle/
│ └── libs.versions.toml // Version catalog
└── settings.gradle.kts
[versions]
kotlin = "2.0.x"
agp = "8.7.x"
rootencoder = "2.7.x"
compose-bom = "2025.03.xx"
hilt = "2.51"
datastore = "1.1.x"
security-crypto = "1.1.0-alpha07"
navigation-compose = "2.8.x"
lifecycle = "2.8.x"
coroutines = "1.9.x"
acra = "5.11.x"
[libraries]
rootencoder-rtmp = { module = "com.github.pedroSG94.RootEncoder:rtmp", version.ref = "rootencoder" }
rootencoder-extra = { module = "com.github.pedroSG94.RootEncoder:extra", version.ref = "rootencoder" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
acra-http = { module = "ch.acra:acra-http", version.ref = "acra" }
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }Removed: CameraX dependencies (
camerax-core,camerax-camera2,camerax-lifecycle). Camera is managed exclusively by RootEncoder'sRtmpCamera2. See §6.3.
| Layer | Approach |
|---|---|
| ViewModel | JUnit 5 + Turbine (StateFlow testing). Mock repositories and service binding. |
| Repository | JUnit 5 + Robolectric for DataStore and EncryptedSharedPreferences tests. |
| ConnectionManager | Unit test reconnection logic, backoff timing, jitter, Doze-aware retry capping. |
| DeviceCapabilityQuery | Instrumented tests on real devices (API 23 + latest). |
| Streaming E2E | Manual test matrix: 3 devices (low-end API 23, mid-range API 28, flagship API 35) × (RTMP, RTMPS) × (video+audio, video-only, audio-only). |
| Lifecycle | Instrumented tests for process death with active service, activity recreation, preview rebind, FGS start restrictions. |
| UI | Compose UI tests for navigation, control states, and notification action handling. |
- Debug builds: Auto-signed with debug keystore.
- Release builds: Signed with a release keystore stored outside the repo. Keystore path and passwords provided via
local.propertiesor CI environment variables. - ProGuard / R8: Enabled for release builds. RootEncoder ProGuard rules included.
- App Bundle:
.aabformat for Play Store..apkfor sideloading.
To guarantee F-Droid acceptance, the project uses Gradle product flavors:
// app/build.gradle.kts
android {
flavorDimensions += "distribution"
productFlavors {
create("foss") {
dimension = "distribution"
// Excludes ALL proprietary / GMS dependencies
}
create("gms") {
dimension = "distribution"
// May include Google Play Services in the future if needed
}
}
}| Flavor | Play Store | F-Droid | Direct APK | GMS allowed |
|---|---|---|---|---|
gms |
Yes | No | Yes | Yes |
foss |
Yes | Yes | Yes | No |
Rule: No com.google.android.gms, Firebase, or proprietary library may appear in the foss dependency tree. CI must verify this via a ./gradlew :app:dependencies --configuration fossReleaseRuntimeClasspath | grep -i gms check that fails the build if any match is found.
F-Droid APK size: To keep per-download size within the 15 MB target (NF-05), configure Gradle ABI splits for the foss release variant. A universal APK including all four RootEncoder native ABI variants would exceed 15 MB.
// app/build.gradle.kts
splits {
abi {
isEnable = true
reset()
include("arm64-v8a", "armeabi-v7a")
isUniversalApk = false
}
}F-Droid supports multi-APK submissions. The 15 MB target applies per-ABI APK, not the universal binary.
- Project scaffolding (Gradle, Hilt, Compose, Navigation)
- Camera preview via RootEncoder
RtmpCamera2(back camera default) - Basic RTMP streaming (video + audio) via RootEncoder
- Start / stop controls
- Single RTMP endpoint input (URL + stream key)
- Foreground service for background streaming (with FGS start restriction compliance)
- Runtime permissions handling
- Activity ↔ service binding with
StateFlow<StreamState>
- Video settings screen (resolution, FPS, bitrate, keyframe interval — all filtered by
MediaCodecInfo) - Audio settings screen (bitrate, sample rate, channels)
- Camera switching (front ↔ back)
- Stream mode selection (video+audio / video-only / audio-only)
- Orientation lock (portrait / landscape)
- Encrypted credential storage via EncryptedSharedPreferences
- Save default endpoint; endpoint profiles
- RTMPS (TLS) support with transport security enforcement (§9.2)
- Username/password authentication (with RTMPS-or-warn enforcement)
- Adaptive bitrate with device-capability ABR ladder
- Auto-reconnect with exponential backoff + jitter and Doze awareness
- Connection test button (obeys transport rules)
- Streaming HUD (bitrate, FPS, duration, status, thermal badge)
- Mute toggle
- Low battery handling
- Audio focus / interruption handling (SL-08)
- Thermal throttling response with cooldown (SL-07)
- Local MP4 recording (MediaStore/SAF on API 29+, app-specific storage on 23–28)
- Notification controls (start/stop/mute) with debounce and reconnect cancellation
- ACRA crash reporting with credential redaction
- Process death recovery (preview rebind, state restore)
- OEM battery optimization guidance flow
- Overlay pipeline implementation (text, timestamps, watermarks)
- H.265 streaming option
- Multi-destination streaming
- Stream scheduling
- SRT protocol option
| Risk | Impact | Mitigation |
|---|---|---|
| RootEncoder API breaking changes | Build failure | Pin to specific version; monitor releases. |
| RootEncoder Camera2 quirks on low-end / OEM devices | Black preview, crashes | Use DeviceCapabilityQuery to validate before selecting resolution/fps. Test on diversified device set. File upstream issues. |
| RTMPS certificate validation failures | Cannot connect to some endpoints | Strictly enforce standard CA validation. No custom X509TrustManager. Users needing self-signed certs must install them into the Android system trust store. Document this in a help screen. |
| Background streaming killed by OEM battery optimization | Stream drops | Guide user to disable battery optimization for the app; use REQUEST_IGNORE_BATTERY_OPTIMIZATIONS where appropriate. Show a one-time setup guide for Samsung/Xiaomi/Huawei. |
| Large APK size from RootEncoder native libs | User drop-off | Use App Bundle; split by ABI. Target < 15 MB. |
| Thermal throttling causes frame drops / encoder crash | Stuttering stream, ANR | Monitor thermals via OnThermalStatusChangedListener (API 29+). Progressive degradation with 60 s cooldown. See SL-07. |
| OEM battery optimization kills foreground service | Silent stream death | Do not silently restart. Notify user on next launch. Use REQUEST_IGNORE_BATTERY_OPTIMIZATIONS; display OEM-specific guidance. |
| FGS start restrictions (API 31+) | Cannot start streaming from background | FGS starts only from user-initiated actions while activity is foregrounded or via notification action. Auto-reconnect operates within an already-running FGS only. |
| Encoder does not support requested config | Crash or silent failure on stream start | Pre-flight validate against MediaCodecInfo before connecting. Fail fast with actionable suggestion. |
| F-Droid build rejected due to proprietary dependencies | Cannot distribute on F-Droid | Use Gradle product flavors (foss / gms). CI verifies no GMS in foss dependency tree. See §16.1. |
| Concurrent ABR + thermal encoder restart | FGS crash from MediaCodec.IllegalStateException when both systems trigger an encoder re-init simultaneously |
All quality-change requests serialized through EncoderController with a coroutine Mutex. See §8.2. |
| Stream key exfiltration via Intent extra | Credentials visible via adb shell dumpsys activity service or captured in ACRA crash report Intent bundle |
FGS start Intent carries only a profile ID; credentials fetched internally from EndpointProfileRepository. See §9.1. |
| EncryptedSharedPreferences restore failure | On a new device after backup restore, Keystore key is absent; app throws SecurityException and all credentials are silently lost |
Declare android:allowBackup="false" or configure BackupAgent exclusion rules; prompt user to re-enter credentials. See §9.1. |
| # | Question | Decision |
|---|---|---|
| 1 | App name & package ID | StreamCaster / com.port80.app |
| 2 | Icon / branding | Minimal geometric: camera lens + broadcast signal arcs. Primary: #E53935 (red), Accent: #1E88E5 (blue), Dark surface: #121212. |
| 3 | Distribution | All: Google Play Store (.aab), F-Droid, direct APK (.apk via GitHub Releases). |
| 4 | Monetization | Free. No ads, no in-app purchases. |
| 5 | Crash reporting | ACRA (open-source, Apache 2.0). Reports via HTTP to self-hosted endpoint or email. F-Droid compatible, zero third-party tracking. Credential redaction required. |
| 6 | minSdk | API 23 (raised from 21). Required for EncryptedSharedPreferences, reliable Keystore, and runtime permissions. Drops ~1% of active devices with no viable workaround. |
| 7 | Camera framework | RootEncoder RtmpCamera2 exclusively. CameraX removed to avoid surface contention. Camera1 path removed (unnecessary with minSdk 23). |
| 8 | Transport security default | RTMPS enforced when auth/keys are present. RTMP with credentials requires explicit per-attempt user opt-in. |
| 9 | Orientation support | Landscape first. UX relies on landscape as primary, providing an option for portrait that the user must explicitly toggle. |
| 10 | Session duration limit | Recommendation-based. On low-end devices, app monitors session duration and issues a notification recommending stopping to prevent heat/battery drain, unless connected to power. |
The following criteria are testable conditions that must pass before the corresponding feature is considered complete.
| # | Criterion |
|---|---|
| AC-01 | FGS start succeeds only via user action (in-app button or notification action). Attempting background auto-start without user affordance is blocked and surfaced to the user. |
| AC-02 | Auto-reconnect honors Doze: retries do not exceed one attempt per minute while the device is in idle mode, unless the user has exempted the app from battery optimizations. |
| AC-03 | Switching from 720p30 to 480p15 on THERMAL_STATUS_SEVERE restarts the encoder without crash. Stream resumes within 3 seconds. |
| AC-04 | Credentials are stored encrypted on API ≥ 23 via EncryptedSharedPreferences. No plaintext fallback exists. |
| AC-05 | Notification stop action cancels in-flight reconnect and leaves stream stopped. No zombie notifications remain after the service stops. |
| AC-06 | Connection test with auth over rtmp:// prompts a plaintext warning dialog. Credentials are transmitted only after explicit user confirmation. |
| AC-07 | ACRA crash reports do not contain stream keys, passwords, or auth headers in any ReportField. |
| AC-08 | After process death with the service still running, relaunching the activity rebinds to the service, restores preview, and reflects live stats within 2 seconds. |
| AC-09 | If the OS kills the FGS, the next activity launch shows a session-ended message. No silent restart occurs. |
| AC-10 | Local recording on API 29+ uses MediaStore or SAF. If the user has not granted storage access, recording fails fast with a user prompt; streaming is not blocked. |
| AC-11 | On incoming phone call, the app mutes the microphone and displays a muted indicator. Audio resumes only on explicit user unmute. |
| AC-12 | Camera revocation in background switches to audio-only. Returning to foreground re-acquires camera and resumes video with an IDR frame. |
| AC-13 | The FGS start Intent contains only a non-sensitive profile ID. No stream key or auth credential appears as an Intent extra at any log level or in adb shell dumpsys activity service. |
| AC-14 | The manifest declares android:allowBackup="false" or a BackupAgent that excludes EncryptedSharedPreferences. A simulated restore-then-launch scenario displays a credentials re-entry prompt rather than crashing. |
| AC-15 | ACRA release-build crash report for an active stream contains zero occurrences of a synthetic stream key string across all ReportField entries, including LOGCAT and SHARED_PREFERENCES. |
| AC-16 | Simultaneous ABR step-down and THERMAL_STATUS_SEVERE event do not crash the encoder or FGS. EncoderController serializes both requests and stream resumes within 3 seconds. |
| AC-17 | On API 28, a mocked ACTION_BATTERY_CHANGED broadcast with EXTRA_TEMPERATURE > 38°C triggers a HUD thermal warning. Temperature ≥ 43°C triggers graceful stream stop with reason displayed. |
| AC-18 | Enabling local recording for the first time on API 29+ immediately presents the SAF folder picker. Tapping Start without completing the SAF grant leaves recording blocked and streaming unaffected. |
| AC-19 | In landscape orientation with an active stream, a device rotation gesture does not restart the stream, alter StreamState, or cause a visible flash of portrait orientation. |