1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-22 18:04:25 +02:00
parent 26fbbe5252
commit ca36a53ac9
29 changed files with 1295 additions and 180 deletions

View File

@@ -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

View File

@@ -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'
} }

View File

@@ -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);
} }
} }

View File

@@ -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);

View File

@@ -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). */

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
});
}
}

View File

@@ -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).";
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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() {}
}
}

View File

@@ -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();
}
}

View File

@@ -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()) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 WiFi 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>

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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
View 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

View File

@@ -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:** WiFi 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 WiFi 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
View File

@@ -0,0 +1,51 @@
# USB-C / HDMI and USB-tether cast (roadmap D & E)
Android Cast streams over **WiFi** (UDP/TCP/QUIC). USB-C→HDMI adapters use Androids **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 |
| WiFi 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 WiFi cast or receiver-on-HDMI.
See [ROADMAP.md](ROADMAP.md) for alpha and commercial tracks.

View File

@@ -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

View 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;
}

View File

@@ -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