1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00

snap + backend headache

This commit is contained in:
Anton Afanasyeu
2026-05-21 17:15:18 +02:00
parent 8c5a0162a2
commit 2e04711c8f
35 changed files with 1328 additions and 88 deletions

View File

@@ -2,8 +2,27 @@
Research and recommendations for **movie cast** vs **video cast** quality enhancement in the Android Cast project. Combines user requirements, comparison tables, algorithm choices, placement (sender vs receiver), and fit with the current codebase. Research and recommendations for **movie cast** vs **video cast** quality enhancement in the Android Cast project. Combines user requirements, comparison tables, algorithm choices, placement (sender vs receiver), and fit with the current codebase.
**Status:** Planning / research only — not implemented. **Status:** Receiver experimental presets in **Developer settings** — audio PCM chain + video GLES post-decode (immersive / HDR shaders).
**Related code (today):** sender `ScreenCastService``VideoEncoder` / `AudioEncoder` → network; receiver network → `VideoDecoder` / `LibvpxCapableVideoDecoder` / `AudioDecoder``TextureView` / `AudioTrack`. **Related code:** `app/.../receiver/av/*`, `ReceiverVideoGlRenderer`, `AudioDecoder`, `VideoDecoder` / `LibvpxVideoDecoder`, `ReceiverPlaybackActivity`, `DeveloperSettingsActivity`.
---
## 0. Receiver A/V presets (product, May 2026)
Developer-only combo in **Developer settings** (not the main cast drawer):
| Video | Audio | Pipeline today |
|-------|-------|----------------|
| **Default** — decode→Surface passthrough | **Default** — decode→AudioTrack passthrough | Hook only; no DSP / no GLES |
| **Immersive** — GLES edge-aware denoise + mild sharpen | **Bass boost** — shelf below ~150 Hz, gentle treble cut | PCM + GLES; **intensity 010** |
| **HDR** — GLES Reinhard-lite + saturation | **Immersive** — clarity + noise gate + limiter (no ML v1) | RNNoise-class deferred; see §8A |
| | **Dolby 5.1 / 7.1** — crossfeed + short delays for stereo headsets | Spatialization stub; **intensity 010** |
**Immersive audio (this pass):** clearer, wider perceived loudness without extra hiss — high-pass (~90 Hz), envelope noise gate, mild presence lift, soft ceiling. Not “cinema reverb”; aim for intelligibility on cast compression artifacts. Scale all stages by **intensity/10**.
**Intensity:** 0 = bypass effect (passthrough samples); 10 = maximum for that preset.
**Integration:** `ReceiverAvRuntime``AudioDecoder` after AAC decode; video decoder → intermediate OES `SurfaceTexture` → GLES shader → `TextureView` (`ReceiverVideoGlHub`) when preset is immersive/HDR; default preset uses direct decode→`TextureView` Surface.
--- ---

View File

@@ -16,6 +16,7 @@ import android.content.Context;
import com.foxx.androidcast.crash.CrashReporter; import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
/** Applies saved theme and locale at process start. */ /** Applies saved theme and locale at process start. */
public class AndroidCastApplication extends Application { public class AndroidCastApplication extends Application {
@@ -31,5 +32,6 @@ public class AndroidCastApplication extends Application {
CastLocaleHelper.applyStoredLocale(this); CastLocaleHelper.applyStoredLocale(this);
CastThemeHelper.applyNightMode(this); CastThemeHelper.applyNightMode(this);
CrashReporter.install(this); CrashReporter.install(this);
ReceiverAvRuntime.reload(this);
} }
} }

View File

