diff --git a/README.md b/README.md
index 78f7d91..2507a07 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# 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
@@ -13,13 +13,16 @@ Minimal LAN screen-casting proof of concept in **Java**. Captures screen + playb
- Playback audio capture (API 29+) → AAC-LC
- Runtime notification permission (Android 13+)
-## Quick start
+## Quick start (alpha demo)
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**.
+3. **Sender:** **Send** → **UDP** → pick **one** receiver → PIN → approve screen capture.
+4. Sender resolution: **Original**, **720p**, **480p**, or **Auto** (settings drawer).
+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
@@ -43,11 +46,13 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
| 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) |
+| **Transport UDP** | Default; lower latency |
+| **Transport TCP** | Reliable fallback |
+| **Sender resolution** | **Original** (native), **720p** / **480p** (long-edge cap), **Auto** (adaptive from peer stats) |
+| **Bitrate** | Estimated / quality / speed modes in settings |
+| **Stream protection** | FEC/NACK optional on UDP — use **None** for alpha demo |
| **Audio** | `AudioPlaybackCapture` + AAC (Android 10+) |
+| **Camera capture** | Rear/front Camera2 (Android 10+); requires camera permission |
## Protocol
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e4e6476..ad90760 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,11 +42,6 @@
android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density" />
-
-
- * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
- * class ReceiverActivity
- * Contributors:
- * - Anton Afanasyeu (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);
- }
-}
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..5ba3000 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -231,7 +231,7 @@ public class ScreenCastService extends Service implements
cameraMode = settings.isCameraCapture();
calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST;
if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) {
- failAndStop(getString(R.string.capture_camera_not_implemented));
+ failAndStop(getString(R.string.capture_camera_requires_api29));
return;
}
int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport());
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..83f5a1c 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);
@@ -207,7 +207,7 @@ public final class SenderCapturePreview {
CameraManager mgr = ctx.getSystemService(CameraManager.class);
String id = chooseCamera(mgr, useFront);
if (id == null) {
- showLabel(R.string.capture_camera_not_implemented);
+ showLabel(R.string.capture_camera_open_failed);
return;
}
surface.setDefaultBufferSize(640, 480);
@@ -256,7 +256,7 @@ public final class SenderCapturePreview {
}, handler);
} catch (CameraAccessException | SecurityException e) {
Log.w(TAG, "Open camera failed", e);
- showLabel(R.string.capture_camera_not_implemented);
+ showLabel(R.string.capture_camera_open_failed);
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java b/app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java
index 19ad957..d2ece53 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderResolutionUi.java
@@ -6,6 +6,7 @@ import androidx.appcompat.app.AppCompatActivity;
import android.widget.Spinner;
+import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastSettingsBinder;
@@ -16,6 +17,9 @@ public final class SenderResolutionUi {
private SenderResolutionUi() {}
public static SenderResolutionUiMode mode(Context context) {
+ if (!BuildConfig.DEBUG) {
+ return SenderResolutionUiMode.SEPARATED;
+ }
return AppPreferences.getDevSenderResolutionUiMode(context);
}
diff --git a/app/src/main/res/layout/activity_receiver.xml b/app/src/main/res/layout/activity_receiver.xml
deleted file mode 100644
index bcca5d3..0000000
--- a/app/src/main/res/layout/activity_receiver.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 211d304..8b90cac 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -33,7 +33,9 @@
Тест сигнала
Камера (лицевая)
Источник для камеры (Android 10+)
- Захват с камеры в стадии разработки. Выберите другой метод.
+ Захват с камеры доступен с 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..09f580b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -33,7 +33,9 @@
Calibration test pattern
Camera (rear Camera2)
Camera source (requires Android 10+)
- Camera capture is not implemented yet. Use screen or calibration test.
+ Camera capture requires Android 10 or newer. Use screen or calibration test.
+ Camera permission denied. Allow camera access or choose another capture mode.
+ Could not open the camera. Try another capture mode.
Calibration cast %1$dx%2$d
Camera cast %1$dx%2$d
Low (~1.5 Mbps, 24 fps)
diff --git a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java
index 251d701..9f34bfa 100644
--- a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java
+++ b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java
@@ -30,6 +30,18 @@ public class CastResolutionTest {
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
public void sd480pCapsLongEdge() {
CastSettings settings = new CastSettings();
diff --git a/docs/ALPHA_SOAK.md b/docs/ALPHA_SOAK.md
new file mode 100644
index 0000000..091f277
--- /dev/null
+++ b/docs/ALPHA_SOAK.md
@@ -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)
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index c12a216..3a9900e 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -15,11 +15,13 @@ 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
+- [ ] Happy path: UDP + H.264/AAC + single receiver, stream protection **None** — see [ALPHA_SOAK.md](ALPHA_SOAK.md)
+- [x] README matches `CastSettings` / `CastConfig` defaults (UDP default, resolution presets)
+- [x] Fix misleading strings (camera permission / API messaging)
+- [x] Two-device QA script — [ALPHA_SOAK.md](ALPHA_SOAK.md)
+- [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
@@ -134,7 +136,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) |
-| Legacy `ReceiverActivity` in manifest | Orphan — remove or wire |
+| Legacy `ReceiverActivity` | Removed — use `ReceiverPlaybackActivity` only |
| Immersive A/V presets (dev) | Experimental |
| Sender resolution UI modes (dev: Separated / Legacy / Combined) | Done (test) |
| 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))
- [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
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
- [PRO_AAR.md](PRO_AAR.md) — cast-core / cast-pro entitlements
diff --git a/examples/crash_reporter/backend/public/api/tag_catalog.php b/examples/crash_reporter/backend/public/api/tag_catalog.php
new file mode 100644
index 0000000..4871c4c
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/tag_catalog.php
@@ -0,0 +1,15 @@
+ 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',
+]);
diff --git a/examples/crash_reporter/backend/scripts/test_tags_api.sh b/examples/crash_reporter/backend/scripts/test_tags_api.sh
new file mode 100755
index 0000000..f4062b2
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/test_tags_api.sh
@@ -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"
diff --git a/examples/crash_reporter/backend/src/TagCatalog.php b/examples/crash_reporter/backend/src/TagCatalog.php
new file mode 100644
index 0000000..7ef1088
--- /dev/null
+++ b/examples/crash_reporter/backend/src/TagCatalog.php
@@ -0,0 +1,84 @@
+|null */
+ private static ?array $workflow = null;
+
+ /** @return list */
+ 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 */
+ 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 $raw from query (?tag=fixed&tag=duplicate)
+ * @return list
+ */
+ 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
+ */
+ 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);
+ }
+}