diff --git a/README.md b/README.md index 78f7d91..685d702 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,37 @@ # Android Cast (POC) -**Roadmap & alpha scope:** [docs/ROADMAP.md](docs/ROADMAP.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md) +**Roadmap & alpha:** [docs/ROADMAP.md](docs/ROADMAP.md) · **Alpha QA & feature freeze:** [docs/ALPHA.md](docs/ALPHA.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md) Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **TCP or UDP** to a receiver that decodes and plays back. ## Features - UDP broadcast discovery on the local network -- TCP or UDP stream transport (selectable; must match on both devices) +- **UDP (default)** or TCP stream transport (selectable; must match on both devices) - PIN auth (SHA-256 hash, default `1234`) - Screen capture → H.264 (AVC) with quality presets and scaled capture resolution - Playback audio capture (API 29+) → AAC-LC +- Camera capture modes (API 29+, rear/front) — best-effort - Runtime notification permission (Android 13+) -## Quick start +## Quick start (alpha) 1. Install the same APK on two devices on the **same Wi‑Fi**. -2. **Receiver** (TV): **Receive** → match transport (TCP/UDP) & options → tap **Start listening** (notification appears; service keeps running). -3. **Sender** (tablet): **Send** → **same transport** → pick receiver → PIN → approve screen capture. -4. Watch the **notification** on both devices for connection status (not only the toast). -5. Stop from the notification **Stop** action. +2. **Receiver:** **Receive** → transport **UDP** → stream protection **None** → **Start listening** (notification *Listening* / *Ready*). +3. **Sender:** **Send** → **UDP** → protection **None** → discover receiver → PIN → approve screen capture. +4. Watch **notifications** on both devices for status. +5. Stop from either notification **Stop** action. + +Full sign-off checklist: [docs/ALPHA.md](docs/ALPHA.md). ### If video never appears -- Transport must match on both sides (e.g. both TCP). -- Receiver must show *Listening* or *Ready* in the notification before you cast. -- Sender notification should reach *Casting via TCP · WxH* — if it shows *Screen capture data missing*, reinstall and try again (Android 13+ intent fix). -- Keep the receiver UI open once so the video `Surface` attaches (or return to it after rotation). +- Transport must match on both sides (e.g. both UDP). +- Stream protection **None** on both sides for alpha. +- Receiver must show *Listening* or *Ready* before you cast. +- Sender notification should reach *Casting* with resolution — if it shows *Screen capture data missing*, reinstall (Android 13+ intent fix). +- Open receiver playback once so the video `Surface` attaches (or return after rotation). +- On rotation-sensitive senders, try **system rotation lock** during cast. ## Build @@ -39,22 +44,29 @@ cd "/home/foxx/repos/My Projects/android cast" adb install -r app/build/outputs/apk/debug/app-debug.apk ``` -## Stream options +## Stream options (defaults) -| Option | Effect | -|--------|--------| -| **Transport TCP** | Reliable delivery; default | -| **Transport UDP** | Lower latency, may drop frames (POC) | -| **Quality Low/Med/High** | Video bitrate 0.8 / 2 / 4 Mbps and FPS 20 / 24 / 30 | -| **Resolution** | Scales VirtualDisplay before encode (full, 75%, 50%, or max 720p height) | -| **Audio** | `AudioPlaybackCapture` + AAC (Android 10+) | +| Option | Default / alpha note | +|--------|----------------------| +| **Transport** | **UDP** (`CastConfig.DEFAULT_TRANSPORT`) | +| **Stream protection** | **None** | +| **Quality** | Medium — 5 Mbps, 30 fps | +| **Resolution** | Adaptive (sender) | +| **Multi-receiver** | **Off** while `CastConfig.ALPHA_FEATURE_FREEZE` | +| **Audio** | On when API 29+ (`AudioPlaybackCapture` + AAC) | + +| Quality | Video bitrate | FPS | +|---------|---------------|-----| +| Low | 1.5 Mbps | 24 | +| Medium | 5 Mbps | 30 | +| High | 10 Mbps | 30 | ## Protocol | Step | Description | |------|-------------| | Discovery | UDP `41234`, JSON with `magic`, `name`, `port`, `transport` | -| Connect | TCP or UDP port `41235` | +| Connect | TCP or UDP port `41235` (QUIC `41236` when enabled post-alpha) | | Auth | `HELLO` → `AUTH` → `AUTH_OK` → `CAST_SETTINGS` | | Stream | `STREAM_CONFIG`, `VIDEO_FRAME`, `AUDIO_CONFIG`, `AUDIO_FRAME` | @@ -62,12 +74,7 @@ UDP frames use `ACUD` header and fragmentation for payloads > ~1.2 KB. ## Bluetooth / BLE -**Not viable for this video stream.** Practical throughput: - -- **BLE**: ~100–300 kb/s — fine for control/metadata only -- **Bluetooth Classic (SPP)**: often ~1–3 Mb/s theoretical — still far below 720p H.264 (~2–8 Mb/s), with high latency - -A phone cannot “passthrough” a live encoded screen stream over BLE to another device for playback without a custom high-bandwidth profile (which consumer Android does not expose for arbitrary apps). Use **Wi‑Fi** for media; BT could be added later only for discovery/pairing if needed. +**Not viable for this video stream.** Use **Wi‑Fi** for media; BLE could be added later only for discovery/pairing. ## Persistent services (AIDL) @@ -78,18 +85,16 @@ Casting runs in **foreground services** with tray notifications: | `ScreenCastService` | Sender — capture, encode, stream | | `ReceiverCastService` | Receiver — listen, decode, play | -Activities bind via AIDL (`ICastSenderService` / `ICastReceiverService`) for status updates and (on the receiver) attaching the video `Surface`. Rotating the screen or leaving the app does **not** stop the receiver listener; the sender keeps casting until you tap **Stop** in the notification. +Activities bind via AIDL for status and (on the receiver) attaching the video `Surface`. Leaving the app does **not** stop the receiver listener; the sender keeps casting until **Stop** in the notification. ## Limitations - No TLS encryption (LAN cleartext POC) -- UDP mode may glitch under load -- Audio captures **playback** routed through the system (not microphone); some apps may be silent by policy -- Single sender → single receiver +- UDP mode may glitch under load (try TCP) +- Audio captures **playback** routed through the system (not microphone) +- Single receiver per session during alpha freeze +- No live sender preview while casting (stability) ## Development (git flow) -We use a **green `master`** plus integration branch **`next`**: features merge to `next` first; releases merge `next` → `master` and are tagged there; production hotfixes branch from `master` and sync back into `next`. - -- Narrative: [docs/GIT_FLOW.md](docs/GIT_FLOW.md) -- PDF: [docs/GIT_FLOW.pdf](docs/GIT_FLOW.pdf) — `pip install -r docs/_pdf_build/requirements-pdf.txt`, then `python3 docs/_pdf_build/build_git_flow_pdf.py` +Green **`master`** plus integration branch **`next`**. See [docs/GIT_FLOW.md](docs/GIT_FLOW.md). diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index 2142e21..ceed0bf 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -264,6 +264,7 @@ public final class AppPreferences { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); } + clampAlphaTransport(s); return s; } @@ -401,6 +402,7 @@ public final class AppPreferences { if (s.getNetworkAdoption() == CastSettings.NetworkAdoption.AUTO) { s.setNetworkAdoption(CastSettings.NetworkAdoption.ESTIMATED); } + clampAlphaTransport(s); return s; } @@ -472,6 +474,15 @@ public final class AppPreferences { return fallback; } + private static void clampAlphaTransport(CastSettings settings) { + if (settings == null || !CastConfig.ALPHA_FEATURE_FREEZE) { + return; + } + if (!CastConfig.isReleaseTransport(settings.getTransport())) { + settings.setTransport(CastConfig.DEFAULT_TRANSPORT); + } + } + private static SharedPreferences prefs(Context context) { if (context == null) { throw new IllegalStateException("context is null"); diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 7f34fda..ca83715 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -29,8 +29,19 @@ public final class CastConfig { public static final int CAST_PORT = 41235; public static final String DISCOVERY_MAGIC = "ACAST1"; + /** + * Alpha / feature-freeze gate: limits transports in settings and disables 1:N cast until post-alpha soak. + * Set to {@code false} on a release branch when re-enabling QUIC and multi-receiver experiments. + */ + public static final boolean ALPHA_FEATURE_FREEZE = true; + /** When true, sender list allows selecting multiple receivers (1:N cast). */ - public static final boolean MULTI_RECEIVER_ENABLED = true; + public static final boolean MULTI_RECEIVER_ENABLED = !ALPHA_FEATURE_FREEZE; + + /** Transports exposed in cast settings while {@link #ALPHA_FEATURE_FREEZE} is on. */ + public static boolean isReleaseTransport(String transport) { + return TRANSPORT_UDP.equals(transport) || TRANSPORT_TCP.equals(transport); + } public static final int MAX_RECEIVERS = 5; public static final String DEFAULT_PIN = "1234"; diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index 1d473ab..dcb8473 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -161,8 +161,10 @@ public final class CastSettingsBinder { valueList.add(CastConfig.TRANSPORT_UDP); labelList.add(activity.getString(R.string.transport_tcp)); valueList.add(CastConfig.TRANSPORT_TCP); - labelList.add(activity.getString(R.string.transport_quic)); - valueList.add(CastConfig.TRANSPORT_QUIC); + if (!CastConfig.ALPHA_FEATURE_FREEZE) { + labelList.add(activity.getString(R.string.transport_quic)); + valueList.add(CastConfig.TRANSPORT_QUIC); + } if (AppPreferences.isDevUsbTetherTransportEnabled(activity)) { labelList.add(activity.getString(R.string.transport_usb_tether)); valueList.add(CastConfig.TRANSPORT_USB_TETHER); diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index fd1ea3d..abee8ea 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -120,6 +120,7 @@ public class ReceiverCastService extends Service { private boolean streamIdle; private volatile boolean castEnded; private long lastVideoFrameMs; + private long lastStreamConfigMs; private final TransportLossStats transportLoss = new TransportLossStats(); private SessionStatsRecorder sessionStatsRecorder; @@ -300,6 +301,8 @@ public class ReceiverCastService extends Service { @Override public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) { + final long nowMs = System.currentTimeMillis(); + mainHandler.post(() -> lastVideoFrameMs = nowMs); if (awaitingKeyframe && !keyFrame) { synchronized (transportLoss) { transportLoss.recvVideoSkippedAwaitingKey++; @@ -313,7 +316,6 @@ public class ReceiverCastService extends Service { final int frameBytes = data != null ? data.length : 0; final byte[] frameData = data; mainHandler.post(() -> { - lastVideoFrameMs = System.currentTimeMillis(); if (streamIdle) { onStreamResumedAfterIdle(); } @@ -484,11 +486,16 @@ public class ReceiverCastService extends Service { } private void checkStreamIdle() { - if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0 - || awaitingKeyframe || reconfiguring) { + if (phase != Phase.STREAMING || streamIdle || lastVideoFrameMs <= 0) { return; } - long idleMs = System.currentTimeMillis() - lastVideoFrameMs; + long nowMs = System.currentTimeMillis(); + if (reconfiguring || awaitingKeyframe) { + if (lastStreamConfigMs > 0 && nowMs - lastStreamConfigMs < 20_000L) { + return; + } + } + long idleMs = nowMs - lastVideoFrameMs; if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) { Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting"); if (sessionStatsRecorder != null) { @@ -594,6 +601,8 @@ public class ReceiverCastService extends Service { return; } boolean firstConfig = !hasPendingConfig; + boolean wasIdle = streamIdle; + boolean decoderWasActive = decoderActive; phase = Phase.STREAMING; streamIdle = false; if (firstConfig) { @@ -602,7 +611,10 @@ public class ReceiverCastService extends Service { reconfiguring = true; enterAwaitingKeyframe(); lastVideoFrameMs = System.currentTimeMillis(); - openPlaybackAwaiting(R.string.playback_awaiting_stream); + lastStreamConfigMs = lastVideoFrameMs; + if (firstConfig || wasIdle || !decoderWasActive) { + openPlaybackAwaiting(R.string.playback_awaiting_stream); + } pendingWidth = size.width; pendingHeight = size.height; displayWidth = size.width; diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 2500186..e699304 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -78,6 +78,7 @@ public class ScreenCastService extends Service implements private static final String TAG = "ScreenCastService"; private static final int CONNECT_RETRIES = 20; private static final long CONNECT_RETRY_MS = 1000; + private static final long CAPTURE_SIZE_UPDATE_DEBOUNCE_MS = 2_000L; public static final String EXTRA_RESULT_CODE = "result_code"; public static final String EXTRA_RESULT_DATA = "result_data"; @@ -126,6 +127,9 @@ public class ScreenCastService extends Service implements private int captureDensity; private DisplayManager displayManager; private DisplayManager.DisplayListener displayListener; + private Runnable pendingCaptureSizeUpdate; + private CastResolution.Size pendingCaptureSize; + private final AtomicBoolean captureReconfigureInFlight = new AtomicBoolean(false); private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver; private volatile boolean calibrationMode; private volatile boolean cameraMode; @@ -188,6 +192,7 @@ public class ScreenCastService extends Service implements return START_STICKY; } CastSettings startSettings = IntentExtras.getCastSettings(intent); + CastActiveState.setSenderCasting(true); startSenderForeground(getString(R.string.connecting), startSettings != null && startSettings.isAudioEnabled()); ioThread = new Thread(() -> runCast(intent), "ScreenCastIO"); @@ -609,17 +614,74 @@ public class ScreenCastService extends Service implements WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); CastResolution.Size size = CastResolution.compute(metrics, activeSettings, getPeerStats()); + scheduleCaptureSizeUpdate(size, "rotation"); + } + + private boolean captureSizeChangeSignificant(CastResolution.Size size) { if (size.width == captureWidth && size.height == captureHeight) { + return false; + } + int dw = Math.abs(size.width - captureWidth); + int dh = Math.abs(size.height - captureHeight); + return dw >= Math.max(32, captureWidth / 10) || dh >= Math.max(32, captureHeight / 10); + } + + private void scheduleCaptureSizeUpdate(CastResolution.Size size, String reason) { + if (calibrationMode || cameraMode || !casting.get() || projection == null + || activeSettings == null || stopping.get()) { + return; + } + if (!captureSizeChangeSignificant(size)) { + return; + } + pendingCaptureSize = size; + Handler handler = projectionHandler != null ? projectionHandler : mainHandler; + if (pendingCaptureSizeUpdate != null) { + handler.removeCallbacks(pendingCaptureSizeUpdate); + } + pendingCaptureSizeUpdate = () -> applyCaptureSizeUpdate(reason); + handler.postDelayed(pendingCaptureSizeUpdate, CAPTURE_SIZE_UPDATE_DEBOUNCE_MS); + } + + private void applyCaptureSizeUpdate(String reason) { + pendingCaptureSizeUpdate = null; + if (calibrationMode || cameraMode || !casting.get() || projection == null + || activeSettings == null || stopping.get() || pendingCaptureSize == null) { + return; + } + CastResolution.Size size = pendingCaptureSize; + pendingCaptureSize = null; + if (!captureSizeChangeSignificant(size)) { + return; + } + if (!captureReconfigureInFlight.compareAndSet(false, true)) { + Log.d(TAG, "Capture size update skipped (" + reason + "): reconfigure in flight"); + scheduleCaptureSizeUpdate(size, reason); return; } try { setupVideoPipeline(size, activeSettings, false); - updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); + if ("rotation".equals(reason)) { + updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); + } + Log.i(TAG, "Capture reconfigured (" + reason + ") " + captureWidth + "x" + captureHeight); } catch (IOException e) { - Log.e(TAG, "Rotation reconfigure failed", e); + Log.e(TAG, "Capture reconfigure failed (" + reason + ")", e); + } finally { + captureReconfigureInFlight.set(false); } } + private void cancelPendingCaptureSizeUpdate() { + pendingCaptureSize = null; + if (pendingCaptureSizeUpdate == null) { + return; + } + Handler handler = projectionHandler != null ? projectionHandler : mainHandler; + handler.removeCallbacks(pendingCaptureSizeUpdate); + pendingCaptureSizeUpdate = null; + } + private NetworkStatsSnapshot getPeerStats() { if (networkFeedback != null && networkFeedback.getPlane() instanceof PassThroughNetworkControlPlane) { return ((PassThroughNetworkControlPlane) networkFeedback.getPlane()).getLastPeerStats(); @@ -709,19 +771,7 @@ public class ScreenCastService extends Service implements WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); CastResolution.Size size = CastResolution.compute(metrics, activeSettings, peer); - int dw = Math.abs(size.width - captureWidth); - int dh = Math.abs(size.height - captureHeight); - if (dw < Math.max(32, captureWidth / 10) && dh < Math.max(32, captureHeight / 10)) { - return; - } - runOnProjectionThread(() -> { - try { - setupVideoPipeline(size, activeSettings, false); - Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight); - } catch (IOException e) { - Log.w(TAG, "Adaptive resize failed", e); - } - }); + scheduleCaptureSizeUpdate(size, "adaptive"); } private void runOnProjectionThread(Runnable action) { @@ -1131,6 +1181,8 @@ public class ScreenCastService extends Service implements } private void releaseProjectionCapture() { + cancelPendingCaptureSizeUpdate(); + captureReconfigureInFlight.set(false); unregisterDisplayListener(); if (virtualDisplay != null) { virtualDisplay.release(); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index 50a11f6..c41d554 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -462,7 +462,7 @@ public class SenderActivity extends DrawerHostActivity { if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) { launchCastService(null, Activity.RESULT_OK); } else { - Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show(); + Toast.makeText(this, R.string.capture_camera_permission_denied, Toast.LENGTH_LONG).show(); } } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java index 75fb7bb..0b53fe1 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderCapturePreview.java @@ -169,7 +169,7 @@ public final class SenderCapturePreview { Context ctx = textureView.getContext(); if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - showLabel(R.string.capture_camera_not_implemented); + showLabel(R.string.capture_camera_permission_denied); return; } labelView.setVisibility(android.view.View.GONE); diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java index 7fabe08..50f33f4 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderScreenPreview.java @@ -78,7 +78,7 @@ public final class SenderScreenPreview { } public void attach(TextureView textureView, CastSettings.CaptureMode mode) { - if (!needsProjection(mode) || !hasConsent()) { + if (CastActiveState.isSenderCasting() || !needsProjection(mode) || !hasConsent()) { return; } boundTexture = textureView; @@ -137,7 +137,7 @@ public final class SenderScreenPreview { } private void ensureVirtualDisplay(TextureView textureView, CastSettings.CaptureMode mode) { - if (!needsProjection(mode) || !hasConsent()) { + if (CastActiveState.isSenderCasting() || !needsProjection(mode) || !hasConsent()) { return; } try { @@ -212,6 +212,14 @@ public final class SenderScreenPreview { boundTexture = null; boundMode = null; pausePreview(); + if (projection != null) { + try { + projection.stop(); + } catch (Exception ignored) { + } + projection = null; + } + SenderPreviewService.stop(appContext); } /** Release consent and active capture (leaving sender screen). */ diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 211d304..042d0b2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -33,7 +33,8 @@ Тест сигнала Камера (лицевая) Источник для камеры (Android 10+) - Захват с камеры в стадии разработки. Выберите другой метод. + Камера недоступна на этом устройстве. Выберите экран или калибровку. + Нет доступа к камере. Разрешите в настройках системы. Передача калибрации %1$dx%2$d Трансляция с камеры %1$dx%2$d Низкий (~1.5 Mbps, 24 fps) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8fb8613..33a889c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,7 +33,8 @@ Calibration test pattern Camera (rear Camera2) Camera source (requires Android 10+) - Camera capture is not implemented yet. Use screen or calibration test. + Camera unavailable on this device. Use screen or calibration test. + Camera permission denied. Enable it in system settings to use camera cast. Calibration cast %1$dx%2$d Camera cast %1$dx%2$d Low (~1.5 Mbps, 24 fps) diff --git a/docs/ALPHA.md b/docs/ALPHA.md new file mode 100644 index 0000000..9cd2d11 --- /dev/null +++ b/docs/ALPHA.md @@ -0,0 +1,156 @@ +# Android Cast — alpha release & QA + +Last updated: 2026-05-23. Use this document for **feature freeze**, **alpha builds**, and **sign-off** before widening testing. + +**Related:** [ROADMAP.md](ROADMAP.md) · [README.md](../README.md) + +--- + +## Alpha scope (what we ship) + +| In scope | Out of scope (frozen) | +|----------|------------------------| +| LAN discover → PIN → cast → stop | Internet relay, TLS on stream | +| **UDP** or **TCP** transport (must match on both devices) | QUIC, WebRTC, USB-tether transport in UI | +| H.264 + AAC (Android 10+ audio) | Passthrough video, Opus/Speex as default | +| **Single receiver** per cast session | 1:N multi-receiver (disabled in `CastConfig`) | +| Stream protection **None** (default) | FEC/NACK soak required for alpha sign-off | +| Screen capture (full / user-choice on API 34+) | Secondary-display capture, Miracast in-app | +| Camera cast (API 29+, best-effort) | Live sender mirror while casting | +| Receiver playback + notification stop | Play Store / AdMob production | + +Code gate: `CastConfig.ALPHA_FEATURE_FREEZE = true` hides experimental transports and turns off multi-receiver selection. + +--- + +## Recommended defaults (both devices) + +Set in **Settings** (or use fresh install defaults): + +| Setting | Sender | Receiver | +|---------|--------|----------| +| Transport | **UDP** | **UDP** | +| Stream protection | **None** | **None** | +| Quality | Medium | Medium | +| Resolution | Adaptive (or HD 720p if rotation is shaky) | — | +| Video codec | Auto | Auto | +| Audio | On (if API 29+) | Play incoming audio: On | +| Capture mode | Full screen (or User choice on Android 14+) | — | +| PIN | Same on both (default `1234`) | Same | + +**Rotation soak:** On Mediatek / Freeme senders (e.g. BL6000), prefer **system rotation lock ON** during first alpha pass; then repeat with lock **OFF** to validate debounced reconfigure. + +--- + +## Feature freeze checklist (engineering) + +| Item | Status | +|------|--------| +| `ALPHA_FEATURE_FREEZE` — UDP/TCP only in settings | Done | +| Multi-receiver disabled for alpha | Done | +| Stored QUIC/WebRTC transport clamped to UDP on load | Done | +| Camera strings (permission vs unavailable) | Done | +| README matches `CastConfig` / `CastSettings` defaults | Done | +| Receiver: keyframe idle grace + debounced sender resize | Done (verify on soak) | +| Preview paused during cast (single MediaProjection) | Done | +| Developer-only: FEC, QUIC, USB-tether, immersive presets | Gated (dev settings) | + +--- + +## Alpha app sign-off (manual QA) + +Run on **two physical devices**, same Wi‑Fi subnet. Record build: About / `versionName` from APK, git commit, date. + +### A. Smoke (required — all must pass) + +| # | Step | Pass | +|---|------|------| +| A1 | Install same debug/release APK on sender + receiver | ☐ | +| A2 | Receiver: **Receive** → Start listening → notification *Listening* / *Ready* | ☐ | +| A3 | Sender: **Send** → discover receiver → correct PIN → approve capture | ☐ | +| A4 | Video appears on receiver within **15 s** of auth | ☐ | +| A5 | Audio audible (if enabled and API 29+) within **20 s** | ☐ | +| A6 | Sender notification shows *Casting* with resolution | ☐ | +| A7 | **Stop** from sender notification → stream ends, receiver returns to idle | ☐ | +| A8 | **Stop** from receiver notification → sender stops cleanly | ☐ | +| A9 | Second cast without reinstall (A2→A7 again) | ☐ | + +### B. Resilience (required) + +| # | Step | Pass | +|---|------|------| +| B1 | Sender home → cast 5 min, no reboot / ANR | ☐ | +| B2 | Receiver app to background 2 min → return → video recovers or clean idle | ☐ | +| B3 | Receiver rotate once → video continues or recovers within 10 s | ☐ | +| B4 | Sender rotate **with rotation lock ON** → 5 min stable | ☐ | +| B5 | Sender rotate **with lock OFF** (optional / device-specific) → no permanent freeze; note if reboot | ☐ | + +### C. Settings matrix (pick one row for official alpha demo) + +| Profile | Transport | Protection | Use | +|---------|-----------|------------|-----| +| **Alpha default** | UDP | None | Primary sign-off | +| Fallback | TCP | None | If UDP glitches on your AP | + +| # | Step | Pass | +|---|------|------| +| C1 | UDP + None: 10 min soak | ☐ | +| C2 | (Optional) TCP + None: 10 min soak | ☐ | + +### D. Regression guards (quick) + +| # | Step | Pass | +|---|------|------| +| D1 | Wrong PIN → clear error, no crash | ☐ | +| D2 | Mismatched transport (sender TCP / receiver UDP) → fails fast with message | ☐ | +| D3 | Camera mode (if used): permission grant → cast 2 min | ☐ | +| D4 | Calibration test mode → pattern on receiver | ☐ | + +### E. Known limitations (not alpha blockers) + +- No TLS; LAN cleartext. +- UDP may drop frames under congestion. +- No live preview on sender while casting. +- Some apps silent under playback capture policy. +- Heavy rotation on some OEMs may still stress system WFD/WM (watchdog) — document device + lock state. + +**Alpha approved when:** all **A**, **B1–B4**, and **C1** pass on your reference sender + receiver pair. + +--- + +## Log capture (when something fails) + +From repo root: + +```bash +./scripts/alpha-qa-logcat.sh SENDER_SERIAL RECEIVER_SERIAL +``` + +Or manually: + +```bash +adb -s SENDER logcat -s ScreenCastService:* ReceiverCastService:* AndroidCast:* SenderScreenPreview:* +adb -s RECEIVER logcat -s ReceiverCastService:* AndroidCast:* +``` + +--- + +## Alpha backend (optional for LAN-only alpha) + +LAN demo does **not** require OTA/crash backend. For **full alpha** with updates and crash upload: + +| Item | Notes | +|------|--------| +| Host `examples/ota/v0/` | See [OTA.md](OTA.md) | +| Deploy crash reporter | See [CRASH_REPORTER.md](CRASH_REPORTER.md) | +| Configure URLs | Developer settings or `local.properties` | + +--- + +## Ending feature freeze + +1. Complete sign-off table above; note devices + APK build id in git tag message. +2. Tag release on `master` per [GIT_FLOW.md](GIT_FLOW.md). +3. To re-enable experiments on a dev branch: set `CastConfig.ALPHA_FEATURE_FREEZE = false` and restore `MULTI_RECEIVER_ENABLED` as needed. + +Post-alpha priorities: rotation-stable encode path, encoder thumbnail on sender, secondary-display capture (roadmap D). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c12a216..67116a8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Android Cast — implementation roadmap -Last updated: 2026-05-21. Living document — refine as alpha and commercial tracks progress. +Last updated: 2026-05-23. Living document — refine as alpha and commercial tracks progress. --- @@ -15,11 +15,17 @@ Last updated: 2026-05-21. Living document — refine as alpha and commercial tra ### Alpha app checklist -- [ ] Happy path: UDP + H.264/AAC + single receiver, stream protection **None** -- [ ] README matches `CastSettings` / `CastConfig` defaults -- [ ] Fix misleading strings (e.g. camera “not implemented”) -- [ ] Two-device QA script (rotate, background, notification stop) -- [ ] Optional: cap multi-receiver at 1 for show, or soak 1:N +**Runbook:** [ALPHA.md](ALPHA.md) (feature freeze, defaults, sign-off tables, log script). + +| Checkpoint | Engineering | Manual sign-off | +|------------|---------------|-----------------| +| Happy path: UDP + H.264/AAC + single receiver, protection **None** | Defaults + freeze in code | ☐ [ALPHA.md](ALPHA.md) § A, C1 | +| README matches `CastSettings` / `CastConfig` | Done | — | +| Fix misleading strings (camera permission vs unavailable) | Done | — | +| Two-device QA (rotate, background, notification stop) | Script in `scripts/alpha-qa-logcat.sh` | ☐ [ALPHA.md](ALPHA.md) § B | +| Cap multi-receiver at 1 for alpha | `MULTI_RECEIVER_ENABLED` off while freeze on | — | +| Hide QUIC/WebRTC/USB in settings for alpha | `ALPHA_FEATURE_FREEZE` | — | +| Receiver stuck on rotation / STREAM_CONFIG storm | Debounce + idle grace in code | ☐ B4–B5 | ### Alpha backend checklist @@ -133,7 +139,7 @@ See [ndk/README.md](../ndk/README.md). |------|--------| | `ReceiverPlaybackActivity` (primary receiver UI) | Done | | Settings drawer + themes + RU locale | Done | -| Multi-receiver 1:N | Done (soak or gate for alpha) | +| Multi-receiver 1:N | Done (gated off while `ALPHA_FEATURE_FREEZE`) | | Legacy `ReceiverActivity` in manifest | Orphan — remove or wire | | Immersive A/V presets (dev) | Experimental | | Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) | @@ -156,6 +162,7 @@ See [ndk/README.md](../ndk/README.md). - [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf)) - [README.md](../README.md) — quick start +- [ALPHA.md](ALPHA.md) — alpha freeze & QA sign-off - [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E - [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play - [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements diff --git a/scripts/alpha-qa-logcat.sh b/scripts/alpha-qa-logcat.sh new file mode 100755 index 0000000..ca86cbf --- /dev/null +++ b/scripts/alpha-qa-logcat.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Capture paired sender/receiver logcat for alpha QA (run from repo root). +set -euo pipefail + +SENDER="${1:-}" +RECEIVER="${2:-}" +STAMP="$(date +%Y%m%d-%H%M%S)" +OUT_DIR="$(dirname "$0")/../alpha-logs-${STAMP}" +mkdir -p "$OUT_DIR" + +TAGS='ScreenCastService:* ReceiverCastService:* AndroidCast:* SenderScreenPreview:* CastProtocol:*' + +adb_cmd() { + local serial="$1" + shift + if [[ -n "$serial" ]]; then + adb -s "$serial" "$@" + else + adb "$@" + fi +} + +echo "Writing logs to $OUT_DIR" +echo "Press Ctrl+C to stop." + +if [[ -n "$SENDER" ]]; then + adb_cmd "$SENDER" logcat -c +fi +if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then + adb_cmd "$RECEIVER" logcat -c +fi + +trap 'echo; echo "Stopped."' INT + +if [[ -n "$SENDER" ]]; then + adb_cmd "$SENDER" logcat $TAGS | tee "$OUT_DIR/sender.log" & + PID_S=$! +fi +if [[ -n "$RECEIVER" && "$RECEIVER" != "$SENDER" ]]; then + adb_cmd "$RECEIVER" logcat $TAGS | tee "$OUT_DIR/receiver.log" & + PID_R=$! +elif [[ -z "$SENDER" ]]; then + adb logcat $TAGS | tee "$OUT_DIR/combined.log" + exit 0 +fi + +wait ${PID_S:-} ${PID_R:-}