@@ -17,6 +17,9 @@ import android.os.Build;
import android.provider.Settings; import android.provider.Settings;
import android.text.TextUtils; import android.text.TextUtils;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
/** /**
* Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options. * Global app preferences: identity, PIN, tray icon, and default sender/receiver cast options.
*/ */
@@ -33,6 +36,10 @@ public final class AppPreferences {
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats"; private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs"; private static final String KEY_SEND_ANONYMOUS_CRASH_LOGS = "send_anonymous_crash_logs";
private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores"; private static final String KEY_DEV_SHOW_CODEC_PRIORITY_SCORES = "dev_show_codec_priority_scores";
private static final String KEY_DEV_RECEIVER_VIDEO_PRESET = "dev_receiver_video_preset";
private static final String KEY_DEV_RECEIVER_AUDIO_PRESET = "dev_receiver_audio_preset";
private static final String KEY_DEV_RECEIVER_AV_INTENSITY = "dev_receiver_av_intensity";
private static final String KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY = "dev_receiver_adaptive_display";
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined"; private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled"; private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url"; private static final String KEY_OTA_CHANNEL_URL = "ota_channel_url";
@@ -257,9 +264,56 @@ public final class AppPreferences {
storeCastSettings(context, PREFIX_SENDER, settings); storeCastSettings(context, PREFIX_SENDER, settings);
} }
public static ReceiverVideoPreset getReceiverVideoPreset(Context context) {
return ReceiverVideoPreset.fromName(
prefs(context).getString(KEY_DEV_RECEIVER_VIDEO_PRESET, null),
ReceiverVideoPreset.DEFAULT);
}
public static void setReceiverVideoPreset(Context context, ReceiverVideoPreset preset) {
prefs(context).edit()
.putString(KEY_DEV_RECEIVER_VIDEO_PRESET, preset.name())
.apply();
}
public static ReceiverAudioPreset getReceiverAudioPreset(Context context) {
return ReceiverAudioPreset.fromName(
prefs(context).getString(KEY_DEV_RECEIVER_AUDIO_PRESET, null),
ReceiverAudioPreset.DEFAULT);
}
public static void setReceiverAudioPreset(Context context, ReceiverAudioPreset preset) {
prefs(context).edit()
.putString(KEY_DEV_RECEIVER_AUDIO_PRESET, preset.name())
.apply();
}
public static int getReceiverAvIntensity(Context context) {
return Math.max(0, Math.min(10, prefs(context).getInt(KEY_DEV_RECEIVER_AV_INTENSITY, 5)));
}
public static void setReceiverAvIntensity(Context context, int intensity) {
prefs(context).edit()
.putInt(KEY_DEV_RECEIVER_AV_INTENSITY, Math.max(0, Math.min(10, intensity)))
.apply();
}
public static boolean isDevReceiverAdaptiveDisplay(Context context) {
return prefs(context).getBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, false);
}
public static void setDevReceiverAdaptiveDisplay(Context context, boolean enabled) {
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, enabled).apply();
}
public static CastSettings loadReceiverDefaults(Context context) { public static CastSettings loadReceiverDefaults(Context context) {
CastSettings s = new CastSettings(); CastSettings s = new CastSettings();
applyStoredCastSettings(context, PREFIX_RECEIVER, s); applyStoredCastSettings(context, PREFIX_RECEIVER, s);
if (isDevReceiverAdaptiveDisplay(context)) {
s.setReceiverDisplayResolution(CastSettings.ReceiverDisplayResolution.ADAPTIVE_EXPERIMENTAL);
} else {
s.setReceiverDisplayResolution(CastSettings.ReceiverDisplayResolution.AUTO_FIT);
}
s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO); s.setAdjustmentPreset(CastSettings.AdjustmentPreset.AUTO);
s.setQuality(CastSettings.Quality.MEDIUM); s.setQuality(CastSettings.Quality.MEDIUM);
if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) { if (s.getBitrateMode() == CastSettings.BitrateMode.AUTO) {

View File

@@ -118,7 +118,6 @@ public final class CastSettingsBinder {
bindTransport(activity, settings, R.id.spinner_receiver_transport, true); bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network); bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
bindReceiverDisplay(activity, settings);
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
if (audioPlay != null) { if (audioPlay != null) {
bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity), bindOnOffSpinner(activity, audioPlay, AppPreferences.isPlayIncomingAudio(activity),
@@ -145,7 +144,6 @@ public final class CastSettingsBinder {
readTransport(activity, settings, R.id.spinner_receiver_transport); readTransport(activity, settings, R.id.spinner_receiver_transport);
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec); readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network); readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
readReceiverDisplay(activity, settings);
Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio); Spinner audioPlay = activity.findViewById(R.id.spinner_receiver_play_audio);
if (audioPlay != null) { if (audioPlay != null) {
AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0); AppPreferences.setPlayIncomingAudio(activity, audioPlay.getSelectedItemPosition() == 0);
@@ -375,22 +373,6 @@ public final class CastSettingsBinder {
} }
} }
private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();
String[] labels = {
activity.getString(R.string.receiver_display_auto_fit),
activity.getString(R.string.receiver_display_adaptive)
};
bindEnumSpinner(spinner, modes, labels, settings.getReceiverDisplayResolution(),
settings::setReceiverDisplayResolution);
}
private static void readReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
readEnumSpinner(activity.findViewById(R.id.spinner_receiver_resolution),
settings::setReceiverDisplayResolution);
}
private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) { private static void bindCaptureMode(AppCompatActivity activity, CastSettings settings) {
Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode); Spinner spinner = activity.findViewById(R.id.spinner_sender_capture_mode);
List<String> labels = new ArrayList<>(); List<String> labels = new ArrayList<>();

View File

@@ -156,18 +156,22 @@ public class CastSettingsFragment extends Fragment {
TextView commit = view.findViewById(R.id.text_app_commit_value); TextView commit = view.findViewById(R.id.text_app_commit_value);
TextView built = view.findViewById(R.id.text_app_built_value); TextView built = view.findViewById(R.id.text_app_built_value);
TextView author = view.findViewById(R.id.text_app_author_value); TextView author = view.findViewById(R.id.text_app_author_value);
View.OnClickListener versionTap = v -> onVersionLabelTapped(); View.OnClickListener devTap = v -> onDeveloperSettingsTap();
if (version != null) { if (version != null) {
version.setText(VersionInfo.APP_VERSION_STRING); version.setText(VersionInfo.APP_VERSION_STRING);
version.setOnClickListener(versionTap); version.setOnClickListener(devTap);
} }
View versionRow = view.findViewById(R.id.row_app_version); View versionRow = view.findViewById(R.id.row_app_version);
if (versionRow != null) { if (versionRow != null) {
versionRow.setOnClickListener(versionTap); versionRow.setOnClickListener(devTap);
} }
TextView versionLabel = view.findViewById(R.id.label_app_version); TextView versionLabel = view.findViewById(R.id.label_app_version);
if (versionLabel != null) { if (versionLabel != null) {
versionLabel.setOnClickListener(versionTap); versionLabel.setOnClickListener(devTap);
}
View appInfoSection = view.findViewById(R.id.section_app_info);
if (appInfoSection != null) {
appInfoSection.setOnClickListener(devTap);
} }
if (protocol != null) { if (protocol != null) {
protocol.setText(VersionInfo.PROTO_VERSION_STRING); protocol.setText(VersionInfo.PROTO_VERSION_STRING);
@@ -202,7 +206,7 @@ public class CastSettingsFragment extends Fragment {
} }
} }
private void onVersionLabelTapped() { private void onDeveloperSettingsTap() {
long now = SystemClock.elapsedRealtime(); long now = SystemClock.elapsedRealtime();
if (now - versionTapWindowStartMs > 2500) { if (now - versionTapWindowStartMs > 2500) {
versionTapCount = 0; versionTapCount = 0;

View File

@@ -13,8 +13,11 @@ package com.foxx.androidcast;
**********************************************************************/ **********************************************************************/
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.CheckBox; import android.widget.CheckBox;
import android.widget.SeekBar;
import android.widget.Spinner;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
@@ -23,11 +26,19 @@ import androidx.appcompat.app.AppCompatActivity;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.ota.OtaUpdateChecker; import com.foxx.androidcast.ota.OtaUpdateChecker;
import com.foxx.androidcast.ota.OtaUpdateCoordinator; import com.foxx.androidcast.ota.OtaUpdateCoordinator;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity; import com.foxx.androidcast.stats.ui.SessionStatsAnalyzerActivity;
import com.google.android.material.textfield.TextInputEditText; import com.google.android.material.textfield.TextInputEditText;
/** Hidden developer settings (opened via version label easter egg). */ /** Hidden developer settings (opened via app-info area easter egg). */
public class DeveloperSettingsActivity extends AppCompatActivity { public class DeveloperSettingsActivity extends AppCompatActivity {
private Spinner videoPresetSpinner;
private Spinner audioPresetSpinner;
private SeekBar intensitySeek;
private TextView intensityValue;
@Override @Override
protected void attachBaseContext(Context newBase) { protected void attachBaseContext(Context newBase) {
super.attachBaseContext(CastLocaleHelper.attach(newBase)); super.attachBaseContext(CastLocaleHelper.attach(newBase));
@@ -43,14 +54,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay); CheckBox debugOverlay = findViewById(R.id.check_receiver_debug_overlay);
CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores); CheckBox codecScores = findViewById(R.id.check_show_codec_priority_scores);
CheckBox sessionStats = findViewById(R.id.check_grab_session_stats); CheckBox sessionStats = findViewById(R.id.check_grab_session_stats);
Button reloadCodecs = findViewById(R.id.btn_reload_codecs_json); CheckBox adaptiveDisplay = findViewById(R.id.check_receiver_adaptive_display);
refreshFromPreferences(debugOverlay, codecScores, sessionStats); refreshFromPreferences(debugOverlay, codecScores, sessionStats, adaptiveDisplay);
findViewById(R.id.btn_open_session_stats).setOnClickListener(v -> findViewById(R.id.btn_open_session_stats).setOnClickListener(v ->
startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class))); startActivity(new android.content.Intent(this, SessionStatsAnalyzerActivity.class)));
reloadCodecs.setOnClickListener(v -> { findViewById(R.id.btn_reload_codecs_json).setOnClickListener(v -> {
String summary = CodecPriorityCatalog.reload(this); String summary = CodecPriorityCatalog.reload(this);
Toast.makeText(this, summary, Toast.LENGTH_LONG).show(); Toast.makeText(this, summary, Toast.LENGTH_LONG).show();
}); });
@@ -70,6 +81,14 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
AppPreferences.setOtaChannelUrl(this, url); AppPreferences.setOtaChannelUrl(this, url);
OtaUpdateCoordinator.requestManualCheck(this); OtaUpdateCoordinator.requestManualCheck(this);
}); });
bindAvPresets();
}
@Override
protected void onPause() {
saveAvPresets();
super.onPause();
} }
@Override @Override
@@ -78,11 +97,117 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
refreshFromPreferences( refreshFromPreferences(
findViewById(R.id.check_receiver_debug_overlay), findViewById(R.id.check_receiver_debug_overlay),
findViewById(R.id.check_show_codec_priority_scores), findViewById(R.id.check_show_codec_priority_scores),
findViewById(R.id.check_grab_session_stats)); findViewById(R.id.check_grab_session_stats),
findViewById(R.id.check_receiver_adaptive_display));
refreshOtaUi( refreshOtaUi(
findViewById(R.id.text_ota_installed_version), findViewById(R.id.text_ota_installed_version),
findViewById(R.id.edit_ota_manifest_url), findViewById(R.id.edit_ota_manifest_url),
findViewById(R.id.check_ota_on_launch)); findViewById(R.id.check_ota_on_launch));
bindAvPresets();
}
private void bindAvPresets() {
videoPresetSpinner = findViewById(R.id.spinner_dev_video_preset);
audioPresetSpinner = findViewById(R.id.spinner_dev_audio_preset);
intensitySeek = findViewById(R.id.seek_dev_av_intensity);
intensityValue = findViewById(R.id.text_dev_av_intensity_value);
if (videoPresetSpinner != null) {
String[] labels = {
getString(R.string.dev_av_video_default),
getString(R.string.dev_av_video_immersive),
getString(R.string.dev_av_video_hdr)
};
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
videoPresetSpinner.setAdapter(adapter);
videoPresetSpinner.setSelection(AppPreferences.getReceiverVideoPreset(this).ordinal());
videoPresetSpinner.setOnItemSelectedListener(simpleSelection(() -> updateIntensityEnabled()));
}
if (audioPresetSpinner != null) {
String[] labels = {
getString(R.string.dev_av_audio_default),
getString(R.string.dev_av_audio_bass_boost),
getString(R.string.dev_av_audio_immersive),
getString(R.string.dev_av_audio_dolby_51),
getString(R.string.dev_av_audio_dolby_71)
};
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
android.R.layout.simple_spinner_item, labels);
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
audioPresetSpinner.setAdapter(adapter);
audioPresetSpinner.setSelection(AppPreferences.getReceiverAudioPreset(this).ordinal());
audioPresetSpinner.setOnItemSelectedListener(simpleSelection(() -> updateIntensityEnabled()));
}
if (intensitySeek != null) {
intensitySeek.setProgress(AppPreferences.getReceiverAvIntensity(this));
intensitySeek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
updateIntensityLabel(progress);
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {}
});
}
updateIntensityEnabled();
updateIntensityLabel(intensitySeek != null ? intensitySeek.getProgress() : 0);
}
private void saveAvPresets() {
if (videoPresetSpinner != null) {
AppPreferences.setReceiverVideoPreset(this,
ReceiverVideoPreset.values()[videoPresetSpinner.getSelectedItemPosition()]);
}
if (audioPresetSpinner != null) {
AppPreferences.setReceiverAudioPreset(this,
ReceiverAudioPreset.values()[audioPresetSpinner.getSelectedItemPosition()]);
}
if (intensitySeek != null) {
AppPreferences.setReceiverAvIntensity(this, intensitySeek.getProgress());
}
ReceiverAvRuntime.reload(this);
}
private void updateIntensityEnabled() {
boolean audioOn = audioPresetSpinner != null
&& ReceiverAudioPreset.values()[audioPresetSpinner.getSelectedItemPosition()]
.supportsIntensity();
boolean videoOn = videoPresetSpinner != null
&& ReceiverVideoPreset.values()[videoPresetSpinner.getSelectedItemPosition()]
.supportsIntensity();
boolean on = audioOn || videoOn;
if (intensitySeek != null) {
intensitySeek.setEnabled(on);
}
TextView label = findViewById(R.id.label_dev_av_intensity);
if (label != null) {
label.setEnabled(on);
}
}
private void updateIntensityLabel(int progress) {
if (intensityValue != null) {
intensityValue.setText(getString(R.string.dev_av_intensity_value, progress));
}
}
private android.widget.AdapterView.OnItemSelectedListener simpleSelection(Runnable after) {
return new android.widget.AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(android.widget.AdapterView<?> parent, android.view.View view,
int position, long id) {
after.run();
}
@Override
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
};
} }
private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) { private void refreshOtaUi(TextView installedVersion, TextInputEditText manifestUrl, CheckBox otaOnLaunch) {
@@ -109,13 +234,16 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
} }
} }
private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats) { private void refreshFromPreferences(CheckBox debugOverlay, CheckBox codecScores, CheckBox sessionStats,
CheckBox adaptiveDisplay) {
bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this), bindDeveloperCheckbox(debugOverlay, AppPreferences.isShowReceiverDebugOverlay(this),
AppPreferences::setShowReceiverDebugOverlay); AppPreferences::setShowReceiverDebugOverlay);
bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this), bindDeveloperCheckbox(codecScores, AppPreferences.isShowCodecPriorityScores(this),
AppPreferences::setShowCodecPriorityScores); AppPreferences::setShowCodecPriorityScores);
bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this), bindDeveloperCheckbox(sessionStats, AppPreferences.isGrabSessionStats(this),
AppPreferences::setGrabSessionStats); AppPreferences::setGrabSessionStats);
bindDeveloperCheckbox(adaptiveDisplay, AppPreferences.isDevReceiverAdaptiveDisplay(this),
AppPreferences::setDevReceiverAdaptiveDisplay);
} }
private interface BoolPrefWriter { private interface BoolPrefWriter {

View File

@@ -25,6 +25,8 @@ import android.util.Log;
import com.foxx.androidcast.media.AacAdtsHelper; import com.foxx.androidcast.media.AacAdtsHelper;
import com.foxx.androidcast.media.PcmLevelAnalyzer; import com.foxx.androidcast.media.PcmLevelAnalyzer;
import com.foxx.androidcast.receiver.av.AudioFilterChain;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -116,6 +118,7 @@ public class AudioDecoder {
codec.configure(format, null, null, 0); codec.configure(format, null, null, 0);
codec.start(); codec.start();
createAudioTrack(sampleRate, channels); createAudioTrack(sampleRate, channels);
ReceiverAvRuntime.audio().reset(sampleRate, channels);
configured = true; configured = true;
draining = true; draining = true;
retryFrame = null; retryFrame = null;
@@ -291,6 +294,7 @@ public class AudioDecoder {
} else { } else {
stats.pcmAudibleBlocks++; stats.pcmAudibleBlocks++;
} }
ReceiverAvRuntime.audio().process(pcm, pcm.length, sampleRate, channels);
int written = writePcm(pcm); int written = writePcm(pcm);
if (written > 0) { if (written > 0) {
stats.pcmBytesWritten += written; stats.pcmBytesWritten += written;

View File

@@ -16,6 +16,7 @@ import android.view.Surface;
import com.foxx.androidcast.media.codec.VideoDecoderSink; import com.foxx.androidcast.media.codec.VideoDecoderSink;
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge; import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import java.io.IOException; import java.io.IOException;
@@ -59,6 +60,7 @@ public final class LibvpxVideoDecoder implements VideoDecoderSink {
return; return;
} }
if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) { if (NativeCodecBridge.vpxDecodeFrame(nativeHandle, data, ptsUs, outputSurface)) {
ReceiverAvRuntime.video().onFrameDecoded(ptsUs);
notifyRenderedLocked(); notifyRenderedLocked();
} else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) { } else if ((++decodeFailLogCount % DECODE_FAIL_LOG_EVERY) == 1) {
Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame); Log.w(TAG, "vpx decode failed len=" + data.length + " key=" + keyFrame);

View File

@@ -757,6 +757,8 @@ public class ReceiverCastService extends Service {
reconfiguring = false; reconfiguring = false;
decoderWidth = pendingWidth; decoderWidth = pendingWidth;
decoderHeight = pendingHeight; decoderHeight = pendingHeight;
com.foxx.androidcast.receiver.av.ReceiverAvRuntime.video()
.onConfigured(pendingWidth, pendingHeight);
updateStatus(getString(R.string.playback_awaiting_stream)); updateStatus(getString(R.string.playback_awaiting_stream));
broadcastStreamStarted(pendingWidth, pendingHeight); broadcastStreamStarted(pendingWidth, pendingHeight);
Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight);

View File

@@ -44,6 +44,7 @@ import com.foxx.androidcast.PictureInPictureHelper;
import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.R; import com.foxx.androidcast.R;
import com.foxx.androidcast.receiver.av.ReceiverVideoGlHub;
import com.foxx.androidcast.ui.AspectRatioFrameLayout; import com.foxx.androidcast.ui.AspectRatioFrameLayout;
import com.foxx.androidcast.ui.PlaybackTextureTransform; import com.foxx.androidcast.ui.PlaybackTextureTransform;
import com.foxx.androidcast.ui.ZoomPanContainer; import com.foxx.androidcast.ui.ZoomPanContainer;
@@ -445,6 +446,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
applyTextureFitCenter(); applyTextureFitCenter();
if (surfaceTexture != null) { if (surfaceTexture != null) {
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight); surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight);
if (surfaceSentToService) {
detachSurface();
}
attachSurfaceIfReady(); attachSurfaceIfReady();
} }
} }
@@ -520,21 +524,28 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
return; return;
} }
try { try {
receiverService.attachVideoSurface(new Surface(surfaceTexture)); int w = videoWidth > 0 ? videoWidth : 1280;
int h = videoHeight > 0 ? videoHeight : 720;
Surface surface = ReceiverVideoGlHub.bindDisplay(this, surfaceTexture, w, h);
receiverService.attachVideoSurface(surface);
surfaceSentToService = true; surfaceSentToService = true;
} catch (RemoteException ignored) { } catch (RemoteException ignored) {
} }
} }
private void detachSurface() { private void detachSurface() {
if (!surfaceSentToService || receiverService == null) { if (!surfaceSentToService && receiverService == null) {
ReceiverVideoGlHub.release(this);
return; return;
} }
if (receiverService != null) {
try { try {
receiverService.detachVideoSurface(); receiverService.detachVideoSurface();
} catch (RemoteException ignored) { } catch (RemoteException ignored) {
} }
}
surfaceSentToService = false; surfaceSentToService = false;
ReceiverVideoGlHub.release(this);
} }
@Override @Override

