mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:39:15 +03:00
AI: polish mobile parts
This commit is contained in:
27
README.md
27
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Android Cast (POC)
|
# 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 scope:** [docs/ROADMAP.md](docs/ROADMAP.md) · **LAN demo QA:** [docs/ALPHA_SOAK.md](docs/ALPHA_SOAK.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.
|
Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playback audio, encodes **H.264 + AAC**, streams over **UDP or TCP** to a receiver that decodes and plays back.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -13,13 +13,16 @@ Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playb
|
|||||||
- Playback audio capture (API 29+) → AAC-LC
|
- Playback audio capture (API 29+) → AAC-LC
|
||||||
- Runtime notification permission (Android 13+)
|
- Runtime notification permission (Android 13+)
|
||||||
|
|
||||||
## Quick start
|
## Quick start (alpha demo)
|
||||||
|
|
||||||
1. Install the same APK on two devices on the **same Wi‑Fi**.
|
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).
|
2. **Receiver:** **Receive** → transport **UDP** → stream protection **None** → **Start listening**.
|
||||||
3. **Sender** (tablet): **Send** → **same transport** → pick receiver → PIN → approve screen capture.
|
3. **Sender:** **Send** → **UDP** → pick **one** receiver → PIN → approve screen capture.
|
||||||
4. Watch the **notification** on both devices for connection status (not only the toast).
|
4. Sender resolution: **Original**, **720p**, **480p**, or **Auto** (settings drawer).
|
||||||
5. Stop from the notification **Stop** action.
|
5. Watch **notifications** on both devices for status (not only toasts).
|
||||||
|
6. Stop from the sender notification **Stop** action.
|
||||||
|
|
||||||
|
Full soak script: [docs/ALPHA_SOAK.md](docs/ALPHA_SOAK.md).
|
||||||
|
|
||||||
### If video never appears
|
### If video never appears
|
||||||
|
|
||||||
@@ -43,11 +46,13 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Transport TCP** | Reliable delivery; default |
|
| **Transport UDP** | Default; lower latency |
|
||||||
| **Transport UDP** | Lower latency, may drop frames (POC) |
|
| **Transport TCP** | Reliable fallback |
|
||||||
| **Quality Low/Med/High** | Video bitrate 0.8 / 2 / 4 Mbps and FPS 20 / 24 / 30 |
|
| **Sender resolution** | **Original** (native), **720p** / **480p** (long-edge cap), **Auto** (adaptive from peer stats) |
|
||||||
| **Resolution** | Scales VirtualDisplay before encode (full, 75%, 50%, or max 720p height) |
|
| **Bitrate** | Estimated / quality / speed modes in settings |
|
||||||
|
| **Stream protection** | FEC/NACK optional on UDP — use **None** for alpha demo |
|
||||||
| **Audio** | `AudioPlaybackCapture` + AAC (Android 10+) |
|
| **Audio** | `AudioPlaybackCapture` + AAC (Android 10+) |
|
||||||
|
| **Camera capture** | Rear/front Camera2 (Android 10+); requires camera permission |
|
||||||
|
|
||||||
## Protocol
|
## Protocol
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,6 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".receiver.ReceiverActivity"
|
|
||||||
android:exported="false"
|
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".CastSettingsActivity"
|
android:name=".CastSettingsActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -423,8 +423,12 @@ public final class AppPreferences {
|
|||||||
SharedPreferences p = prefs(context);
|
SharedPreferences p = prefs(context);
|
||||||
String transport = p.getString(prefix + "transport", CastConfig.DEFAULT_TRANSPORT);
|
String transport = p.getString(prefix + "transport", CastConfig.DEFAULT_TRANSPORT);
|
||||||
s.setTransport(transport);
|
s.setTransport(transport);
|
||||||
s.setResolution(readEnum(p, prefix + "resolution", CastSettings.Resolution.values(),
|
CastSettings.Resolution resolution = readEnum(p, prefix + "resolution",
|
||||||
CastSettings.Resolution.ADAPTIVE));
|
CastSettings.Resolution.values(), CastSettings.Resolution.ADAPTIVE);
|
||||||
|
if (PREFIX_SENDER.equals(prefix)) {
|
||||||
|
resolution = CastSettings.forPresetUi(resolution);
|
||||||
|
}
|
||||||
|
s.setResolution(resolution);
|
||||||
s.setVideoCodec(readEnum(p, prefix + "video_codec", CastSettings.VideoCodec.values(),
|
s.setVideoCodec(readEnum(p, prefix + "video_codec", CastSettings.VideoCodec.values(),
|
||||||
CastSettings.VideoCodec.AUTO));
|
CastSettings.VideoCodec.AUTO));
|
||||||
s.setBitrateMode(readEnum(p, prefix + "bitrate_mode", CastSettings.BitrateMode.values(),
|
s.setBitrateMode(readEnum(p, prefix + "bitrate_mode", CastSettings.BitrateMode.values(),
|
||||||
|
|||||||
@@ -117,6 +117,27 @@ public class CastSettings implements Serializable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps legacy stored resolutions to the alpha sender presets
|
||||||
|
* ({@link #FULL} / {@link #HD_720P} / {@link #SD_480P} / {@link #ADAPTIVE}).
|
||||||
|
*/
|
||||||
|
public static Resolution forPresetUi(Resolution stored) {
|
||||||
|
if (stored == null) {
|
||||||
|
return Resolution.ADAPTIVE;
|
||||||
|
}
|
||||||
|
switch (stored) {
|
||||||
|
case FIXED_720P:
|
||||||
|
return Resolution.HD_720P;
|
||||||
|
case FIXED_1080P:
|
||||||
|
return Resolution.FULL;
|
||||||
|
case THREE_QUARTERS:
|
||||||
|
case HALF:
|
||||||
|
return Resolution.ADAPTIVE;
|
||||||
|
default:
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String transport = CastConfig.DEFAULT_TRANSPORT;
|
private String transport = CastConfig.DEFAULT_TRANSPORT;
|
||||||
private Quality quality = Quality.MEDIUM;
|
private Quality quality = Quality.MEDIUM;
|
||||||
private Resolution resolution = Resolution.ADAPTIVE;
|
private Resolution resolution = Resolution.ADAPTIVE;
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
package com.foxx.androidcast.receiver;
|
|
||||||
|
|
||||||
/*********************************************************************
|
|
||||||
* ReceiverActivity.java
|
|
||||||
* Created at: Fri 15 May 2026 16:49:02 +0200
|
|
||||||
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
|
||||||
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
|
||||||
* class ReceiverActivity
|
|
||||||
* Contributors:
|
|
||||||
* - Anton Afanasyeu <a.afanasieff@gmail.com> (6 commits, 163 lines)
|
|
||||||
* - Cursor Agent (project assistant)
|
|
||||||
* Digest: SHA256 5ca140ebc07f2ed3353cce1db093f850230b3ed1657466551f96c7c1b8349f88
|
|
||||||
**********************************************************************/
|
|
||||||
import android.content.ComponentName;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.ServiceConnection;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.IBinder;
|
|
||||||
import android.os.RemoteException;
|
|
||||||
import android.view.WindowManager;
|
|
||||||
import android.widget.Button;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
|
|
||||||
import com.foxx.androidcast.AppPreferences;
|
|
||||||
import com.foxx.androidcast.CastActivityTracker;
|
|
||||||
import com.foxx.androidcast.CastSettings;
|
|
||||||
import com.foxx.androidcast.ICastReceiverService;
|
|
||||||
import com.foxx.androidcast.ICastStatusCallback;
|
|
||||||
import com.foxx.androidcast.PermissionHelper;
|
|
||||||
import com.foxx.androidcast.R;
|
|
||||||
|
|
||||||
/** Receiver setup — options live in {@link com.foxx.androidcast.CastSettingsActivity}. */
|
|
||||||
public class ReceiverActivity extends AppCompatActivity {
|
|
||||||
private final CastSettings castSettings = new CastSettings();
|
|
||||||
|
|
||||||
private TextView statusText;
|
|
||||||
private ICastReceiverService receiverService;
|
|
||||||
private boolean bound;
|
|
||||||
|
|
||||||
private final ICastStatusCallback.Stub statusCallback = new ICastStatusCallback.Stub() {
|
|
||||||
@Override
|
|
||||||
public void onStatus(String status) {
|
|
||||||
runOnUiThread(() -> statusText.setText(status));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAuthenticated(String senderName) {
|
|
||||||
runOnUiThread(() -> statusText.setText(getString(R.string.casting_from, senderName)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStreamStarted(int width, int height) {
|
|
||||||
runOnUiThread(() -> statusText.setText(getString(R.string.playback_opened, width, height)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onVideoRendering() {
|
|
||||||
runOnUiThread(() -> statusText.setText(R.string.playback_rendering));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStreamIdle() {
|
|
||||||
runOnUiThread(() -> statusText.setText(R.string.playback_awaiting_stream));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDisconnected() {
|
|
||||||
runOnUiThread(() -> statusText.setText(R.string.waiting_sender));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private final ServiceConnection connection = new ServiceConnection() {
|
|
||||||
@Override
|
|
||||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
|
||||||
receiverService = ICastReceiverService.Stub.asInterface(service);
|
|
||||||
bound = true;
|
|
||||||
try {
|
|
||||||
receiverService.registerCallback(statusCallback);
|
|
||||||
statusText.setText(receiverService.getStatus());
|
|
||||||
} catch (RemoteException e) {
|
|
||||||
statusText.setText(e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onServiceDisconnected(ComponentName name) {
|
|
||||||
receiverService = null;
|
|
||||||
bound = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.activity_receiver);
|
|
||||||
PermissionHelper.request(this);
|
|
||||||
castSettings.copyFrom(AppPreferences.loadReceiverDefaults(this));
|
|
||||||
|
|
||||||
statusText = findViewById(R.id.text_status);
|
|
||||||
findViewById(R.id.btn_start_receiver).setOnClickListener(v -> startReceiverService());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
CastActivityTracker.onResume(this);
|
|
||||||
PermissionHelper.remindIfMissing(this, AppPreferences.isPlayIncomingAudio(this));
|
|
||||||
updateKeepScreenOn();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStart() {
|
|
||||||
super.onStart();
|
|
||||||
bindService(new Intent(this, ReceiverCastService.class), connection, Context.BIND_AUTO_CREATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStop() {
|
|
||||||
if (bound && receiverService != null) {
|
|
||||||
try {
|
|
||||||
receiverService.unregisterCallback(statusCallback);
|
|
||||||
} catch (RemoteException ignored) {
|
|
||||||
}
|
|
||||||
unbindService(connection);
|
|
||||||
bound = false;
|
|
||||||
receiverService = null;
|
|
||||||
}
|
|
||||||
super.onStop();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPause() {
|
|
||||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
super.onPause();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateKeepScreenOn() {
|
|
||||||
if (bound && receiverService != null) {
|
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
} else {
|
|
||||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void startReceiverService() {
|
|
||||||
String pin = AppPreferences.getPin(this);
|
|
||||||
boolean allowAudio = AppPreferences.isPlayIncomingAudio(this);
|
|
||||||
ReceiverPrefs.setAllowAudio(this, allowAudio);
|
|
||||||
|
|
||||||
Intent intent = new Intent(this, ReceiverCastService.class);
|
|
||||||
intent.setAction(ReceiverCastService.ACTION_START);
|
|
||||||
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
|
|
||||||
intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
|
|
||||||
intent.putExtra(CastSettings.EXTRA, castSettings);
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(intent);
|
|
||||||
} else {
|
|
||||||
startService(intent);
|
|
||||||
}
|
|
||||||
statusText.setText(R.string.receiver_starting);
|
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -231,7 +231,7 @@ public class ScreenCastService extends Service implements
|
|||||||
cameraMode = settings.isCameraCapture();
|
cameraMode = settings.isCameraCapture();
|
||||||
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
|
||||||
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
|
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
|
||||||
failAndStop(getString(R.string.capture_camera_not_implemented));
|
failAndStop(getString(R.string.capture_camera_requires_api29));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
launchCastService(null, Activity.RESULT_OK);
|
launchCastService(null, Activity.RESULT_OK);
|
||||||
} else {
|
} 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ public final class SenderCapturePreview {
|
|||||||
Context ctx = textureView.getContext();
|
Context ctx = textureView.getContext();
|
||||||
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA)
|
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA)
|
||||||
!= PackageManager.PERMISSION_GRANTED) {
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
showLabel(R.string.capture_camera_not_implemented);
|
showLabel(R.string.capture_camera_permission_denied);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
labelView.setVisibility(android.view.View.GONE);
|
labelView.setVisibility(android.view.View.GONE);
|
||||||
@@ -207,7 +207,7 @@ public final class SenderCapturePreview {
|
|||||||
CameraManager mgr = ctx.getSystemService(CameraManager.class);
|
CameraManager mgr = ctx.getSystemService(CameraManager.class);
|
||||||
String id = chooseCamera(mgr, useFront);
|
String id = chooseCamera(mgr, useFront);
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
showLabel(R.string.capture_camera_not_implemented);
|
showLabel(R.string.capture_camera_open_failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
surface.setDefaultBufferSize(640, 480);
|
surface.setDefaultBufferSize(640, 480);
|
||||||
@@ -256,7 +256,7 @@ public final class SenderCapturePreview {
|
|||||||
}, handler);
|
}, handler);
|
||||||
} catch (CameraAccessException | SecurityException e) {
|
} catch (CameraAccessException | SecurityException e) {
|
||||||
Log.w(TAG, "Open camera failed", e);
|
Log.w(TAG, "Open camera failed", e);
|
||||||
showLabel(R.string.capture_camera_not_implemented);
|
showLabel(R.string.capture_camera_open_failed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||||||
|
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.BuildConfig;
|
||||||
import com.foxx.androidcast.AppPreferences;
|
import com.foxx.androidcast.AppPreferences;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.CastSettingsBinder;
|
import com.foxx.androidcast.CastSettingsBinder;
|
||||||
@@ -16,6 +17,9 @@ public final class SenderResolutionUi {
|
|||||||
private SenderResolutionUi() {}
|
private SenderResolutionUi() {}
|
||||||
|
|
||||||
public static SenderResolutionUiMode mode(Context context) {
|
public static SenderResolutionUiMode mode(Context context) {
|
||||||
|
if (!BuildConfig.DEBUG) {
|
||||||
|
return SenderResolutionUiMode.SEPARATED;
|
||||||
|
}
|
||||||
return AppPreferences.getDevSenderResolutionUiMode(context);
|
return AppPreferences.getDevSenderResolutionUiMode(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:gravity="center"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="24dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/receiver_title"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/text_status"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/waiting_sender" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/btn_start_receiver"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="24dp"
|
|
||||||
android:text="@string/start_receiver" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="16dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:text="@string/receiver_use_settings_hint"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
</LinearLayout>
|
|
||||||
@@ -33,7 +33,9 @@
|
|||||||
<string name="capture_calibration_test">Тест сигнала</string>
|
<string name="capture_calibration_test">Тест сигнала</string>
|
||||||
<string name="capture_camera">Камера (лицевая)</string>
|
<string name="capture_camera">Камера (лицевая)</string>
|
||||||
<string name="capture_camera_unavailable">Источник для камеры (Android 10+)</string>
|
<string name="capture_camera_unavailable">Источник для камеры (Android 10+)</string>
|
||||||
<string name="capture_camera_not_implemented">Захват с камеры в стадии разработки. Выберите другой метод.</string>
|
<string name="capture_camera_requires_api29">Захват с камеры доступен с Android 10. Выберите экран или калибровку.</string>
|
||||||
|
<string name="capture_camera_permission_denied">Нет доступа к камере. Разрешите камеру или выберите другой режим.</string>
|
||||||
|
<string name="capture_camera_open_failed">Не удалось открыть камеру. Выберите другой режим захвата.</string>
|
||||||
<string name="casting_calibration">Передача калибрации %1$dx%2$d</string>
|
<string name="casting_calibration">Передача калибрации %1$dx%2$d</string>
|
||||||
<string name="casting_camera">Трансляция с камеры %1$dx%2$d</string>
|
<string name="casting_camera">Трансляция с камеры %1$dx%2$d</string>
|
||||||
<string name="quality_low">Низкий (~1.5 Mbps, 24 fps)</string>
|
<string name="quality_low">Низкий (~1.5 Mbps, 24 fps)</string>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
<string name="capture_calibration_test">Calibration test pattern</string>
|
<string name="capture_calibration_test">Calibration test pattern</string>
|
||||||
<string name="capture_camera">Camera (rear Camera2)</string>
|
<string name="capture_camera">Camera (rear Camera2)</string>
|
||||||
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
|
<string name="capture_camera_unavailable">Camera source (requires Android 10+)</string>
|
||||||
<string name="capture_camera_not_implemented">Camera capture is not implemented yet. Use screen or calibration test.</string>
|
<string name="capture_camera_requires_api29">Camera capture requires Android 10 or newer. Use screen or calibration test.</string>
|
||||||
|
<string name="capture_camera_permission_denied">Camera permission denied. Allow camera access or choose another capture mode.</string>
|
||||||
|
<string name="capture_camera_open_failed">Could not open the camera. Try another capture mode.</string>
|
||||||
<string name="casting_calibration">Calibration cast %1$dx%2$d</string>
|
<string name="casting_calibration">Calibration cast %1$dx%2$d</string>
|
||||||
<string name="casting_camera">Camera cast %1$dx%2$d</string>
|
<string name="casting_camera">Camera cast %1$dx%2$d</string>
|
||||||
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
<string name="quality_low">Low (~1.5 Mbps, 24 fps)</string>
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ public class CastResolutionTest {
|
|||||||
assertTrue(size.width > size.height);
|
assertTrue(size.width > size.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void forPresetUiMapsLegacyResolutions() {
|
||||||
|
assertEquals(CastSettings.Resolution.HD_720P,
|
||||||
|
CastSettings.forPresetUi(CastSettings.Resolution.FIXED_720P));
|
||||||
|
assertEquals(CastSettings.Resolution.FULL,
|
||||||
|
CastSettings.forPresetUi(CastSettings.Resolution.FIXED_1080P));
|
||||||
|
assertEquals(CastSettings.Resolution.ADAPTIVE,
|
||||||
|
CastSettings.forPresetUi(CastSettings.Resolution.HALF));
|
||||||
|
assertEquals(CastSettings.Resolution.SD_480P,
|
||||||
|
CastSettings.forPresetUi(CastSettings.Resolution.SD_480P));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void sd480pCapsLongEdge() {
|
public void sd480pCapsLongEdge() {
|
||||||
CastSettings settings = new CastSettings();
|
CastSettings settings = new CastSettings();
|
||||||
|
|||||||
53
docs/ALPHA_SOAK.md
Normal file
53
docs/ALPHA_SOAK.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Alpha soak checklist (LAN demo)
|
||||||
|
|
||||||
|
Manual QA on **two devices** on the same Wi‑Fi. Goal: stable show path without experimental transports or stream protection.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
| Setting | Sender | Receiver |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Transport | **UDP** | **UDP** (must match) |
|
||||||
|
| Stream protection | **None** | **None** |
|
||||||
|
| Video codec | **H.264 (AUTO)** | **H.264 (AUTO)** |
|
||||||
|
| Resolution (sender) | **720p** or **Auto** for first pass | — |
|
||||||
|
| Multi-receiver | **One** receiver selected | — |
|
||||||
|
|
||||||
|
PIN default: `1234` (change in settings if needed).
|
||||||
|
|
||||||
|
## Happy path (~15 min)
|
||||||
|
|
||||||
|
1. Install the same debug/release APK on both devices.
|
||||||
|
2. **Receiver:** Main → **Receive** → **Start listening** (notification shows *Listening*).
|
||||||
|
3. **Sender:** Main → **Send** → refresh discovery → pick receiver → enter PIN → approve screen capture.
|
||||||
|
4. Confirm sender notification: *Casting via UDP · WxH*.
|
||||||
|
5. Confirm video and audio on receiver for **≥ 5 minutes**.
|
||||||
|
6. Rotate sender once; confirm stream recovers or stop/restart cleanly.
|
||||||
|
7. Press **Stop** on sender notification; receiver returns to awaiting state.
|
||||||
|
|
||||||
|
## Stress (optional, ~10 min)
|
||||||
|
|
||||||
|
- Background sender app for 30s, return — stream continues or fails with clear status (no silent hang).
|
||||||
|
- Lock/unlock receiver once during cast.
|
||||||
|
- Second cast session without reinstall (stop → start again).
|
||||||
|
|
||||||
|
## Pass criteria
|
||||||
|
|
||||||
|
- No crash on either device during happy path.
|
||||||
|
- Video appears within **10s** of capture consent.
|
||||||
|
- Stop ends services and notifications on both sides.
|
||||||
|
|
||||||
|
## If something fails
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---------|--------|
|
||||||
|
| No video | Transport match; receiver listening first; open receiver UI once for surface attach |
|
||||||
|
| Auth failed | PIN; same Wi‑Fi; firewall on LAN |
|
||||||
|
| *Screen capture data missing* | Reinstall APK; Android 13+ consent intent |
|
||||||
|
| Choppy only on UDP | Retry with **TCP** on both sides (fallback test) |
|
||||||
|
|
||||||
|
## Not in alpha soak scope
|
||||||
|
|
||||||
|
- QUIC / WebRTC / USB-tether transports
|
||||||
|
- FEC / NACK stream protection
|
||||||
|
- 1:N multi-receiver (unless explicitly testing)
|
||||||
|
- OTA / crash upload (backend track)
|
||||||
@@ -15,11 +15,13 @@ Last updated: 2026-05-21. Living document — refine as alpha and commercial tra
|
|||||||
|
|
||||||
### Alpha app checklist
|
### Alpha app checklist
|
||||||
|
|
||||||
- [ ] Happy path: UDP + H.264/AAC + single receiver, stream protection **None**
|
- [ ] Happy path: UDP + H.264/AAC + single receiver, stream protection **None** — see [ALPHA_SOAK.md](ALPHA_SOAK.md)
|
||||||
- [ ] README matches `CastSettings` / `CastConfig` defaults
|
- [x] README matches `CastSettings` / `CastConfig` defaults (UDP default, resolution presets)
|
||||||
- [ ] Fix misleading strings (e.g. camera “not implemented”)
|
- [x] Fix misleading strings (camera permission / API messaging)
|
||||||
- [ ] Two-device QA script (rotate, background, notification stop)
|
- [x] Two-device QA script — [ALPHA_SOAK.md](ALPHA_SOAK.md)
|
||||||
- [ ] Optional: cap multi-receiver at 1 for show, or soak 1:N
|
- [x] Sender resolution UI: Original / 720p / 480p / Auto (release builds; legacy modes dev-only)
|
||||||
|
- [x] Remove orphan `ReceiverActivity` (receiver entry is `ReceiverPlaybackActivity`)
|
||||||
|
- [ ] Optional: cap multi-receiver at 1 for show, or soak 1:N — soak doc recommends one receiver
|
||||||
|
|
||||||
### Alpha backend checklist
|
### Alpha backend checklist
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ See [ndk/README.md](../ndk/README.md).
|
|||||||
| `ReceiverPlaybackActivity` (primary receiver UI) | Done |
|
| `ReceiverPlaybackActivity` (primary receiver UI) | Done |
|
||||||
| Settings drawer + themes + RU locale | Done |
|
| Settings drawer + themes + RU locale | Done |
|
||||||
| Multi-receiver 1:N | Done (soak or gate for alpha) |
|
| Multi-receiver 1:N | Done (soak or gate for alpha) |
|
||||||
| Legacy `ReceiverActivity` in manifest | Orphan — remove or wire |
|
| Legacy `ReceiverActivity` | Removed — use `ReceiverPlaybackActivity` only |
|
||||||
| Immersive A/V presets (dev) | Experimental |
|
| Immersive A/V presets (dev) | Experimental |
|
||||||
| Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) |
|
| Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) |
|
||||||
| True dual-stream encode (480p + 720p simultaneous) | Deferred |
|
| True dual-stream encode (480p + 720p simultaneous) | Deferred |
|
||||||
@@ -156,6 +158,7 @@ See [ndk/README.md](../ndk/README.md).
|
|||||||
|
|
||||||
- [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf))
|
- [GIT_FLOW.md](GIT_FLOW.md) — green `master` / `next` development cycle ([PDF](GIT_FLOW.pdf))
|
||||||
- [README.md](../README.md) — quick start
|
- [README.md](../README.md) — quick start
|
||||||
|
- [ALPHA_SOAK.md](ALPHA_SOAK.md) — two-device LAN demo QA
|
||||||
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
|
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
|
||||||
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
|
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
|
||||||
- [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements
|
- [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements
|
||||||
|
|||||||
15
examples/crash_reporter/backend/public/api/tag_catalog.php
Normal file
15
examples/crash_reporter/backend/public/api/tag_catalog.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||||
|
|
||||||
|
if (!Auth::user()) {
|
||||||
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out([
|
||||||
|
'ok' => true,
|
||||||
|
'workflow' => TagCatalog::workflowPresets(),
|
||||||
|
'presets' => TagCatalog::editorPresets(),
|
||||||
|
'filter_mode_default' => 'and',
|
||||||
|
'filter_mode_help' => 'AND = report has all listed tags; OR = any listed tag',
|
||||||
|
]);
|
||||||
59
examples/crash_reporter/backend/scripts/test_tags_api.sh
Executable file
59
examples/crash_reporter/backend/scripts/test_tags_api.sh
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Smoke-test reports list + tag edit API (session cookie after login).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# export CRASH_BASE="https://f0xx.org/app/androidcast_project/crashes"
|
||||||
|
# export CRASH_USER="admin"
|
||||||
|
# export CRASH_PASS="secret"
|
||||||
|
# ./scripts/test_tags_api.sh
|
||||||
|
#
|
||||||
|
# Or pass a browser session cookie:
|
||||||
|
# export CRASH_COOKIE="PHPSESSID=..."
|
||||||
|
# ./scripts/test_tags_api.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE="${CRASH_BASE:-http://127.0.0.1:8080}"
|
||||||
|
COOKIE_JAR="${TMPDIR:-/tmp}/crash_api_cookies_$$.txt"
|
||||||
|
trap 'rm -f "$COOKIE_JAR"' EXIT
|
||||||
|
|
||||||
|
if [[ -z "${CRASH_COOKIE:-}" ]]; then
|
||||||
|
if [[ -z "${CRASH_USER:-}" || -z "${CRASH_PASS:-}" ]]; then
|
||||||
|
echo "Set CRASH_USER + CRASH_PASS or CRASH_COOKIE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
curl -sS -c "$COOKIE_JAR" -b "$COOKIE_JAR" -X POST \
|
||||||
|
-d "username=${CRASH_USER}&password=${CRASH_PASS}" \
|
||||||
|
"${BASE}/login" -o /dev/null
|
||||||
|
CURL_AUTH=(-b "$COOKIE_JAR")
|
||||||
|
else
|
||||||
|
CURL_AUTH=(-H "Cookie: ${CRASH_COOKIE}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== tag catalog =="
|
||||||
|
curl -sS "${CURL_AUTH[@]}" "${BASE}/api/tag_catalog.php" | head -c 400
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "== reports (first page) =="
|
||||||
|
LIST=$(curl -sS "${CURL_AUTH[@]}" "${BASE}/api/reports.php?page=1&per_page=5")
|
||||||
|
echo "$LIST" | head -c 500
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
ID=$(echo "$LIST" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | head -1)
|
||||||
|
if [[ -z "$ID" ]]; then
|
||||||
|
echo "No report id in list; skip tag write test"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== set tags on report #$ID (duplicate + fixed) =="
|
||||||
|
BODY='{"id":'"$ID"',"tags":[{"id":"duplicate","label":"duplicate","bg":"#6d28d9"},{"id":"fixed","label":"fixed","bg":"#047857"}]}'
|
||||||
|
curl -sS "${CURL_AUTH[@]}" -X POST -H "Content-Type: application/json" \
|
||||||
|
-d "$BODY" "${BASE}/api/report_tags.php"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "== filter AND duplicate+fixed =="
|
||||||
|
curl -sS "${CURL_AUTH[@]}" \
|
||||||
|
"${BASE}/api/reports.php?tag=duplicate&tag=fixed&tag_mode=and&per_page=3" | head -c 400
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "OK"
|
||||||
84
examples/crash_reporter/backend/src/TagCatalog.php
Normal file
84
examples/crash_reporter/backend/src/TagCatalog.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** Workflow triage tags — multiple labels per report allowed (e.g. duplicate + fixed). */
|
||||||
|
final class TagCatalog {
|
||||||
|
/** @var list<array{id:string,label:string,bg:string}>|null */
|
||||||
|
private static ?array $workflow = null;
|
||||||
|
|
||||||
|
/** @return list<array{id:string,label:string,bg:string}> */
|
||||||
|
public static function workflowPresets(): array {
|
||||||
|
if (self::$workflow !== null) {
|
||||||
|
return self::$workflow;
|
||||||
|
}
|
||||||
|
self::$workflow = [
|
||||||
|
['id' => 'in-progress', 'label' => 'in progress', 'bg' => '#3d8bfd'],
|
||||||
|
['id' => 'fixed', 'label' => 'fixed', 'bg' => '#047857'],
|
||||||
|
['id' => 'rejected', 'label' => 'rejected', 'bg' => '#dc2626'],
|
||||||
|
['id' => 'done', 'label' => 'done', 'bg' => '#5c6b82'],
|
||||||
|
['id' => 'not-reproducible', 'label' => 'not reproducible', 'bg' => '#d97706'],
|
||||||
|
['id' => 'not-a-bug', 'label' => 'not a bug', 'bg' => '#6b7280'],
|
||||||
|
['id' => 'duplicate', 'label' => 'duplicate', 'bg' => '#6d28d9'],
|
||||||
|
];
|
||||||
|
return self::$workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, true> */
|
||||||
|
public static function workflowIds(): array {
|
||||||
|
$map = [];
|
||||||
|
foreach (self::workflowPresets() as $tag) {
|
||||||
|
$map[$tag['id']] = true;
|
||||||
|
}
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isWorkflowId(string $id): bool {
|
||||||
|
return isset(self::workflowIds()[$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $raw from query (?tag=fixed&tag=duplicate)
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function normalizeFilterIds(array $raw): array {
|
||||||
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($raw as $item) {
|
||||||
|
$id = strtolower(trim((string) $item));
|
||||||
|
$id = preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? '';
|
||||||
|
$id = trim($id, '-');
|
||||||
|
if ($id === '' || strlen($id) > 32 || isset($seen[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset(array_flip(report_reserved_tag_ids())[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$id] = true;
|
||||||
|
$out[] = $id;
|
||||||
|
if (count($out) >= 8) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow presets first, then distinct custom tags from DB (excluding workflow ids).
|
||||||
|
*
|
||||||
|
* @return list<array{id:string,label:string,bg:string}>
|
||||||
|
*/
|
||||||
|
public static function editorPresets(int $historicalLimit = 32): array {
|
||||||
|
$map = [];
|
||||||
|
foreach (self::workflowPresets() as $tag) {
|
||||||
|
$map[$tag['id']] = $tag;
|
||||||
|
}
|
||||||
|
foreach (ReportRepository::listTagPresets($historicalLimit + 20) as $tag) {
|
||||||
|
$id = (string) ($tag['id'] ?? '');
|
||||||
|
if ($id === '' || isset($map[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$id] = $tag;
|
||||||
|
}
|
||||||
|
return array_values($map);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user