1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 06:39:09 +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

@@ -122,6 +122,8 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'com.google.android.gms:play-services-cronet:18.1.0'
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'
}

View File

@@ -14,8 +14,11 @@ package com.foxx.androidcast;
import android.app.Application;
import android.content.Context;
import com.foxx.androidcast.commercial.CommercialFeatures;
import com.foxx.androidcast.crash.CrashReporter;
import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.network.CastTransportFactory;
import com.foxx.androidcast.receiver.av.ReceiverAvRuntime;
/** Applies saved theme and locale at process start. */
@@ -33,5 +36,8 @@ public class AndroidCastApplication extends Application {
CastThemeHelper.applyNightMode(this);
CrashReporter.install(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.text.TextUtils;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
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_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. */
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();
}
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) {
CastSettings s = new CastSettings();
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_QUIC = "quic";
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. */
public static final String DEFAULT_TRANSPORT = TRANSPORT_UDP;
/** 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,
boolean unused) {
Spinner spinner = activity.findViewById(spinnerId);
String[] labels = {
activity.getString(R.string.transport_udp),
activity.getString(R.string.transport_tcp),
activity.getString(R.string.transport_quic)
};
String[] values = {
CastConfig.TRANSPORT_UDP,
CastConfig.TRANSPORT_TCP,
CastConfig.TRANSPORT_QUIC
};
java.util.ArrayList<String> labelList = new java.util.ArrayList<>();
java.util.ArrayList<String> valueList = new java.util.ArrayList<>();
labelList.add(activity.getString(R.string.transport_udp));
valueList.add(CastConfig.TRANSPORT_UDP);
labelList.add(activity.getString(R.string.transport_tcp));
valueList.add(CastConfig.TRANSPORT_TCP);
labelList.add(activity.getString(R.string.transport_quic));
valueList.add(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 -> {
settings.setTransport(v);
refreshStreamProtectionUi(activity, settings);

View File

@@ -23,6 +23,11 @@ import android.widget.Toast;
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.ota.OtaUpdateChecker;
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
@@ -83,6 +88,8 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
});
bindAvPresets();
bindWiredDisplaySection();
bindCommercialSection();
}
@Override
@@ -104,6 +111,98 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
findViewById(R.id.edit_ota_manifest_url),
findViewById(R.id.check_ota_on_launch));
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() {

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.CastSettings;
import com.foxx.androidcast.network.quic.QuicCronetCastTransport;
import com.foxx.androidcast.network.usb.UsbTetherTransportFactory;
import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory;
import java.io.IOException;
@@ -42,6 +43,9 @@ public final class CastTransportFactory {
if (WebRtcTransportFactory.isTransportId(mode)) {
return WebRtcTransportFactory.create();
}
if (UsbTetherTransportFactory.isTransportId(mode)) {
return UsbTetherTransportFactory.create();
}
if (CastConfig.TRANSPORT_UDP.equals(mode)) {
UdpCastTransport udp = new UdpCastTransport();
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.CastActiveState;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastNotifications;
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)
throws IOException {
Log.d(TAG, "Video capture target displayId=" + resolveCaptureDisplayId()
+ " policy=" + AppPreferences.getExternalDisplayCapturePolicy(this).name());
if (settings.isPassthrough()) {
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);
displayListener = new DisplayManager.DisplayListener() {
@Override
public void onDisplayAdded(int displayId) {}
public void onDisplayAdded(int displayId) {
if (displayId != Display.DEFAULT_DISPLAY) {
onExternalDisplayTopologyChanged();
}
}
@Override
public void onDisplayRemoved(int displayId) {}
public void onDisplayRemoved(int displayId) {
if (displayId != Display.DEFAULT_DISPLAY) {
onExternalDisplayTopologyChanged();
}
}
@Override
public void onDisplayChanged(int displayId) {
if (displayId == Display.DEFAULT_DISPLAY) {
runOnProjectionThread(ScreenCastService.this::maybeReconfigureForRotation);
} else {
onExternalDisplayTopologyChanged();
}
}
};
@@ -562,6 +576,30 @@ public class ScreenCastService extends Service implements
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() {
if (calibrationMode || cameraMode || !casting.get() || projection == null
|| activeSettings == null || stopping.get()) {

View File

@@ -178,5 +178,118 @@
android:layout_marginTop="8dp"
android:text="@string/ota_section_hint"
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>
</ScrollView>

View File

@@ -188,6 +188,9 @@
<string name="licenses_title">Лицензии</string>
<string name="licenses_load_error">Не могу найти факл лицензий</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_codec_priority_scores">Показывать приоритет кодеков в настройках</string>
<string name="dev_grab_session_stats">Сохранять анонимную статистику сессий</string>

View File

@@ -199,6 +199,28 @@
<string name="licenses_title">Licenses</string>
<string name="licenses_load_error">Could not load license text.</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_codec_priority_scores">Show codec priority scores in settings</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());
}
}