View File

@@ -19,6 +19,7 @@ import android.view.Surface;
import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.H264Bitstream; import com.foxx.androidcast.media.H264Bitstream;
import com.foxx.androidcast.media.codec.VideoDecoderSink; import com.foxx.androidcast.media.codec.VideoDecoderSink;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
import java.io.IOException; import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
@@ -160,6 +161,7 @@ public class VideoDecoder implements VideoDecoderSink {
&& (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0; && (info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) == 0;
c.releaseOutputBuffer(outIndex, render); c.releaseOutputBuffer(outIndex, render);
if (render) { if (render) {
ReceiverAvRuntime.video().onFrameDecoded(info.presentationTimeUs);
notifyFirstFrameLocked(); notifyFirstFrameLocked();
} }
} }

View File

@@ -0,0 +1,9 @@
package com.foxx.androidcast.receiver.av;
/** In-place PCM filter (16-bit interleaved) between AAC decoder and {@link android.media.AudioTrack}. */
public interface AudioFilter {
/** @param intensity 0 = bypass effect, 10 = maximum for this filter */
void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity);
default void reset(int sampleRate, int channels) {}
}

View File

@@ -0,0 +1,31 @@
package com.foxx.androidcast.receiver.av;
/** Ordered PCM filters (decoder → chain → AudioTrack). */
public final class AudioFilterChain {
private final AudioFilter[] filters;
private final int intensity;
public AudioFilterChain(AudioFilter[] filters, int intensity) {
this.filters = filters != null ? filters : new AudioFilter[0];
this.intensity = Math.max(0, Math.min(10, intensity));
}
public static AudioFilterChain passthrough() {
return new AudioFilterChain(new AudioFilter[] {new PassthroughAudioFilter()}, 0);
}
public void reset(int sampleRate, int channels) {
for (AudioFilter f : filters) {
f.reset(sampleRate, channels);
}
}
public void process(byte[] pcm, int bytes, int sampleRate, int channels) {
if (pcm == null || bytes <= 0) {
return;
}
for (AudioFilter f : filters) {
f.process(pcm, bytes, sampleRate, channels, intensity);
}
}
}

