mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +03:00
snap
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# Android Cast (POC)
|
# Android Cast (POC)
|
||||||
|
|
||||||
|
**Roadmap & alpha scope:** [docs/ROADMAP.md](docs/ROADMAP.md) · USB-C/HDMI: [docs/USB_HDMI_CAST.md](docs/USB_HDMI_CAST.md) · Commercial (dev-gated): [docs/COMMERCIAL.md](docs/COMMERCIAL.md)
|
||||||
|
|
||||||
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 **TCP or UDP** to a receiver that decodes and plays back.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ dependencies {
|
|||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
|
||||||
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5'
|
implementation 'org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5'
|
||||||
|
implementation 'com.android.billingclient:billing:7.1.1'
|
||||||
|
implementation 'com.google.android.play:review:2.0.2'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ package com.foxx.androidcast;
|
|||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.commercial.CommercialFeatures;
|
||||||
import com.foxx.androidcast.crash.CrashReporter;
|
import com.foxx.androidcast.crash.CrashReporter;
|
||||||
|
import com.foxx.androidcast.display.WiredDisplayMonitor;
|
||||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||||
|
import com.foxx.androidcast.network.CastTransportFactory;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
|
||||||
|
|
||||||
/** Applies saved theme and locale at process start. */
|
/** Applies saved theme and locale at process start. */
|
||||||
@@ -33,5 +36,8 @@ public class AndroidCastApplication extends Application {
|
|||||||
CastThemeHelper.applyNightMode(this);
|
CastThemeHelper.applyNightMode(this);
|
||||||
CrashReporter.install(this);
|
CrashReporter.install(this);
|
||||||
ReceiverAvRuntime.reload(this);
|
ReceiverAvRuntime.reload(this);
|
||||||
|
CastTransportFactory.init(this);
|
||||||
|
WiredDisplayMonitor.getInstance(this).ensureRegistered();
|
||||||
|
CommercialFeatures.refreshFromPreferences(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import android.os.Build;
|
|||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||||
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
import com.foxx.androidcast.receiver.av.ReceiverVideoPreset;
|
||||||
|
|
||||||
@@ -49,6 +50,13 @@ public final class AppPreferences {
|
|||||||
private static final String KEY_OTA_DISMISSED_VERSION = "ota_dismissed_version_code";
|
private static final String KEY_OTA_DISMISSED_VERSION = "ota_dismissed_version_code";
|
||||||
private static final String KEY_OTA_ROUND_ROBIN_INDEX = "ota_round_robin_index";
|
private static final String KEY_OTA_ROUND_ROBIN_INDEX = "ota_round_robin_index";
|
||||||
|
|
||||||
|
private static final String KEY_DEV_WIRED_DISPLAY_HINTS = "dev_wired_display_hints";
|
||||||
|
private static final String KEY_DEV_USB_TETHER_TRANSPORT = "dev_usb_tether_transport";
|
||||||
|
private static final String KEY_DEV_EXTERNAL_CAPTURE_POLICY = "dev_external_capture_policy";
|
||||||
|
private static final String KEY_DEV_COMMERCIAL_ADS = "dev_commercial_ads";
|
||||||
|
private static final String KEY_DEV_COMMERCIAL_IAP = "dev_commercial_iap";
|
||||||
|
private static final String KEY_DEV_COMMERCIAL_PLAY_STORE = "dev_commercial_play_store";
|
||||||
|
|
||||||
/** Minimum interval between automatic OTA checks on app launch. */
|
/** Minimum interval between automatic OTA checks on app launch. */
|
||||||
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
public static final long OTA_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000L;
|
||||||
|
|
||||||
@@ -306,6 +314,59 @@ public final class AppPreferences {
|
|||||||
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, enabled).apply();
|
prefs(context).edit().putBoolean(KEY_DEV_RECEIVER_ADAPTIVE_DISPLAY, enabled).apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isDevWiredDisplayHintsEnabled(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_WIRED_DISPLAY_HINTS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevWiredDisplayHintsEnabled(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_WIRED_DISPLAY_HINTS, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDevUsbTetherTransportEnabled(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_USB_TETHER_TRANSPORT, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevUsbTetherTransportEnabled(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_USB_TETHER_TRANSPORT, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExternalDisplayCapturePolicy getExternalDisplayCapturePolicy(Context context) {
|
||||||
|
return ExternalDisplayCapturePolicy.fromName(
|
||||||
|
prefs(context).getString(KEY_DEV_EXTERNAL_CAPTURE_POLICY, null),
|
||||||
|
ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setExternalDisplayCapturePolicy(Context context, ExternalDisplayCapturePolicy policy) {
|
||||||
|
prefs(context).edit()
|
||||||
|
.putString(KEY_DEV_EXTERNAL_CAPTURE_POLICY,
|
||||||
|
policy != null ? policy.name() : ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY.name())
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDevCommercialAdsEnabled(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_COMMERCIAL_ADS, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevCommercialAdsEnabled(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_ADS, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDevCommercialIapEnabled(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_COMMERCIAL_IAP, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevCommercialIapEnabled(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_IAP, enabled).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isDevCommercialPlayStoreEnabled(Context context) {
|
||||||
|
return prefs(context).getBoolean(KEY_DEV_COMMERCIAL_PLAY_STORE, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setDevCommercialPlayStoreEnabled(Context context, boolean enabled) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_DEV_COMMERCIAL_PLAY_STORE, 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);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public final class CastConfig {
|
|||||||
public static final String TRANSPORT_UDP = "udp";
|
public static final String TRANSPORT_UDP = "udp";
|
||||||
public static final String TRANSPORT_QUIC = "quic";
|
public static final String TRANSPORT_QUIC = "quic";
|
||||||
public static final String TRANSPORT_WEBRTC = "webrtc";
|
public static final String TRANSPORT_WEBRTC = "webrtc";
|
||||||
|
/** USB tether / ADB reverse — stub until roadmap E is implemented. */
|
||||||
|
public static final String TRANSPORT_USB_TETHER = "usb_tether";
|
||||||
/** Default cast transport (UDP); TCP/QUIC remain selectable in settings. */
|
/** Default cast transport (UDP); TCP/QUIC remain selectable in settings. */
|
||||||
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
|
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
|
||||||
/** Dedicated port for experimental QUIC transport (UDP/TCP framed). */
|
/** Dedicated port for experimental QUIC transport (UDP/TCP framed). */
|
||||||
|
|||||||
@@ -155,16 +155,20 @@ public final class CastSettingsBinder {
|
|||||||
private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId,
|
private static void bindTransport(AppCompatActivity activity, CastSettings settings, int spinnerId,
|
||||||
boolean unused) {
|
boolean unused) {
|
||||||
Spinner spinner = activity.findViewById(spinnerId);
|
Spinner spinner = activity.findViewById(spinnerId);
|
||||||
String[] labels = {
|
java.util.ArrayList<String> labelList = new java.util.ArrayList<>();
|
||||||
activity.getString(R.string.transport_udp),
|
java.util.ArrayList<String> valueList = new java.util.ArrayList<>();
|
||||||
activity.getString(R.string.transport_tcp),
|
labelList.add(activity.getString(R.string.transport_udp));
|
||||||
activity.getString(R.string.transport_quic)
|
valueList.add(CastConfig.TRANSPORT_UDP);
|
||||||
};
|
labelList.add(activity.getString(R.string.transport_tcp));
|
||||||
String[] values = {
|
valueList.add(CastConfig.TRANSPORT_TCP);
|
||||||
CastConfig.TRANSPORT_UDP,
|
labelList.add(activity.getString(R.string.transport_quic));
|
||||||
CastConfig.TRANSPORT_TCP,
|
valueList.add(CastConfig.TRANSPORT_QUIC);
|
||||||
CastConfig.TRANSPORT_QUIC
|
if (AppPreferences.isDevUsbTetherTransportEnabled(activity)) {
|
||||||
};
|
labelList.add(activity.getString(R.string.transport_usb_tether));
|
||||||
|
valueList.add(CastConfig.TRANSPORT_USB_TETHER);
|
||||||
|
}
|
||||||
|
String[] labels = labelList.toArray(new String[0]);
|
||||||
|
String[] values = valueList.toArray(new String[0]);
|
||||||
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
bindStringSpinner(spinner, labels, values, settings.getTransport(), v -> {
|
||||||
settings.setTransport(v);
|
settings.setTransport(v);
|
||||||
refreshStreamProtectionUi(activity, settings);
|
refreshStreamProtectionUi(activity, settings);
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import android.widget.Toast;
|
|||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.commercial.CommercialFeatures;
|
||||||
|
import com.foxx.androidcast.commercial.InAppPurchaseCoordinator;
|
||||||
|
import com.foxx.androidcast.commercial.PlayStoreIntegration;
|
||||||
|
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||||
|
import com.foxx.androidcast.display.WiredDisplayMonitor;
|
||||||
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;
|
||||||
@@ -83,6 +88,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bindAvPresets();
|
bindAvPresets();
|
||||||
|
bindWiredDisplaySection();
|
||||||
|
bindCommercialSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -104,6 +111,98 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
|||||||
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();
|
bindAvPresets();
|
||||||
|
bindWiredDisplaySection();
|
||||||
|
bindCommercialSection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindWiredDisplaySection() {
|
||||||
|
TextView status = findViewById(R.id.text_wired_display_status);
|
||||||
|
if (status != null) {
|
||||||
|
status.setText(WiredDisplayMonitor.getInstance(this).getSummaryLine());
|
||||||
|
}
|
||||||
|
CheckBox hints = findViewById(R.id.check_wired_display_hints);
|
||||||
|
bindDeveloperCheckbox(hints, AppPreferences.isDevWiredDisplayHintsEnabled(this),
|
||||||
|
AppPreferences::setDevWiredDisplayHintsEnabled);
|
||||||
|
|
||||||
|
Spinner policySpinner = findViewById(R.id.spinner_external_capture_policy);
|
||||||
|
TextView policyNote = findViewById(R.id.text_external_capture_policy_note);
|
||||||
|
if (policySpinner != null) {
|
||||||
|
String[] labels = {
|
||||||
|
getString(R.string.dev_capture_policy_default),
|
||||||
|
getString(R.string.dev_capture_policy_prefer_external),
|
||||||
|
getString(R.string.dev_capture_policy_external_experimental)
|
||||||
|
};
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, labels);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
policySpinner.setAdapter(adapter);
|
||||||
|
policySpinner.setSelection(AppPreferences.getExternalDisplayCapturePolicy(this).ordinal());
|
||||||
|
policySpinner.setOnItemSelectedListener(simpleSelection(() -> {
|
||||||
|
ExternalDisplayCapturePolicy p = ExternalDisplayCapturePolicy.values()[
|
||||||
|
policySpinner.getSelectedItemPosition()];
|
||||||
|
AppPreferences.setExternalDisplayCapturePolicy(this, p);
|
||||||
|
if (policyNote != null) {
|
||||||
|
policyNote.setText(p.implementationNote());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (policyNote != null) {
|
||||||
|
policyNote.setText(AppPreferences.getExternalDisplayCapturePolicy(this).implementationNote());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox usbTether = findViewById(R.id.check_usb_tether_transport);
|
||||||
|
bindDeveloperCheckbox(usbTether, AppPreferences.isDevUsbTetherTransportEnabled(this),
|
||||||
|
AppPreferences::setDevUsbTetherTransportEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindCommercialSection() {
|
||||||
|
CheckBox ads = findViewById(R.id.check_commercial_ads);
|
||||||
|
CheckBox iap = findViewById(R.id.check_commercial_iap);
|
||||||
|
CheckBox play = findViewById(R.id.check_commercial_play_store);
|
||||||
|
bindDeveloperCheckbox(ads, AppPreferences.isDevCommercialAdsEnabled(this), (ctx, on) -> {
|
||||||
|
AppPreferences.setDevCommercialAdsEnabled(ctx, on);
|
||||||
|
CommercialFeatures.refreshFromPreferences(ctx);
|
||||||
|
});
|
||||||
|
bindDeveloperCheckbox(iap, AppPreferences.isDevCommercialIapEnabled(this), (ctx, on) -> {
|
||||||
|
AppPreferences.setDevCommercialIapEnabled(ctx, on);
|
||||||
|
CommercialFeatures.refreshFromPreferences(ctx);
|
||||||
|
});
|
||||||
|
bindDeveloperCheckbox(play, AppPreferences.isDevCommercialPlayStoreEnabled(this), (ctx, on) -> {
|
||||||
|
AppPreferences.setDevCommercialPlayStoreEnabled(ctx, on);
|
||||||
|
CommercialFeatures.refreshFromPreferences(ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
Button listing = findViewById(R.id.btn_open_play_store_listing);
|
||||||
|
if (listing != null) {
|
||||||
|
listing.setOnClickListener(v -> {
|
||||||
|
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(this)) {
|
||||||
|
Toast.makeText(this, R.string.dev_commercial_enable_play_first, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayStoreIntegration.getInstance(this).openAppListing(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Button review = findViewById(R.id.btn_request_play_review);
|
||||||
|
if (review != null) {
|
||||||
|
review.setOnClickListener(v -> {
|
||||||
|
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(this)) {
|
||||||
|
Toast.makeText(this, R.string.dev_commercial_enable_play_first, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayStoreIntegration.getInstance(this).requestInAppReviewIfAvailable(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Button queryIap = findViewById(R.id.btn_query_iap_products);
|
||||||
|
if (queryIap != null) {
|
||||||
|
queryIap.setOnClickListener(v -> {
|
||||||
|
if (!CommercialFeatures.isInAppPurchaseEnabled(this)) {
|
||||||
|
Toast.makeText(this, R.string.dev_commercial_enable_iap_first, Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
InAppPurchaseCoordinator.getInstance(this).queryPremiumProduct(() ->
|
||||||
|
Toast.makeText(this, R.string.dev_iap_query_done, Toast.LENGTH_SHORT).show());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bindAvPresets() {
|
private void bindAvPresets() {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.foxx.androidcast.commercial;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.AppPreferences;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates ads, in-app purchases, and Play Store integration. All off until enabled in
|
||||||
|
* developer settings (commercial release prep).
|
||||||
|
*/
|
||||||
|
public final class CommercialFeatures {
|
||||||
|
private CommercialFeatures() {}
|
||||||
|
|
||||||
|
public static boolean isAdsEnabled(Context context) {
|
||||||
|
return AppPreferences.isDevCommercialAdsEnabled(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isInAppPurchaseEnabled(Context context) {
|
||||||
|
return AppPreferences.isDevCommercialIapEnabled(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isPlayStoreIntegrationEnabled(Context context) {
|
||||||
|
return AppPreferences.isDevCommercialPlayStoreEnabled(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isAnyCommercialFeatureEnabled(Context context) {
|
||||||
|
return isAdsEnabled(context)
|
||||||
|
|| isInAppPurchaseEnabled(context)
|
||||||
|
|| isPlayStoreIntegrationEnabled(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazy-init billing / ads / review helpers when flags are turned on. */
|
||||||
|
public static void refreshFromPreferences(Context context) {
|
||||||
|
Context app = context.getApplicationContext();
|
||||||
|
if (isInAppPurchaseEnabled(app)) {
|
||||||
|
InAppPurchaseCoordinator.getInstance(app).ensureInitialized();
|
||||||
|
} else {
|
||||||
|
InAppPurchaseCoordinator.getInstance(app).shutdown();
|
||||||
|
}
|
||||||
|
if (isAdsEnabled(app)) {
|
||||||
|
InAppAdCoordinator.getInstance(app).ensureInitialized();
|
||||||
|
} else {
|
||||||
|
InAppAdCoordinator.getInstance(app).shutdown();
|
||||||
|
}
|
||||||
|
if (isPlayStoreIntegrationEnabled(app)) {
|
||||||
|
PlayStoreIntegration.getInstance(app).ensureInitialized();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.foxx.androidcast.commercial;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-app ads placeholder. No AdMob SDK wired yet — when {@link CommercialFeatures#isAdsEnabled}
|
||||||
|
* is true, logs and reserves UI hooks for a future banner/interstitial integration.
|
||||||
|
*/
|
||||||
|
public final class InAppAdCoordinator {
|
||||||
|
private static final String TAG = "InAppAdCoordinator";
|
||||||
|
private static volatile InAppAdCoordinator instance;
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private boolean initialized;
|
||||||
|
|
||||||
|
public static InAppAdCoordinator getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (InAppAdCoordinator.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new InAppAdCoordinator(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InAppAdCoordinator(Context appContext) {
|
||||||
|
this.appContext = appContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ensureInitialized() {
|
||||||
|
if (!CommercialFeatures.isAdsEnabled(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
Log.i(TAG, "Ads enabled (developer) — integrate AdMob App ID in manifest when ready.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach a placeholder banner container (no network ads until SDK + unit IDs are added). */
|
||||||
|
public void attachBannerPlaceholder(Activity activity, ViewGroup container) {
|
||||||
|
if (!CommercialFeatures.isAdsEnabled(appContext) || container == null) {
|
||||||
|
if (container != null) {
|
||||||
|
container.setVisibility(ViewGroup.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureInitialized();
|
||||||
|
container.setVisibility(ViewGroup.VISIBLE);
|
||||||
|
if (container.getChildCount() == 0) {
|
||||||
|
android.widget.TextView hint = new android.widget.TextView(activity);
|
||||||
|
hint.setText(R.string.commercial_ads_placeholder);
|
||||||
|
hint.setPadding(24, 16, 24, 16);
|
||||||
|
container.addView(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.foxx.androidcast.commercial;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.android.billingclient.api.BillingClient;
|
||||||
|
import com.android.billingclient.api.BillingClientStateListener;
|
||||||
|
import com.android.billingclient.api.BillingResult;
|
||||||
|
import com.android.billingclient.api.PendingPurchasesParams;
|
||||||
|
import com.android.billingclient.api.Purchase;
|
||||||
|
import com.android.billingclient.api.PurchasesUpdatedListener;
|
||||||
|
import com.android.billingclient.api.QueryProductDetailsParams;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Play Billing wrapper — connects only when developer settings enable IAP.
|
||||||
|
* Product IDs are placeholders until Play Console SKUs exist.
|
||||||
|
*/
|
||||||
|
public final class InAppPurchaseCoordinator implements PurchasesUpdatedListener {
|
||||||
|
private static final String TAG = "InAppPurchaseCoordinator";
|
||||||
|
|
||||||
|
/** Example SKU — create matching in-app product in Play Console before release. */
|
||||||
|
public static final String SKU_PREMIUM = "androidcast_premium";
|
||||||
|
|
||||||
|
private static volatile InAppPurchaseCoordinator instance;
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private BillingClient billingClient;
|
||||||
|
private boolean initialized;
|
||||||
|
|
||||||
|
public static InAppPurchaseCoordinator getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (InAppPurchaseCoordinator.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new InAppPurchaseCoordinator(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InAppPurchaseCoordinator(Context appContext) {
|
||||||
|
this.appContext = appContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ensureInitialized() {
|
||||||
|
if (!CommercialFeatures.isInAppPurchaseEnabled(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialized && billingClient != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
billingClient = BillingClient.newBuilder(appContext)
|
||||||
|
.setListener(this)
|
||||||
|
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
||||||
|
.build();
|
||||||
|
billingClient.startConnection(new BillingClientStateListener() {
|
||||||
|
@Override
|
||||||
|
public void onBillingSetupFinished(BillingResult billingResult) {
|
||||||
|
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
||||||
|
initialized = true;
|
||||||
|
Log.i(TAG, "Billing connected (developer IAP enabled)");
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Billing setup: " + billingResult.getDebugMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBillingServiceDisconnected() {
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void shutdown() {
|
||||||
|
if (billingClient != null) {
|
||||||
|
billingClient.endConnection();
|
||||||
|
billingClient = null;
|
||||||
|
}
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isReady() {
|
||||||
|
return initialized && billingClient != null && billingClient.isReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query placeholder SKU for developer testing (no UI yet). */
|
||||||
|
public void queryPremiumProduct(Runnable onDone) {
|
||||||
|
if (!isReady()) {
|
||||||
|
if (onDone != null) {
|
||||||
|
onDone.run();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QueryProductDetailsParams.Product product = QueryProductDetailsParams.Product.newBuilder()
|
||||||
|
.setProductId(SKU_PREMIUM)
|
||||||
|
.setProductType(BillingClient.ProductType.INAPP)
|
||||||
|
.build();
|
||||||
|
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
|
||||||
|
.setProductList(Collections.singletonList(product))
|
||||||
|
.build();
|
||||||
|
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
|
||||||
|
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK
|
||||||
|
&& productDetailsList != null && !productDetailsList.isEmpty()) {
|
||||||
|
Log.i(TAG, "Product available: " + SKU_PREMIUM);
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Product query: " + billingResult.getDebugMessage());
|
||||||
|
}
|
||||||
|
if (onDone != null) {
|
||||||
|
onDone.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
|
||||||
|
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK || purchases == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Purchase purchase : purchases) {
|
||||||
|
Log.i(TAG, "Purchase state: " + purchase.getPurchaseState() + " products=" + purchase.getProducts());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.foxx.androidcast.commercial;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.ActivityNotFoundException;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.google.android.play.core.review.ReviewInfo;
|
||||||
|
import com.google.android.play.core.review.ReviewManager;
|
||||||
|
import com.google.android.play.core.review.ReviewManagerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play Store listing and in-app review — active only when developer settings enable Play integration.
|
||||||
|
*/
|
||||||
|
public final class PlayStoreIntegration {
|
||||||
|
private static final String TAG = "PlayStoreIntegration";
|
||||||
|
private static volatile PlayStoreIntegration instance;
|
||||||
|
|
||||||
|
private final Context appContext;
|
||||||
|
private ReviewManager reviewManager;
|
||||||
|
private boolean initialized;
|
||||||
|
|
||||||
|
public static PlayStoreIntegration getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (PlayStoreIntegration.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new PlayStoreIntegration(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayStoreIntegration(Context appContext) {
|
||||||
|
this.appContext = appContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ensureInitialized() {
|
||||||
|
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reviewManager = ReviewManagerFactory.create(appContext);
|
||||||
|
initialized = true;
|
||||||
|
Log.i(TAG, "Play Store integration enabled (developer)");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openAppListing(Activity activity) {
|
||||||
|
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String pkg = appContext.getPackageName();
|
||||||
|
try {
|
||||||
|
activity.startActivity(new Intent(Intent.ACTION_VIEW,
|
||||||
|
Uri.parse("market://details?id=" + pkg)));
|
||||||
|
} catch (ActivityNotFoundException e) {
|
||||||
|
activity.startActivity(new Intent(Intent.ACTION_VIEW,
|
||||||
|
Uri.parse("https://play.google.com/store/apps/details?id=" + pkg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Launch Google Play in-app review flow when available (test via internal track). */
|
||||||
|
public void requestInAppReviewIfAvailable(Activity activity) {
|
||||||
|
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext) || reviewManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reviewManager.requestReviewFlow().addOnCompleteListener(task -> {
|
||||||
|
if (!task.isSuccessful()) {
|
||||||
|
Log.w(TAG, "Review flow request failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ReviewInfo reviewInfo = task.getResult();
|
||||||
|
reviewManager.launchReviewFlow(activity, reviewInfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.foxx.androidcast.display;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Developer-only preference for which display MediaProjection should target once
|
||||||
|
* multi-display capture is implemented (roadmap D).
|
||||||
|
*/
|
||||||
|
public enum ExternalDisplayCapturePolicy {
|
||||||
|
/** Current production behavior: default display only. */
|
||||||
|
DEFAULT_DISPLAY_ONLY,
|
||||||
|
/**
|
||||||
|
* When an external display is connected, prefer capturing it (not implemented —
|
||||||
|
* falls back to default until wired capture lands).
|
||||||
|
*/
|
||||||
|
PREFER_EXTERNAL_WHEN_PRESENT,
|
||||||
|
/**
|
||||||
|
* Explicit secondary-display capture (future; requires API 34+ rules review).
|
||||||
|
*/
|
||||||
|
EXTERNAL_DISPLAY_EXPERIMENTAL;
|
||||||
|
|
||||||
|
public static ExternalDisplayCapturePolicy fromName(String name, ExternalDisplayCapturePolicy fallback) {
|
||||||
|
if (name == null || name.isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return valueOf(name);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable status for developer settings. */
|
||||||
|
public String implementationNote() {
|
||||||
|
switch (this) {
|
||||||
|
case PREFER_EXTERNAL_WHEN_PRESENT:
|
||||||
|
return "Planned: auto-route capture to HDMI when present (not active).";
|
||||||
|
case EXTERNAL_DISPLAY_EXPERIMENTAL:
|
||||||
|
return "Planned: explicit secondary-display VirtualDisplay (not active).";
|
||||||
|
case DEFAULT_DISPLAY_ONLY:
|
||||||
|
default:
|
||||||
|
return "Active: captures the default display (standard cast).";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package com.foxx.androidcast.display;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.hardware.display.DisplayManager;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.DisplayMetrics;
|
||||||
|
import android.view.Display;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks wired / external displays (USB-C HDMI, presentation, etc.) for roadmap D.
|
||||||
|
* Does not perform capture — informs UI and future capture routing.
|
||||||
|
*/
|
||||||
|
public final class WiredDisplayMonitor implements DisplayManager.DisplayListener {
|
||||||
|
|
||||||
|
public static final class ExternalDisplayInfo {
|
||||||
|
public final int displayId;
|
||||||
|
public final String name;
|
||||||
|
public final int widthPx;
|
||||||
|
public final int heightPx;
|
||||||
|
public final int flags;
|
||||||
|
|
||||||
|
ExternalDisplayInfo(Display display) {
|
||||||
|
displayId = display.getDisplayId();
|
||||||
|
name = display.getName() != null ? display.getName() : ("Display " + displayId);
|
||||||
|
DisplayMetrics m = new DisplayMetrics();
|
||||||
|
display.getRealMetrics(m);
|
||||||
|
widthPx = m.widthPixels;
|
||||||
|
heightPx = m.heightPixels;
|
||||||
|
flags = display.getFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean likelyHdmiOrWired() {
|
||||||
|
return (flags & Display.FLAG_PRESENTATION) != 0
|
||||||
|
|| (flags & Display.FLAG_SECURE) != 0
|
||||||
|
|| displayId != Display.DEFAULT_DISPLAY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface Listener {
|
||||||
|
void onWiredDisplaysChanged(List<ExternalDisplayInfo> externalDisplays);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static volatile WiredDisplayMonitor instance;
|
||||||
|
|
||||||
|
private final DisplayManager displayManager;
|
||||||
|
private final Handler mainHandler;
|
||||||
|
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||||
|
private volatile List<ExternalDisplayInfo> externalDisplays = Collections.emptyList();
|
||||||
|
private boolean registered;
|
||||||
|
|
||||||
|
public static WiredDisplayMonitor getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
synchronized (WiredDisplayMonitor.class) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new WiredDisplayMonitor(context.getApplicationContext());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WiredDisplayMonitor(Context context) {
|
||||||
|
displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
|
||||||
|
mainHandler = new Handler(Looper.getMainLooper());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addListener(Listener listener) {
|
||||||
|
if (listener != null) {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener.onWiredDisplaysChanged(externalDisplays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeListener(Listener listener) {
|
||||||
|
listeners.remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void ensureRegistered() {
|
||||||
|
if (registered || displayManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
displayManager.registerDisplayListener(this, mainHandler);
|
||||||
|
registered = true;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void unregister() {
|
||||||
|
if (!registered || displayManager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
displayManager.unregisterDisplayListener(this);
|
||||||
|
registered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ExternalDisplayInfo> getExternalDisplays() {
|
||||||
|
return externalDisplays;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasExternalDisplay() {
|
||||||
|
return !externalDisplays.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSummaryLine() {
|
||||||
|
if (externalDisplays.isEmpty()) {
|
||||||
|
return "No external display detected (USB-C HDMI will not work until OS sees a second screen).";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append(externalDisplays.size()).append(" external display(s): ");
|
||||||
|
for (int i = 0; i < externalDisplays.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append("; ");
|
||||||
|
}
|
||||||
|
ExternalDisplayInfo info = externalDisplays.get(i);
|
||||||
|
sb.append(info.name).append(" ").append(info.widthPx).append("×").append(info.heightPx);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisplayAdded(int displayId) {
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisplayRemoved(int displayId) {
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDisplayChanged(int displayId) {
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
List<ExternalDisplayInfo> next = scan();
|
||||||
|
externalDisplays = next;
|
||||||
|
for (Listener l : listeners) {
|
||||||
|
l.onWiredDisplaysChanged(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ExternalDisplayInfo> scan() {
|
||||||
|
if (displayManager == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
Display[] displays = displayManager.getDisplays();
|
||||||
|
if (displays == null || displays.length == 0) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<ExternalDisplayInfo> out = new ArrayList<>();
|
||||||
|
for (Display d : displays) {
|
||||||
|
if (d == null || d.getDisplayId() == Display.DEFAULT_DISPLAY) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.add(new ExternalDisplayInfo(d));
|
||||||
|
}
|
||||||
|
return Collections.unmodifiableList(out);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import android.content.Context;
|
|||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
import com.foxx.androidcast.CastSettings;
|
import com.foxx.androidcast.CastSettings;
|
||||||
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
|
||||||
|
import com.foxx.androidcast.network.usb.UsbTetherTransportFactory;
|
||||||
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
|
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -42,6 +43,9 @@ public final class CastTransportFactory {
|
|||||||
if (WebRtcTransportFactory.isTransportId(mode)) {
|
if (WebRtcTransportFactory.isTransportId(mode)) {
|
||||||
return WebRtcTransportFactory.create();
|
return WebRtcTransportFactory.create();
|
||||||
}
|
}
|
||||||
|
if (UsbTetherTransportFactory.isTransportId(mode)) {
|
||||||
|
return UsbTetherTransportFactory.create();
|
||||||
|
}
|
||||||
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
|
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
|
||||||
UdpCastTransport udp = new UdpCastTransport();
|
UdpCastTransport udp = new UdpCastTransport();
|
||||||
applyUdpProtectionDeferred(udp);
|
applyUdpProtectionDeferred(udp);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.foxx.androidcast.network.usb;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.network.CastProtocol;
|
||||||
|
import com.foxx.androidcast.network.CastTransport;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Future cast transport over USB tethering or ADB reverse port forward (roadmap E).
|
||||||
|
* Same {@link CastProtocol} framing as TCP/UDP once a byte stream is available.
|
||||||
|
*/
|
||||||
|
public interface UsbTetherCastTransport extends CastTransport {
|
||||||
|
|
||||||
|
/** Returns true when {@link android.hardware.usb.UsbManager} reports tether active, etc. */
|
||||||
|
boolean isUsbLinkAvailable();
|
||||||
|
|
||||||
|
static UsbTetherCastTransport createStub() {
|
||||||
|
return new UsbTetherCastTransportStub();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UsbTetherCastTransportStub implements UsbTetherCastTransport {
|
||||||
|
@Override
|
||||||
|
public boolean isUsbLinkAvailable() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(int port) throws IOException {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"USB-tether cast is not implemented yet. Enable in developer settings only for "
|
||||||
|
+ "future testing; use UDP/TCP over Wi-Fi for alpha.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void connect(String host, int port) throws IOException {
|
||||||
|
throw new UnsupportedOperationException(
|
||||||
|
"USB-tether cast is not implemented yet (roadmap E).");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void send(byte type, byte[] payload) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("USB-tether cast is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||||
|
throw new UnsupportedOperationException("USB-tether cast is not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getModeLabel() {
|
||||||
|
return "USB tether (stub)";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.foxx.androidcast.network.usb;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
|
public final class UsbTetherTransportFactory {
|
||||||
|
private UsbTetherTransportFactory() {}
|
||||||
|
|
||||||
|
public static boolean isTransportId(String transport) {
|
||||||
|
return CastConfig.TRANSPORT_USB_TETHER.equals(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UsbTetherCastTransport create() {
|
||||||
|
return UsbTetherCastTransport.createStub();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ import android.view.WindowManager;
|
|||||||
|
|
||||||
import com.foxx.androidcast.AppPreferences;
|
import com.foxx.androidcast.AppPreferences;
|
||||||
import com.foxx.androidcast.CastActiveState;
|
import com.foxx.androidcast.CastActiveState;
|
||||||
|
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||||
|
import com.foxx.androidcast.display.WiredDisplayMonitor;
|
||||||
import com.foxx.androidcast.CastTrayNotifier;
|
import com.foxx.androidcast.CastTrayNotifier;
|
||||||
import com.foxx.androidcast.CastNotifications;
|
import com.foxx.androidcast.CastNotifications;
|
||||||
import com.foxx.androidcast.CastConfig;
|
import com.foxx.androidcast.CastConfig;
|
||||||
@@ -465,6 +467,8 @@ public class ScreenCastService extends Service implements
|
|||||||
|
|
||||||
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
|
private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
Log.d(TAG, "Video capture target displayId=" + resolveCaptureDisplayId()
|
||||||
|
+ " policy=" + AppPreferences.getExternalDisplayCapturePolicy(this).name());
|
||||||
if (settings.isPassthrough()) {
|
if (settings.isPassthrough()) {
|
||||||
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
throw new IOException("Passthrough video is not implemented yet (debug only)");
|
||||||
}
|
}
|
||||||
@@ -538,15 +542,25 @@ public class ScreenCastService extends Service implements
|
|||||||
displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE);
|
displayManager = (DisplayManager) getSystemService(DISPLAY_SERVICE);
|
||||||
displayListener = new DisplayManager.DisplayListener() {
|
displayListener = new DisplayManager.DisplayListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onDisplayAdded(int displayId) {}
|
public void onDisplayAdded(int displayId) {
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
onExternalDisplayTopologyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisplayRemoved(int displayId) {}
|
public void onDisplayRemoved(int displayId) {
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
onExternalDisplayTopologyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisplayChanged(int displayId) {
|
public void onDisplayChanged(int displayId) {
|
||||||
if (displayId == Display.DEFAULT_DISPLAY) {
|
if (displayId == Display.DEFAULT_DISPLAY) {
|
||||||
runOnProjectionThread(ScreenCastService.this::maybeReconfigureForRotation);
|
runOnProjectionThread(ScreenCastService.this::maybeReconfigureForRotation);
|
||||||
|
} else {
|
||||||
|
onExternalDisplayTopologyChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -562,6 +576,30 @@ public class ScreenCastService extends Service implements
|
|||||||
displayListener = null;
|
displayListener = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onExternalDisplayTopologyChanged() {
|
||||||
|
WiredDisplayMonitor monitor = WiredDisplayMonitor.getInstance(this);
|
||||||
|
if (monitor.hasExternalDisplay()) {
|
||||||
|
Log.i(TAG, "External display: " + monitor.getSummaryLine());
|
||||||
|
if (AppPreferences.isDevWiredDisplayHintsEnabled(this)) {
|
||||||
|
updateStatus(getString(R.string.wired_display_detected_hint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display id for MediaProjection capture (roadmap D). Non-default capture not implemented yet.
|
||||||
|
*/
|
||||||
|
private int resolveCaptureDisplayId() {
|
||||||
|
ExternalDisplayCapturePolicy policy = AppPreferences.getExternalDisplayCapturePolicy(this);
|
||||||
|
if (policy == ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY) {
|
||||||
|
return Display.DEFAULT_DISPLAY;
|
||||||
|
}
|
||||||
|
if (WiredDisplayMonitor.getInstance(this).hasExternalDisplay()) {
|
||||||
|
Log.i(TAG, "Capture policy " + policy.name() + " — using default display until wired capture ships");
|
||||||
|
}
|
||||||
|
return Display.DEFAULT_DISPLAY;
|
||||||
|
}
|
||||||
|
|
||||||
private void maybeReconfigureForRotation() {
|
private void maybeReconfigureForRotation() {
|
||||||
if (calibrationMode || cameraMode || !casting.get() || projection == null
|
if (calibrationMode || cameraMode || !casting.get() || projection == null
|
||||||
|| activeSettings == null || stopping.get()) {
|
|| activeSettings == null || stopping.get()) {
|
||||||
|
|||||||
@@ -178,5 +178,118 @@
|
|||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="@string/ota_section_hint"
|
android:text="@string/ota_section_hint"
|
||||||
android:textSize="12sp" />
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:text="@string/dev_wired_display_section"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_wired_display_status"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_wired_display_hints"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_wired_display_hints" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_external_capture_policy"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<Spinner
|
||||||
|
android:id="@+id/spinner_external_capture_policy"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/text_external_capture_policy_note"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_usb_tether_transport"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_usb_tether_transport" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_wired_display_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:text="@string/dev_commercial_section"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_commercial_ads"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_commercial_ads" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_commercial_iap"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_commercial_iap" />
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/check_commercial_play_store"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_commercial_play_store" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_open_play_store_listing"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/dev_open_play_store_listing" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_request_play_review"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_request_play_review" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_query_iap_products"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_query_iap_products" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/dev_commercial_hint"
|
||||||
|
android:textSize="12sp" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -188,6 +188,9 @@
|
|||||||
<string name="licenses_title">Лицензии</string>
|
<string name="licenses_title">Лицензии</string>
|
||||||
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
<string name="licenses_load_error">Не могу найти факл лицензий</string>
|
||||||
<string name="developer_settings_title">Для разработчиков</string>
|
<string name="developer_settings_title">Для разработчиков</string>
|
||||||
|
<string name="dev_wired_display_section">USB-C / HDMI (план D)</string>
|
||||||
|
<string name="dev_commercial_section">Монетизация (выкл. по умолчанию)</string>
|
||||||
|
<string name="transport_usb_tether">USB-тетер (эксперимент)</string>
|
||||||
<string name="dev_show_receiver_debug_overlay">Показывать отладочный оверлей на приёмнике</string>
|
<string name="dev_show_receiver_debug_overlay">Показывать отладочный оверлей на приёмнике</string>
|
||||||
<string name="dev_show_codec_priority_scores">Показывать приоритет кодеков в настройках</string>
|
<string name="dev_show_codec_priority_scores">Показывать приоритет кодеков в настройках</string>
|
||||||
<string name="dev_grab_session_stats">Сохранять анонимную статистику сессий</string>
|
<string name="dev_grab_session_stats">Сохранять анонимную статистику сессий</string>
|
||||||
|
|||||||
@@ -199,6 +199,28 @@
|
|||||||
<string name="licenses_title">Licenses</string>
|
<string name="licenses_title">Licenses</string>
|
||||||
<string name="licenses_load_error">Could not load license text.</string>
|
<string name="licenses_load_error">Could not load license text.</string>
|
||||||
<string name="developer_settings_title">Developer settings</string>
|
<string name="developer_settings_title">Developer settings</string>
|
||||||
|
<string name="dev_wired_display_section">USB-C / HDMI (roadmap D)</string>
|
||||||
|
<string name="dev_wired_display_hints">Show sender status when external display is plugged in</string>
|
||||||
|
<string name="dev_wired_display_hint">Wired HDMI uses the OS display stack, not the cast protocol. If no second screen appears in system Display settings, adapters cannot be fixed in-app. See docs/USB_HDMI_CAST.md.</string>
|
||||||
|
<string name="dev_external_capture_policy">External display capture policy (future)</string>
|
||||||
|
<string name="dev_usb_tether_transport">Show USB-tether transport in cast settings (stub, roadmap E)</string>
|
||||||
|
<string name="wired_display_detected_hint">External display detected — use system mirror or Wi‑Fi cast to receiver</string>
|
||||||
|
<string name="transport_usb_tether">USB tether (experimental)</string>
|
||||||
|
<string name="dev_commercial_section">Commercial (off until enabled here)</string>
|
||||||
|
<string name="dev_commercial_ads">Enable in-app ads (placeholder)</string>
|
||||||
|
<string name="dev_commercial_iap">Enable in-app purchases (Play Billing)</string>
|
||||||
|
<string name="dev_commercial_play_store">Enable Play Store integration</string>
|
||||||
|
<string name="dev_open_play_store_listing">Open Play Store listing</string>
|
||||||
|
<string name="dev_request_play_review">Request in-app review flow</string>
|
||||||
|
<string name="dev_query_iap_products">Query IAP products (log)</string>
|
||||||
|
<string name="dev_commercial_hint">Ads, billing, and review stay disabled for daily use. Configure Play Console SKUs and AdMob before any public release.</string>
|
||||||
|
<string name="commercial_ads_placeholder">[Ads placeholder — enable AdMob in manifest for production]</string>
|
||||||
|
<string name="dev_commercial_enable_play_first">Enable Play Store integration above first</string>
|
||||||
|
<string name="dev_commercial_enable_iap_first">Enable in-app purchases above first</string>
|
||||||
|
<string name="dev_iap_query_done">IAP query finished — see logcat</string>
|
||||||
|
<string name="dev_capture_policy_default">Default display only (active)</string>
|
||||||
|
<string name="dev_capture_policy_prefer_external">Prefer external when present (planned)</string>
|
||||||
|
<string name="dev_capture_policy_external_experimental">External display experimental (planned)</string>
|
||||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
||||||
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.foxx.androidcast.display;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class ExternalDisplayCapturePolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void fromName_valid() {
|
||||||
|
assertEquals(ExternalDisplayCapturePolicy.PREFER_EXTERNAL_WHEN_PRESENT,
|
||||||
|
ExternalDisplayCapturePolicy.fromName("PREFER_EXTERNAL_WHEN_PRESENT",
|
||||||
|
ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void fromName_invalidFallsBack() {
|
||||||
|
assertEquals(ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY,
|
||||||
|
ExternalDisplayCapturePolicy.fromName("NOPE",
|
||||||
|
ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void implementationNote_nonEmpty() {
|
||||||
|
for (ExternalDisplayCapturePolicy p : ExternalDisplayCapturePolicy.values()) {
|
||||||
|
assertTrue(p.implementationNote().length() > 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.foxx.androidcast.network.usb;
|
||||||
|
|
||||||
|
import com.foxx.androidcast.CastConfig;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
public class UsbTetherTransportFactoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void recognizesUsbTetherId() {
|
||||||
|
assertTrue(UsbTetherTransportFactory.isTransportId(CastConfig.TRANSPORT_USB_TETHER));
|
||||||
|
assertFalse(UsbTetherTransportFactory.isTransportId(CastConfig.TRANSPORT_UDP));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void stubNotLinkAvailable() {
|
||||||
|
assertFalse(UsbTetherCastTransport.createStub().isUsbLinkAvailable());
|
||||||
|
}
|
||||||
|
}
|
||||||
103
backend_ops.txt
103
backend_ops.txt
@@ -1,103 +0,0 @@
|
|||||||
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
|
|
||||||
27
docs/COMMERCIAL.md
Normal file
27
docs/COMMERCIAL.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Commercial features (developer-gated)
|
||||||
|
|
||||||
|
Ads, in-app purchases, and Play Store helpers are **disabled for normal users**. Turn them on only in **Developer settings** while testing monetization.
|
||||||
|
|
||||||
|
## Developer toggles
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
|---------|--------|
|
||||||
|
| Enable in-app ads | Initializes `InAppAdCoordinator` (placeholder UI; AdMob not linked yet) |
|
||||||
|
| Enable in-app purchases | Connects Google Play Billing; placeholder SKU `androidcast_premium` |
|
||||||
|
| Enable Play Store integration | Allows listing intent + in-app review API |
|
||||||
|
|
||||||
|
Changing a toggle calls `CommercialFeatures.refreshFromPreferences()`.
|
||||||
|
|
||||||
|
## Before production
|
||||||
|
|
||||||
|
1. Create app in **Google Play Console** and add in-app products matching `InAppPurchaseCoordinator.SKU_PREMIUM`.
|
||||||
|
2. Add **AdMob** App ID to `AndroidManifest.xml` and replace `InAppAdCoordinator` placeholder with real ad units.
|
||||||
|
3. Privacy policy + ads declaration if using ads.
|
||||||
|
4. Remove or hide developer toggles in release builds (optional `BuildConfig.DEBUG` guard).
|
||||||
|
|
||||||
|
## Code map
|
||||||
|
|
||||||
|
- `com.foxx.androidcast.commercial.CommercialFeatures`
|
||||||
|
- `InAppPurchaseCoordinator` — Billing Client 7.x
|
||||||
|
- `InAppAdCoordinator` — stub
|
||||||
|
- `PlayStoreIntegration` — `market://` + Play In-App Review
|
||||||
117
docs/ROADMAP.md
117
docs/ROADMAP.md
@@ -1,5 +1,75 @@
|
|||||||
# Android Cast — implementation roadmap
|
# Android Cast — implementation roadmap
|
||||||
|
|
||||||
|
Last updated: 2026-05-21. Living document — refine as alpha and commercial tracks progress.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alpha definition
|
||||||
|
|
||||||
|
| Track | Goal | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| **Android app** | Stable, feature-rich, **demo-ready** on LAN (discover → PIN → cast → stop; soak two devices) | Core path done; polish UX/docs and gate experimental options for show |
|
||||||
|
| **Backend** | Deployed **OTA channel** + **crash ingest** (+ optional MQTT per `docs/OTA.md`) | WIP — examples under `examples/`; deployment blocked |
|
||||||
|
|
||||||
|
**Alpha app** can ship before backend is live if the demo is LAN-only. **Full alpha** includes OTA/crash URLs configured in developer settings or `local.properties`.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Alpha backend checklist
|
||||||
|
|
||||||
|
- [ ] Host `examples/ota/v0/` channel JSON + APK artifacts
|
||||||
|
- [ ] Deploy `examples/crash_reporter/backend/` (see `nginx.conf`, `README.md`)
|
||||||
|
- [ ] Set `ota.channel.url` and crash upload URL in dev settings / `local.properties`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Product positioning
|
||||||
|
|
||||||
|
- **Primary:** Wi‑Fi LAN cast when **USB-C→HDMI** wired mirror fails or is unavailable (common on some tablets).
|
||||||
|
- **Not in scope for v0:** Internet relay (`gateway_url_hint`), TLS on cast protocol, WebRTC as default transport.
|
||||||
|
|
||||||
|
See [USB_HDMI_CAST.md](USB_HDMI_CAST.md) for wired display vs Wi‑Fi cast.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## USB-C / HDMI (roadmap D) and USB-tether cast (roadmap E)
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `WiredDisplayMonitor` — detect external displays | Done |
|
||||||
|
| `ExternalDisplayCapturePolicy` + developer UI | Done (policy stored; capture still default display) |
|
||||||
|
| `ScreenCastService` display hot-plug hooks | Done |
|
||||||
|
| Secondary-display MediaProjection capture | TODO |
|
||||||
|
| `UsbTetherCastTransport` stub + dev-gated transport id | Done |
|
||||||
|
| USB tether / ADB reverse discovery + framing | TODO |
|
||||||
|
|
||||||
|
Details: [USB_HDMI_CAST.md](USB_HDMI_CAST.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commercial (Play Store prep)
|
||||||
|
|
||||||
|
All **off by default**. Enable only in **Developer settings** for internal testing.
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `CommercialFeatures` gate | Done |
|
||||||
|
| `InAppPurchaseCoordinator` (Play Billing 7.x) | Done (connects when dev IAP on; SKU placeholder) |
|
||||||
|
| `InAppAdCoordinator` (placeholder, no AdMob SDK yet) | Done |
|
||||||
|
| `PlayStoreIntegration` (listing intent + in-app review API) | Done |
|
||||||
|
| AdMob / unit IDs / banner layouts in main UI | TODO |
|
||||||
|
| Play Console products + privacy policy | TODO |
|
||||||
|
|
||||||
|
See [COMMERCIAL.md](COMMERCIAL.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stream protection (FEC / NACK)
|
## Stream protection (FEC / NACK)
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
@@ -17,6 +87,8 @@
|
|||||||
|
|
||||||
Enable in **Settings → Stream protection (UDP)** on both sender and receiver, then reconnect.
|
Enable in **Settings → Stream protection (UDP)** on both sender and receiver, then reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## libvpx / Opus / Speex
|
## libvpx / Opus / Speex
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
@@ -30,3 +102,48 @@ Enable in **Settings → Stream protection (UDP)** on both sender and receiver,
|
|||||||
| Opus / Speex build + sinks | TODO |
|
| Opus / Speex build + sinks | TODO |
|
||||||
|
|
||||||
See [ndk/README.md](../ndk/README.md).
|
See [ndk/README.md](../ndk/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transports & protocols
|
||||||
|
|
||||||
|
| Transport | Status |
|
||||||
|
|-----------|--------|
|
||||||
|
| UDP (default) | Done |
|
||||||
|
| TCP | Done |
|
||||||
|
| QUIC (Cronet, experimental) | Partial |
|
||||||
|
| WebRTC | Stub (`WebRtcCastTransport`) |
|
||||||
|
| USB tether (`usb_tether`) | Stub — dev settings only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receiver / sender UX
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `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 |
|
||||||
|
| Immersive A/V presets (dev) | Experimental |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred / post-alpha
|
||||||
|
|
||||||
|
- Internet relay gateway
|
||||||
|
- TLS on cast stream
|
||||||
|
- Passthrough video codec
|
||||||
|
- Lab automation under simulated loss
|
||||||
|
- Full wired-HDMI capture (roadmap D phase 2)
|
||||||
|
- USB-tether cast production path (roadmap E)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- [README.md](../README.md) — quick start
|
||||||
|
- [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — D & E
|
||||||
|
- [COMMERCIAL.md](COMMERCIAL.md) — ads / IAP / Play
|
||||||
|
- [OTA.md](OTA.md) — backend layout v0
|
||||||
|
- [CRASH_REPORTER.md](CRASH_REPORTER.md) — crash upload
|
||||||
|
|||||||
51
docs/USB_HDMI_CAST.md
Normal file
51
docs/USB_HDMI_CAST.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# USB-C / HDMI and USB-tether cast (roadmap D & E)
|
||||||
|
|
||||||
|
Android Cast streams over **Wi‑Fi** (UDP/TCP/QUIC). USB-C→HDMI adapters use Android’s **wired external display** stack, which is separate from the cast protocol.
|
||||||
|
|
||||||
|
## Layer model
|
||||||
|
|
||||||
|
| Layer | Mechanism | This app today |
|
||||||
|
|-------|-----------|----------------|
|
||||||
|
| Wired mirror | `DisplayManager` + HDMI/DP alt mode | Not encoded by us; OS drives the sink |
|
||||||
|
| Wi‑Fi cast | MediaProjection + `CastTransport` | Primary product path |
|
||||||
|
| USB-tether cast (E) | IP over USB (RNDIS/ADB reverse) + same framing | **Stub** (`usb_tether` transport) |
|
||||||
|
|
||||||
|
## Phase D — external display awareness & future capture
|
||||||
|
|
||||||
|
**Implemented (foundation):**
|
||||||
|
|
||||||
|
- `WiredDisplayMonitor` — tracks non-default displays (HDMI, presentation, etc.).
|
||||||
|
- `ExternalDisplayCapturePolicy` — developer preference for future capture routing.
|
||||||
|
- Developer settings: live wired-display status + capture policy selector.
|
||||||
|
- `ScreenCastService` registers extended display listener (rotation + hot-plug).
|
||||||
|
|
||||||
|
**Not implemented yet (post-alpha):**
|
||||||
|
|
||||||
|
- Capture **secondary display** via `MediaProjection` / `createVirtualDisplay` on non-default `Display`.
|
||||||
|
- Android 14+ per-app capture rules and extend-vs-mirror modes.
|
||||||
|
- Automatic switch to external display when passive/active USB-C adapter works.
|
||||||
|
|
||||||
|
**User workaround when HDMI dongle fails:** LAN cast to a receiver (phone/box) connected to the projector by HDMI or built-in Android on the projector.
|
||||||
|
|
||||||
|
## Phase E — USB-tether transport
|
||||||
|
|
||||||
|
**Implemented (foundation):**
|
||||||
|
|
||||||
|
- `UsbTetherCastTransport` stub implementing `CastTransport`.
|
||||||
|
- `CastConfig.TRANSPORT_USB_TETHER` — selectable only when **Developer settings → USB-tether transport** is on.
|
||||||
|
- Factory routes to stub; connect/listen throw until RNDIS/ADB tunnel + discovery are wired.
|
||||||
|
|
||||||
|
**Future work:**
|
||||||
|
|
||||||
|
- Detect USB tether / ADB TCP forward (`adb reverse`).
|
||||||
|
- Optional discovery on `127.0.0.1` or tether interface IP.
|
||||||
|
- Same `CastProtocol` framing as UDP/TCP.
|
||||||
|
|
||||||
|
## Tablet + adapter checklist (e.g. Doogee Tab G6 Max → HY300)
|
||||||
|
|
||||||
|
1. **Settings → Display** — does a second screen appear when the dongle is plugged in?
|
||||||
|
2. **Passive** adapter: requires USB-C **DisplayPort Alt Mode** on the tablet port.
|
||||||
|
3. **Active** adapter: needs a chip/firmware pair the tablet supports (often still DP alt mode internally).
|
||||||
|
4. If the OS never sees an external display, **no app feature** can fix the cable path; use Wi‑Fi cast or receiver-on-HDMI.
|
||||||
|
|
||||||
|
See [ROADMAP.md](ROADMAP.md) for alpha and commercial tracks.
|
||||||
@@ -47,8 +47,9 @@ curl -X POST http://127.0.0.1:8080/api/upload.php \
|
|||||||
## Production (nginx + PHP-FPM)
|
## Production (nginx + PHP-FPM)
|
||||||
|
|
||||||
0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`).
|
0. Ensure PHP modules above are installed for the FPM SAPI (not only CLI `php -m`).
|
||||||
1. Copy or symlink `backend/` on the VM (e.g. `androidcast_project/backend` → `examples/crash_reporter/backend`).
|
1. Deploy the git tree so this path exists on the VM (no symlinks required):
|
||||||
2. Use **one** `location ^~ /app/androidcast_project/crashes/` with `alias .../backend/public/` (see `nginx.vm.conf`). Do not mix `proxy_*`, `try_files`, and hardcoded `upload.php` in the same block.
|
`.../androidcast_project/android_cast/examples/crash_reporter/backend/`
|
||||||
|
2. Point nginx `SCRIPT_FILENAME` at `.../backend/public/` (see `nginx.vm.conf`). Do not mix `proxy_*`, `try_files`, and broken alias/try_files combos in one block.
|
||||||
3. Set `base_path` in `config.php` to match the URL prefix exactly.
|
3. Set `base_path` in `config.php` to match the URL prefix exactly.
|
||||||
3. Choose DB:
|
3. Choose DB:
|
||||||
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
|
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
|
||||||
|
|||||||
36
examples/crash_reporter/backend/nginx.apps.conf.fragment
Normal file
36
examples/crash_reporter/backend/nginx.apps.conf.fragment
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Paste into the listen 443 (and 80) server {} in /etc/nginx/conf.d/apps.conf
|
||||||
|
# Replaces old per-URL blocks + any backend symlink paths.
|
||||||
|
|
||||||
|
location = /app/androidcast_project/crashes {
|
||||||
|
return 301 /app/androidcast_project/crashes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project {
|
||||||
|
return 301 /app/androidcast_project/crashes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /app/androidcast_project/ {
|
||||||
|
return 301 /app/androidcast_project/crashes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/android_cast/examples/crash_reporter/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/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;
|
||||||
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
# Inner VM nginx — crash reporter behind reverse proxy.
|
# Inner VM nginx — crash reporter (no symlinks).
|
||||||
#
|
#
|
||||||
# Adjust CRASH_PUBLIC to match your deploy (symlink OK):
|
# Canonical disk paths on Alpine BE:
|
||||||
# /var/www/localhost/htdocs/apps/app/androidcast_project/backend/public
|
# .../androidcast_project/android_cast/examples/crash_reporter/backend/public
|
||||||
#
|
#
|
||||||
# URL prefix must match config.php base_path:
|
# URL prefix (config.php base_path):
|
||||||
# /app/androidcast_project/crashes
|
# /app/androidcast_project/crashes
|
||||||
|
#
|
||||||
# set this once if your install supports it, else replace paths below
|
# Use the same location blocks on listen 80 and 443 if the FE proxy uses :80.
|
||||||
# map is optional; explicit paths in each block are clearest
|
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
|
server_name apps.f0xx.org artc0.intra.raptor.org;
|
||||||
|
|
||||||
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";
|
||||||
@@ -18,74 +18,41 @@ 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;
|
||||||
|
|
||||||
# --- adjust only this directory root on disk ---
|
|
||||||
# CRASH_PUBLIC = .../backend/public
|
|
||||||
|
|
||||||
location = /app/androidcast_project/crashes {
|
location = /app/androidcast_project/crashes {
|
||||||
return 301 /app/androidcast_project/crashes/;
|
return 301 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Directory URL -> index.php (avoids try_files + alias bugs)
|
|
||||||
location = /app/androidcast_project/crashes/ {
|
|
||||||
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/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;
|
|
||||||
client_max_body_size 4m;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /app/androidcast_project {
|
location = /app/androidcast_project {
|
||||||
return 301 /app/androidcast_project/crashes/;
|
return 301 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /app/androidcast_project/ {
|
location = /app/androidcast_project/ {
|
||||||
return 301 /app/androidcast_project/crashes/;
|
return 301 /app/androidcast_project/crashes/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||||
|
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||||
|
}
|
||||||
|
|
||||||
|
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/android_cast/examples/crash_reporter/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /app/androidcast_project/crashes/ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/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 ~ /\. {
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user