View File

@@ -0,0 +1,40 @@
package com.foxx.androidcast.receiver.av;
/**
* Boosts energy below ~150 Hz and gently reduces mids/highs at higher intensity.
*/
public final class BassBoostAudioFilter implements AudioFilter {
private float[] lowL;
private float[] lowR;
private int channels;
@Override
public void reset(int sampleRate, int channels) {
this.channels = Math.max(1, channels);
lowL = new float[this.channels];
lowR = new float[this.channels];
}
@Override
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
if (intensity <= 0 || sampleRate <= 0) {
return;
}
if (lowL == null || this.channels != channels) {
reset(sampleRate, channels);
}
float g = PcmAudioFilters.intensityGain(intensity);
float alpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 150.0 / sampleRate));
float bassGain = 1f + 1.2f * g;
float trebleCut = 1f - 0.35f * g;
PcmAudioFilters.process16Le(pcm, bytes, channels, (sample, ch, frame) -> {
float[] state = ch == 0 ? lowL : (ch == 1 ? lowR : lowL);
int idx = Math.min(ch, state.length - 1);
state[idx] += alpha * (sample - state[idx]);
float low = state[idx];
float high = sample - low;
float out = sample + (bassGain - 1f) * low + (trebleCut - 1f) * high;
return Math.max(-1f, Math.min(1f, out));
});
}
}

View File

@@ -0,0 +1,60 @@
package com.foxx.androidcast.receiver.av;
/**
* Stereo headphone spatialization stub: crossfeed + short delays (5.1 or 7.1 flavor).
*/
public final class DolbySurroundAudioFilter implements AudioFilter {
private final boolean sevenOne;
private float[] delayL;
private float[] delayR;
private int writeIdx;
private int channels;
DolbySurroundAudioFilter(boolean sevenOne) {
this.sevenOne = sevenOne;
}
@Override
public void reset(int sampleRate, int channels) {
this.channels = channels;
int maxDelay = sampleRate > 0 ? Math.max(32, sampleRate * 12 / 1000) : 512;
delayL = new float[maxDelay];
delayR = new float[maxDelay];
writeIdx = 0;
}
@Override
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
if (intensity <= 0 || channels < 2 || sampleRate <= 0) {
return;
}
if (delayL == null || this.channels != channels) {
reset(sampleRate, channels);
}
float g = PcmAudioFilters.intensityGain(intensity);
int d1 = Math.min(delayL.length - 1, sampleRate * 3 / 1000);
int d2 = Math.min(delayL.length - 1, sampleRate * 7 / 1000);
int d3 = sevenOne ? Math.min(delayL.length - 1, sampleRate * 11 / 1000) : 0;
float wet = 0.22f * g;
int frames = bytes / (2 * channels);
int off = 0;
for (int i = 0; i < frames; i++) {
float l = PcmAudioFilters.readSample(pcm, off);
float r = PcmAudioFilters.readSample(pcm, off + 2);
delayL[writeIdx] = l;
delayR[writeIdx] = r;
float tapL = delayL[(writeIdx - d1 + delayL.length) % delayL.length];
float tapR = delayR[(writeIdx - d1 + delayR.length) % delayR.length];
float wideL = l + wet * (tapR * 0.6f + delayR[(writeIdx - d2 + delayR.length) % delayR.length] * 0.3f);
float wideR = r + wet * (tapL * 0.6f + delayL[(writeIdx - d2 + delayL.length) % delayL.length] * 0.3f);
if (sevenOne && d3 > 0) {
wideL += wet * 0.15f * delayR[(writeIdx - d3 + delayR.length) % delayR.length];
wideR += wet * 0.15f * delayL[(writeIdx - d3 + delayL.length) % delayL.length];
}
PcmAudioFilters.writeSample(pcm, off, wideL);
PcmAudioFilters.writeSample(pcm, off + 2, wideR);
writeIdx = (writeIdx + 1) % delayL.length;
off += 2 * channels;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.foxx.androidcast.receiver.av;
import android.content.Context;
/** GLES post-decode path for immersive / HDR experimental presets. */
public final class GlExperimentalVideoPipeline implements ReceiverVideoPipeline {
private final Context appContext;
private final ReceiverVideoPreset preset;
public GlExperimentalVideoPipeline(Context context, ReceiverVideoPreset preset) {
this.appContext = context.getApplicationContext();
this.preset = preset;
}
@Override
public void onFrameDecoded(long ptsUs) {
ReceiverVideoGlHub.onFrameDecoded(appContext);
}
@Override
public void onConfigured(int width, int height) {
ReceiverVideoGlHub.refreshPreset(appContext);
}
@Override
public void onReleased() {
ReceiverVideoGlHub.release(appContext);
}
public ReceiverVideoPreset getPreset() {
return preset;
}
}

View File

@@ -0,0 +1,51 @@
package com.foxx.androidcast.receiver.av;
/**
* Lightweight clarity chain (no ML): DC/high-pass, noise gate, gentle presence, limiter.
* Aligns with receiver live path in {@code AV_QUALITY_QA_SESSION.md} §8A audio.
*/
public final class ImmersiveAudioFilter implements AudioFilter {
private float hpL;
private float hpR;
private float env;
private int channels;
@Override
public void reset(int sampleRate, int channels) {
this.channels = Math.max(1, channels);
hpL = hpR = env = 0f;
}
@Override
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
if (intensity <= 0 || sampleRate <= 0) {
return;
}
if (this.channels != channels) {
reset(sampleRate, channels);
}
float g = PcmAudioFilters.intensityGain(intensity);
float hpAlpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 90.0 / sampleRate));
float envAlpha = (float) (1.0 - Math.exp(-2.0 * Math.PI * 12.0 / sampleRate));
float gateThresh = 0.008f * (1f - 0.5f * g);
float presence = 0.15f * g;
float ceiling = 0.98f - 0.05f * g;
PcmAudioFilters.process16Le(pcm, bytes, channels, (sample, ch, frame) -> {
float hp = ch == 0 ? hpL : (ch == 1 ? hpR : hpL);
hp += hpAlpha * (sample - hp);
if (ch == 0) {
hpL = hp;
} else if (ch == 1) {
hpR = hp;
}
float x = sample - hp;
float ax = Math.abs(x);
env += envAlpha * (ax - env);
if (env < gateThresh) {
x *= 0.25f + 0.75f * (env / Math.max(gateThresh, 1e-6f));
}
float bright = x + presence * (x - hp);
return Math.max(-ceiling, Math.min(ceiling, bright * (1f + 0.08f * g)));
});
}
}

View File

@@ -0,0 +1,9 @@
package com.foxx.androidcast.receiver.av;
/** No-op audio filter (preset DEFAULT). */
public final class PassthroughAudioFilter implements AudioFilter {
@Override
public void process(byte[] pcm, int bytes, int sampleRate, int channels, int intensity) {
// passthrough
}
}

View File

@@ -0,0 +1,13 @@
package com.foxx.androidcast.receiver.av;
/** Direct decode→display Surface (preset DEFAULT). */
public final class PassthroughVideoPipeline implements ReceiverVideoPipeline {
static final PassthroughVideoPipeline INSTANCE = new PassthroughVideoPipeline();
private PassthroughVideoPipeline() {}
@Override
public void onFrameDecoded(long ptsUs) {
// Frame already on the display Surface.
}
}

View File

@@ -0,0 +1,43 @@
package com.foxx.androidcast.receiver.av;
/** Lightweight 16-bit PCM helpers for experimental receiver filters. */
final class PcmAudioFilters {
private PcmAudioFilters() {}
static float intensityGain(int intensity) {
return Math.max(0f, Math.min(10, intensity)) / 10f;
}
static void process16Le(byte[] pcm, int bytes, int channels, SampleProcessor proc) {
int frames = bytes / (2 * channels);
int off = 0;
for (int i = 0; i < frames; i++) {
for (int ch = 0; ch < channels; ch++) {
float s = readSample(pcm, off);
s = proc.process(s, ch, i);
writeSample(pcm, off, s);
off += 2;
}
}
}
static float readSample(byte[] pcm, int off) {
int lo = pcm[off] & 0xff;
int hi = pcm[off + 1];
int v = (hi << 8) | lo;
if (v > 32767) {
v -= 65536;
}
return v / 32768f;
}
static void writeSample(byte[] pcm, int off, float s) {
int v = Math.round(Math.max(-1f, Math.min(1f, s)) * 32767f);
pcm[off] = (byte) (v & 0xff);
pcm[off + 1] = (byte) ((v >> 8) & 0xff);
}
interface SampleProcessor {
float process(float sample, int channel, int frameIndex);
}
}

View File

@@ -0,0 +1,25 @@
package com.foxx.androidcast.receiver.av;
/** Receiver audio enhancement preset (developer / experimental). */
public enum ReceiverAudioPreset {
DEFAULT,
BASS_BOOST,
IMMERSIVE,
DOLBY_51,
DOLBY_71;
public static ReceiverAudioPreset fromName(String name, ReceiverAudioPreset fallback) {
if (name == null) {
return fallback;
}
try {
return valueOf(name.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return fallback;
}
}
public boolean supportsIntensity() {
return this != DEFAULT;
}
}

View File

@@ -0,0 +1,40 @@
package com.foxx.androidcast.receiver.av;
import android.content.Context;
import com.foxx.androidcast.AppPreferences;
public final class ReceiverAvPipelineFactory {
private ReceiverAvPipelineFactory() {}
public static ReceiverVideoPipeline createVideo(Context context) {
Context app = context.getApplicationContext();
ReceiverVideoPreset preset = AppPreferences.getReceiverVideoPreset(app);
switch (preset) {
case IMMERSIVE:
case HDR:
return new GlExperimentalVideoPipeline(app, preset);
case DEFAULT:
default:
return ReceiverVideoPipeline.passthrough();
}
}
public static AudioFilterChain createAudio(Context context) {
ReceiverAudioPreset preset = AppPreferences.getReceiverAudioPreset(context);
int intensity = AppPreferences.getReceiverAvIntensity(context);
switch (preset) {
case BASS_BOOST:
return new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, intensity);
case IMMERSIVE:
return new AudioFilterChain(new AudioFilter[] {new ImmersiveAudioFilter()}, intensity);
case DOLBY_51:
return new AudioFilterChain(new AudioFilter[] {new DolbySurroundAudioFilter(false)}, intensity);
case DOLBY_71:
return new AudioFilterChain(new AudioFilter[] {new DolbySurroundAudioFilter(true)}, intensity);
case DEFAULT:
default:
return AudioFilterChain.passthrough();
}
}
}

View File

@@ -0,0 +1,31 @@
package com.foxx.androidcast.receiver.av;
import android.content.Context;
/** Live receiver A/V pipelines (reloaded from developer settings). */
public final class ReceiverAvRuntime {
private static volatile ReceiverVideoPipeline videoPipeline = ReceiverVideoPipeline.passthrough();
private static volatile AudioFilterChain audioChain = AudioFilterChain.passthrough();
private ReceiverAvRuntime() {}
public static ReceiverVideoPipeline video() {
return videoPipeline;
}
public static AudioFilterChain audio() {
return audioChain;
}
public static void reload(Context context) {
Context app = context.getApplicationContext();
videoPipeline = ReceiverAvPipelineFactory.createVideo(app);
audioChain = ReceiverAvPipelineFactory.createAudio(app);
audioChain.reset(48_000, 2);
if (!ReceiverVideoGlHub.isGlMode(app)) {
ReceiverVideoGlHub.release(app);
} else {
ReceiverVideoGlHub.refreshPreset(app);
}
}
}

View File

@@ -0,0 +1,68 @@
package com.foxx.androidcast.receiver.av;
import android.content.Context;
import android.graphics.SurfaceTexture;
import android.view.Surface;
import com.foxx.androidcast.AppPreferences;
/** Routes decoder output through GLES when an experimental video preset is active. */
public final class ReceiverVideoGlHub {
private static final Object LOCK = new Object();
private static ReceiverVideoGlRenderer renderer;
private ReceiverVideoGlHub() {}
public static boolean isGlMode(Context context) {
ReceiverVideoPreset p = AppPreferences.getReceiverVideoPreset(context);
return p == ReceiverVideoPreset.IMMERSIVE || p == ReceiverVideoPreset.HDR;
}
/** @return Surface for {@code MediaCodec} / libvpx (decoder input). */
public static Surface bindDisplay(Context context, SurfaceTexture display, int width, int height) {
synchronized (LOCK) {
if (!isGlMode(context)) {
releaseLocked();
return new Surface(display);
}
if (renderer == null) {
renderer = new ReceiverVideoGlRenderer();
}
renderer.setPreset(
AppPreferences.getReceiverVideoPreset(context),
AppPreferences.getReceiverAvIntensity(context));
return renderer.bindDisplay(display, width, height);
}
}
public static void refreshPreset(Context context) {
synchronized (LOCK) {
if (renderer != null && isGlMode(context)) {
renderer.setPreset(
AppPreferences.getReceiverVideoPreset(context),
AppPreferences.getReceiverAvIntensity(context));
}
}
}
public static void onFrameDecoded(Context context) {
synchronized (LOCK) {
if (renderer != null && isGlMode(context)) {
renderer.onDecoderFrameAvailable();
}
}
}
public static void release(Context context) {
synchronized (LOCK) {
releaseLocked();
}
}
private static void releaseLocked() {
if (renderer != null) {
renderer.release();
renderer = null;
}
}
}

View File

@@ -0,0 +1,293 @@
package com.foxx.androidcast.receiver.av;
import android.graphics.SurfaceTexture;
import android.opengl.EGL14;
import android.opengl.EGLConfig;
import android.opengl.EGLContext;
import android.opengl.EGLDisplay;
import android.opengl.EGLSurface;
import android.opengl.GLES11Ext;
import android.opengl.GLES20;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.Surface;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
/**
* GLES2 bridge: decoder renders to an external-OES {@link SurfaceTexture}, then a preset shader
* draws into the {@link android.view.TextureView} display surface.
*/
final class ReceiverVideoGlRenderer {
private static final String TAG = "ReceiverVideoGl";
private static final int GL_TEXTURE_EXTERNAL_OES = 0x8D65;
private static final String VERT_OES =
"#extension GL_OES_EGL_image_external : require\n"
+ "attribute vec4 aPosition;\n"
+ "attribute vec2 aTexCoord;\n"
+ "varying vec2 vTexCoord;\n"
+ "void main() {\n"
+ " gl_Position = aPosition;\n"
+ " vTexCoord = aTexCoord;\n"
+ "}\n";
private static final String FRAG_OES =
"#extension GL_OES_EGL_image_external : require\n"
+ "precision mediump float;\n"
+ "varying vec2 vTexCoord;\n"
+ "uniform samplerExternalOES uTexture;\n"
+ "uniform float uIntensity;\n"
+ "uniform int uPreset;\n"
+ "void main() {\n"
+ " vec2 uv = vTexCoord;\n"
+ " vec4 src = texture2D(uTexture, uv);\n"
+ " vec3 c = src.rgb;\n"
+ " float t = clamp(uIntensity, 0.0, 1.0);\n"
+ " if (t <= 0.001) {\n"
+ " gl_FragColor = src;\n"
+ " return;\n"
+ " }\n"
+ " vec2 px = vec2(1.0 / 720.0, 1.0 / 1280.0);\n"
+ " vec3 n = texture2D(uTexture, uv + vec2(px.x, 0.0)).rgb;\n"
+ " vec3 s = texture2D(uTexture, uv - vec2(px.x, 0.0)).rgb;\n"
+ " vec3 e = texture2D(uTexture, uv + vec2(0.0, px.y)).rgb;\n"
+ " vec3 w = texture2D(uTexture, uv - vec2(0.0, px.y)).rgb;\n"
+ " vec3 blur = (n + s + e + w + c) * 0.2;\n"
+ " vec3 outCol;\n"
+ " if (uPreset == 1) {\n"
+ " vec3 hdr = c * (1.0 + 0.45 * t);\n"
+ " hdr = hdr / (hdr + vec3(0.55));\n"
+ " hdr = pow(hdr, vec3(0.9));\n"
+ " float luma = dot(hdr, vec3(0.299, 0.587, 0.114));\n"
+ " hdr = mix(vec3(luma), hdr, 1.0 + 0.3 * t);\n"
+ " outCol = mix(c, hdr, t);\n"
+ " } else {\n"
+ " vec3 denoise = mix(c, blur, 0.4 * t);\n"
+ " vec3 sharp = denoise + (denoise - blur) * (0.35 * t);\n"
+ " float luma = dot(sharp, vec3(0.299, 0.587, 0.114));\n"
+ " outCol = mix(vec3(luma), sharp, 1.0 + 0.08 * t);\n"
+ " outCol = mix(c, outCol, t);\n"
+ " }\n"
+ " gl_FragColor = vec4(clamp(outCol, 0.0, 1.0), src.a);\n"
+ "}\n";
private static final float[] QUAD = {
-1f, -1f, 0f, 1f,
1f, -1f, 1f, 1f,
-1f, 1f, 0f, 0f,
1f, 1f, 1f, 0f,
};
private final Handler mainHandler = new Handler(Looper.getMainLooper());
private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY;
private EGLContext eglContext = EGL14.EGL_NO_CONTEXT;
private EGLSurface outputEglSurface = EGL14.EGL_NO_SURFACE;
private int program;
private int oesTextureId;
private SurfaceTexture decoderTexture;
private Surface decoderSurface;
private SurfaceTexture displayTexture;
private int viewportW = 1;
private int viewportH = 1;
private volatile ReceiverVideoPreset preset = ReceiverVideoPreset.IMMERSIVE;
private volatile int intensity = 5;
private volatile boolean frameAvailable;
private FloatBuffer quadBuffer;
Surface bindDisplay(SurfaceTexture display, int width, int height) {
release();
displayTexture = display;
viewportW = Math.max(1, width);
viewportH = Math.max(1, height);
display.setDefaultBufferSize(viewportW, viewportH);
initEgl(new Surface(display));
createDecoderInput();
return decoderSurface;
}
void setPreset(ReceiverVideoPreset preset, int intensity) {
this.preset = preset != null ? preset : ReceiverVideoPreset.IMMERSIVE;
this.intensity = Math.max(0, Math.min(10, intensity));
}
void onDecoderFrameAvailable() {
frameAvailable = true;
drawFrameInternal();
}
void release() {
if (Looper.myLooper() == mainHandler.getLooper()) {
releaseOnGlThread();
} else {
mainHandler.post(this::releaseOnGlThread);
}
}
private void createDecoderInput() {
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
oesTextureId = textures[0];
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTextureId);
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
decoderTexture = new SurfaceTexture(oesTextureId);
decoderTexture.setDefaultBufferSize(viewportW, viewportH);
decoderTexture.setOnFrameAvailableListener(st -> onDecoderFrameAvailable(), mainHandler);
decoderSurface = new Surface(decoderTexture);
}
private void initEgl(Surface outputSurface) {
eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
throw new IllegalStateException("eglGetDisplay failed");
}
int[] version = new int[2];
if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
throw new IllegalStateException("eglInitialize failed");
}
EGLConfig config = chooseConfig(eglDisplay);
int[] ctxAttr = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE};
eglContext = EGL14.eglCreateContext(eglDisplay, config, EGL14.EGL_NO_CONTEXT, ctxAttr, 0);
outputEglSurface = EGL14.eglCreateWindowSurface(eglDisplay, config, outputSurface,
new int[] {EGL14.EGL_NONE}, 0);
if (!EGL14.eglMakeCurrent(eglDisplay, outputEglSurface, outputEglSurface, eglContext)) {
throw new IllegalStateException("eglMakeCurrent failed");
}
program = buildProgram(VERT_OES, FRAG_OES);
quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder())
.asFloatBuffer();
quadBuffer.put(QUAD).position(0);
GLES20.glViewport(0, 0, viewportW, viewportH);
Log.i(TAG, "GLES video bridge " + viewportW + "x" + viewportH);
}
private void drawFrameInternal() {
if (decoderTexture == null || outputEglSurface == EGL14.EGL_NO_SURFACE || !frameAvailable) {
return;
}
frameAvailable = false;
try {
if (!EGL14.eglMakeCurrent(eglDisplay, outputEglSurface, outputEglSurface, eglContext)) {
return;
}
decoderTexture.updateTexImage();
GLES20.glViewport(0, 0, viewportW, viewportH);
GLES20.glClearColor(0f, 0f, 0f, 1f);
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
GLES20.glUseProgram(program);
int aPos = GLES20.glGetAttribLocation(program, "aPosition");
int aTex = GLES20.glGetAttribLocation(program, "aTexCoord");
int uTex = GLES20.glGetUniformLocation(program, "uTexture");
int uInt = GLES20.glGetUniformLocation(program, "uIntensity");
int uPreset = GLES20.glGetUniformLocation(program, "uPreset");
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GL_TEXTURE_EXTERNAL_OES, oesTextureId);
GLES20.glUniform1i(uTex, 0);
GLES20.glUniform1f(uInt, intensity / 10f);
GLES20.glUniform1i(uPreset, preset == ReceiverVideoPreset.HDR ? 1 : 0);
quadBuffer.position(0);
GLES20.glEnableVertexAttribArray(aPos);
GLES20.glVertexAttribPointer(aPos, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
quadBuffer.position(2);
GLES20.glEnableVertexAttribArray(aTex);
GLES20.glVertexAttribPointer(aTex, 2, GLES20.GL_FLOAT, false, 16, quadBuffer);
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
GLES20.glDisableVertexAttribArray(aPos);
GLES20.glDisableVertexAttribArray(aTex);
EGL14.eglSwapBuffers(eglDisplay, outputEglSurface);
} catch (Exception e) {
Log.w(TAG, "drawFrame: " + e.getMessage());
}
}
private void releaseOnGlThread() {
if (decoderSurface != null) {
decoderSurface.release();
decoderSurface = null;
}
if (decoderTexture != null) {
decoderTexture.release();
decoderTexture = null;
}
if (oesTextureId != 0) {
int[] t = new int[] {oesTextureId};
GLES20.glDeleteTextures(1, t, 0);
oesTextureId = 0;
}
if (eglDisplay != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE,
EGL14.EGL_NO_CONTEXT);
if (outputEglSurface != EGL14.EGL_NO_SURFACE) {
EGL14.eglDestroySurface(eglDisplay, outputEglSurface);
outputEglSurface = EGL14.EGL_NO_SURFACE;
}
if (eglContext != EGL14.EGL_NO_CONTEXT) {
EGL14.eglDestroyContext(eglDisplay, eglContext);
eglContext = EGL14.EGL_NO_CONTEXT;
}
EGL14.eglTerminate(eglDisplay);
eglDisplay = EGL14.EGL_NO_DISPLAY;
}
displayTexture = null;
}
private static EGLConfig chooseConfig(EGLDisplay display) {
int[] attribs = {
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
EGL14.EGL_RED_SIZE, 8,
EGL14.EGL_GREEN_SIZE, 8,
EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_ALPHA_SIZE, 8,
EGL14.EGL_NONE
};
EGLConfig[] configs = new EGLConfig[1];
int[] num = new int[1];
if (!EGL14.eglChooseConfig(display, attribs, 0, configs, 0, 1, num, 0)) {
throw new IllegalStateException("eglChooseConfig failed");
}
return configs[0];
}
private static int buildProgram(String vert, String frag) {
int vs = compile(GLES20.GL_VERTEX_SHADER, vert);
int fs = compile(GLES20.GL_FRAGMENT_SHADER, frag);
int prog = GLES20.glCreateProgram();
GLES20.glAttachShader(prog, vs);
GLES20.glAttachShader(prog, fs);
GLES20.glLinkProgram(prog);
int[] link = new int[1];
GLES20.glGetProgramiv(prog, GLES20.GL_LINK_STATUS, link, 0);
if (link[0] == 0) {
String log = GLES20.glGetProgramInfoLog(prog);
GLES20.glDeleteProgram(prog);
throw new IllegalStateException("Program link failed: " + log);
}
GLES20.glDeleteShader(vs);
GLES20.glDeleteShader(fs);
return prog;
}
private static int compile(int type, String src) {
int shader = GLES20.glCreateShader(type);
GLES20.glShaderSource(shader, src);
GLES20.glCompileShader(shader);
int[] ok = new int[1];
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, ok, 0);
if (ok[0] == 0) {
String log = GLES20.glGetShaderInfoLog(shader);
GLES20.glDeleteShader(shader);
throw new IllegalStateException("Shader compile failed: " + log);
}
return shader;
}
}

View File

@@ -0,0 +1,18 @@
package com.foxx.androidcast.receiver.av;
/** Hook between video decoder output and display (direct Surface or GLES bridge). */
public interface ReceiverVideoPipeline {
/**
* Called after {@code releaseOutputBuffer(..., render=true)} (or libvpx render). For GLES
* experimental presets, triggers shader draw to the TextureView surface.
*/
void onFrameDecoded(long ptsUs);
default void onConfigured(int width, int height) {}
default void onReleased() {}
static ReceiverVideoPipeline passthrough() {
return PassthroughVideoPipeline.INSTANCE;
}
}

View File

@@ -0,0 +1,23 @@
package com.foxx.androidcast.receiver.av;
/** Receiver video enhancement preset (developer / experimental). */
public enum ReceiverVideoPreset {
DEFAULT,
IMMERSIVE,
HDR;
public boolean supportsIntensity() {
return this != DEFAULT;
}
public static ReceiverVideoPreset fromName(String name, ReceiverVideoPreset fallback) {
if (name == null) {
return fallback;
}
try {
return valueOf(name.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return fallback;
}
}
}

View File

@@ -181,17 +181,6 @@
android:text="@string/settings_section_receiver" android:text="@string/settings_section_receiver"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_display" />
<Spinner
android:id="@+id/spinner_receiver_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -38,6 +38,76 @@
android:layout_marginTop="12dp" android:layout_marginTop="12dp"
android:text="@string/dev_grab_session_stats" /> android:text="@string/dev_grab_session_stats" />
<CheckBox
android:id="@+id/check_receiver_adaptive_display"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_receiver_adaptive_display" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:text="@string/dev_av_presets_section"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_av_video_preset"
android:textSize="14sp" />
<Spinner
android:id="@+id/spinner_dev_video_preset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_av_audio_preset"
android:textSize="14sp" />
<Spinner
android:id="@+id/spinner_dev_audio_preset"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/label_dev_av_intensity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/dev_av_intensity"
android:textSize="14sp" />
<SeekBar
android:id="@+id/seek_dev_av_intensity"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:max="10"
android:progress="5" />
<TextView
android:id="@+id/text_dev_av_intensity_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/dev_av_presets_hint"
android:textSize="12sp" />
<Button <Button
android:id="@+id/btn_open_session_stats" android:id="@+id/btn_open_session_stats"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -239,17 +239,6 @@
android:text="@string/receiver_pip_hint" android:text="@string/receiver_pip_hint"
android:textSize="12sp" /> android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/label_receiver_display" />
<Spinner
android:id="@+id/spinner_receiver_resolution"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/section_app_info"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"

View File

@@ -217,6 +217,21 @@
<string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string> <string name="session_stats_chart_hint">Tap sessions to toggle overlay. Pinch or scroll wheel to zoom. Drag finger for values.</string>
<string name="dev_reload_codecs_json">Reload codecs.json</string> <string name="dev_reload_codecs_json">Reload codecs.json</string>
<string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string> <string name="dev_reload_codecs_json_hint">Loads assets/codecs.json overrides without restarting the app. Reopen the settings drawer to refresh spinners.</string>
<string name="dev_receiver_adaptive_display">Adaptive receiver display (experimental)</string>
<string name="dev_av_presets_section">Receiver A/V presets (experimental)</string>
<string name="dev_av_video_preset">Video preset</string>
<string name="dev_av_audio_preset">Audio preset</string>
<string name="dev_av_intensity">Filter intensity</string>
<string name="dev_av_intensity_value">Intensity: %1$d (0 = off, 10 = max)</string>
<string name="dev_av_presets_hint">Audio: PCM chain after AAC decode. Video experimental: GLES shader after decode (immersive denoise/sharpen, HDR tone map). Intensity 010. Restart cast session after preset changes.</string>
<string name="dev_av_video_default">Default (passthrough)</string>
<string name="dev_av_video_immersive">Immersive (experimental)</string>
<string name="dev_av_video_hdr">HDR (experimental)</string>
<string name="dev_av_audio_default">Default (passthrough)</string>
<string name="dev_av_audio_bass_boost">Bass boost</string>
<string name="dev_av_audio_immersive">Immersive</string>
<string name="dev_av_audio_dolby_51">Dolby 5.1 (stereo headset)</string>
<string name="dev_av_audio_dolby_71">Dolby 7.1 (stereo headset)</string>
<string name="ota_section_title">App updates (OTA)</string> <string name="ota_section_title">App updates (OTA)</string>
<string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string> <string name="ota_section_hint">v0 layout: https://host/v0/ota/channel/stable.json → manifest → APK. Service checks every 1 minute by default (overridable in private settings.json).</string>
<string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string> <string name="ota_manifest_url_hint">OTA channel URL (v0 stable.json)</string>

View File

@@ -0,0 +1,47 @@
package com.foxx.androidcast.receiver.av;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
public class ReceiverAvPipelineFactoryTest {
@Test
public void passthroughChainLeavesPcmUnchangedAtZeroIntensity() {
AudioFilterChain chain = new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, 0);
byte[] pcm = new byte[] {0, 0x40, 0, 0, (byte) 0x80, (byte) 0xFF, 0, 0};
byte[] copy = pcm.clone();
chain.process(copy, copy.length, 48_000, 2);
assertEquals(pcm[0], copy[0]);
assertEquals(pcm[1], copy[1]);
}
@Test
public void bassBoostChangesSamplesAtHighIntensity() {
AudioFilterChain chain = new AudioFilterChain(new AudioFilter[] {new BassBoostAudioFilter()}, 10);
chain.reset(48_000, 2);
byte[] pcm = new byte[4096];
for (int i = 0; i < pcm.length; i += 2) {
pcm[i] = (byte) (i & 0xff);
pcm[i + 1] = 0;
}
byte[] copy = pcm.clone();
chain.process(copy, copy.length, 48_000, 2);
boolean changed = false;
for (int i = 0; i < pcm.length; i++) {
if (pcm[i] != copy[i]) {
changed = true;
break;
}
}
org.junit.Assert.assertTrue(changed);
}
@Test
public void videoPassthroughOnFrameDecodedNoOp() {
ReceiverVideoPipeline p = ReceiverVideoPipeline.passthrough();
assertNotNull(p);
p.onFrameDecoded(1L);
}
}

103
backend_ops.txt Normal file
View File

@@ -0,0 +1,103 @@
Android Cast — crash reporter backend deployment (checklist)
================================================================
PUBLIC URL (example)
https://apps.f0xx.org/app/androidcast_project/crashes/
Upload API: .../crashes/api/upload.php
FILES ON VM
Deploy backend so web root is:
.../androidcast_project/backend/public/
Symlink is fine, e.g.:
backend -> android_cast/examples/crash_reporter/backend/
config: backend/config/config.php (copy from config.example.php)
DB (SQLite default): backend/data/crashes.sqlite
Writable dirs: backend/data/ backend/storage/
ALPINE + PHP-FPM 8.1 (required modules)
apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3
rc-service php-fpm81 restart
Verify FPM (not only CLI): php-fpm81 -m | grep -E 'session|pdo_sqlite|PDO'
If "session_name() undefined" -> php81-session missing or FPM not restarted
INIT DB (SQLite)
mkdir -p backend/data backend/storage
sqlite3 backend/data/crashes.sqlite < backend/sql/schema.sqlite.sql
chown -R www-data:www-data backend/data backend/storage # user = php-fpm pool user
config.php
base_path MUST match URL prefix exactly:
'base_path' => '/app/androidcast_project/crashes'
(empty base_path only for local: php -S -t public)
INNER NGINX (VM behind reverse proxy)
- ONE location for the app prefix only:
location ^~ /app/androidcast_project/crashes/ {
alias .../backend/public/;
try_files + nested .php -> php-fpm socket
}
- See: examples/crash_reporter/backend/nginx.vm.conf
- DO NOT mix proxy_*, try_files, and fastcgi in one broken /app/androidcast_project/ block
- DO NOT set root = androidcast_project/ for crash URLs (doubles path on disk)
- Optional redirects:
/app/androidcast_project/ -> /app/androidcast_project/crashes/
- fastcgi_pass: unix:/run/php-fpm.socket (match your install)
- client_max_body_size 4m (upload JSON)
- Every adb/php-fpm call: use </dev/null if testing from scripts (not required in nginx)
OUTER REVERSE PROXY (apps.f0xx.org -> artc0 VM)
- proxy_pass with full URI preserved
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- TLS terminate at outer nginx
ANDROID APP (device settings.json)
"crash": {
"enabled": "true",
"upload_url": "https://apps.f0xx.org/app/androidcast_project/crashes/api/upload.php",
"prune_after_upload": "true",
"upload_interval": "5m"
}
Enable "Send anonymous crash logs" in app settings.
SMOKE TESTS
curl -vk -H 'Host: apps.f0xx.org' \
https://127.0.0.1/app/androidcast_project/crashes/
curl -vk -H 'Host: apps.f0xx.org' -H 'Content-Type: application/json' \
-d '{"schema_version":1,"report_id":"test","generated_at_epoch_ms":1,"crash_type":"java","fingerprint":"x","device":{},"app":{}}' \
https://127.0.0.1/app/androidcast_project/crashes/api/upload.php
Web UI login (change in prod): admin / admin
COMMON MISTAKES
- 400 on GET /app/androidcast_project/ only -> health check wrong path; add redirect or ignore
- 500 session_name() -> install php81-session for FPM
- Location /crashes/ without /app/androidcast_project/ prefix -> no match behind full URI proxy
- "Primary script unknown" in nginx error log:
php-fpm got a SCRIPT_FILENAME that does not exist on disk
usual cause: fastcgi.conf + alias + try_files (wrong path)
fix: examples/crash_reporter/backend/nginx.vm.conf (explicit paths, fastcgi_params)
verify: ls .../backend/public/index.php
compare: php-fpm pool user can read that file
- Pending reports deleted before pull: prune_after_upload uploads then deletes on device
CURL WHERE
127.0.0.1 tests only ON the VM (loopback on that machine)
From laptop use: curl -vk https://apps.f0xx.org/app/androidcast_project/crashes/
-H Host only needed when URL is 127.0.0.1 but vhost is apps.f0xx.org
PULL REPORTS FROM DEVICE (laptop)
./pull_stats.sh (also pulls log_capture/crash_reports/<serial>/)
PROD HARDENING (when ready)
- Change admin password (users table password_hash)
- TLS only, restrict /api/upload.php by firewall if desired
- MariaDB: use sql/schema.mariadb.sql + db.driver mysql in config.php
- nginx/php-fpm error logs: check on 500, not only access log
REF IN REPO
examples/crash_reporter/backend/README.md
examples/crash_reporter/backend/nginx.vm.conf
docs/CRASH_REPORTER.md

View File

@@ -1,14 +1,16 @@
# Inner VM nginx — crash reporter behind apps.f0xx.org reverse proxy. # Inner VM nginx — crash reporter behind reverse proxy.
# #
# Files: .../androidcast_project/backend/public/ (symlink to examples/crash_reporter/backend/public) # Adjust CRASH_PUBLIC to match your deploy (symlink OK):
# URL: https://apps.f0xx.org/app/androidcast_project/crashes/ # /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public
# PHP: config.php → 'base_path' => '/app/androidcast_project/crashes'
# #
# Outer proxy must pass the full URI and Host (your apps.f0xx.org block already does). # URL prefix must match config.php base_path:
# /app/androidcast_project/crashes
# set this once if your install supports it, else replace paths below
# map is optional; explicit paths in each block are clearest
server { server {
listen 443 ssl; listen 443 ssl;
# server_name _;
ssl_certificate "/etc/letsencrypt/archive/apps.f0xx.org/fullchain1.pem"; ssl_certificate "/etc/letsencrypt/archive/apps.f0xx.org/fullchain1.pem";
ssl_certificate_key "/etc/letsencrypt/archive/apps.f0xx.org/privkey1.pem"; ssl_certificate_key "/etc/letsencrypt/archive/apps.f0xx.org/privkey1.pem";
@@ -16,32 +18,56 @@ server {
access_log /var/log/nginx/apps.intra.raptor.org.access_log main; access_log /var/log/nginx/apps.intra.raptor.org.access_log main;
error_log /var/log/nginx/apps.intra.raptor.org.error_log warn; error_log /var/log/nginx/apps.intra.raptor.org.error_log warn;
# Do NOT set root to androidcast_project/ — that doubles the /app/androidcast_project/ prefix on disk. # --- adjust only this directory root on disk ---
# CRASH_PUBLIC = .../backend/public
# --- Crash reporter (only this block handles the app) --- location = /app/androidcast_project/crashes {
location ^~ /app/androidcast_project/crashes/ { return 301 /app/androidcast_project/crashes/;
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/;
index index.php;
try_files $uri $uri/ @crash_php;
location ~ \.php$ {
include /etc/nginx/fastcgi.conf;
fastcgi_pass unix:/run/php-fpm.socket;
# $request_filename is correct when alias ends with / and location ends with /
fastcgi_param SCRIPT_FILENAME $request_filename;
client_max_body_size 4m;
}
} }
location @crash_php { # Directory URL -> index.php (avoids try_files + alias bugs)
include /etc/nginx/fastcgi.conf; location = /app/androidcast_project/crashes/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket; fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php; fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/crashes/index.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/index.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
location = /app/androidcast_project/crashes/api/upload.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/api/upload.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php;
fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m;
}
# Static assets (css/js)
location ^~ /app/androidcast_project/crashes/assets/ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/assets/;
}
# Any other .php under the prefix (if added later)
location ~ ^/app/androidcast_project/crashes/(.+\.php)$ {
alias /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public/$1;
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME $request_filename;
fastcgi_param REQUEST_URI $request_uri; fastcgi_param REQUEST_URI $request_uri;
client_max_body_size 4m; client_max_body_size 4m;
} }
# Optional: parent path → crashes (stops 400 probes on /app/androidcast_project/)
location = /app/androidcast_project { location = /app/androidcast_project {
return 301 /app/androidcast_project/crashes/; return 301 /app/androidcast_project/crashes/;
} }
@@ -49,14 +75,17 @@ server {
return 301 /app/androidcast_project/crashes/; return 301 /app/androidcast_project/crashes/;
} }
# Other PHP on this host (outside crash app)
location ~ \.php$ {
root /var/www/localhost/htdocs/apps/app/androidcast_project;
include /etc/nginx/fastcgi.conf;
fastcgi_pass unix:/run/php-fpm.socket;
}
location ~ /\. { location ~ /\. {
deny all; deny all;
} }
} }
# WHY "Primary script unknown":
# - include fastcgi.conf often sets SCRIPT_FILENAME=$document_root$fastcgi_script_name
# - That breaks under "alias" and subpath URLs -> php-fpm looks for a file that does not exist
# - Fix: use fastcgi_params + explicit SCRIPT_FILENAME absolute path (above)
#
# Verify on VM:
# ls -la /var/www/.../backend/public/index.php
# ls -la /var/www/.../backend/public/api/upload.php
# sudo -u nginx test -r .../index.php # user = php-fpm pool user