From ca36a53ac928d205a75f843cd7807aaeb0a7e4bd Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 22 May 2026 18:04:25 +0200 Subject: [PATCH] snap --- README.md | 2 + app/build.gradle | 2 + .../androidcast/AndroidCastApplication.java | 6 + .../com/foxx/androidcast/AppPreferences.java | 61 +++++++ .../java/com/foxx/androidcast/CastConfig.java | 2 + .../foxx/androidcast/CastSettingsBinder.java | 24 ++- .../DeveloperSettingsActivity.java | 99 ++++++++++ .../commercial/CommercialFeatures.java | 49 +++++ .../commercial/InAppAdCoordinator.java | 68 +++++++ .../commercial/InAppPurchaseCoordinator.java | 126 +++++++++++++ .../commercial/PlayStoreIntegration.java | 80 ++++++++ .../display/ExternalDisplayCapturePolicy.java | 43 +++++ .../display/WiredDisplayMonitor.java | 172 ++++++++++++++++++ .../network/CastTransportFactory.java | 4 + .../network/usb/UsbTetherCastTransport.java | 58 ++++++ .../usb/UsbTetherTransportFactory.java | 15 ++ .../androidcast/sender/ScreenCastService.java | 42 ++++- .../layout/activity_developer_settings.xml | 113 ++++++++++++ app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values/strings.xml | 22 +++ .../ExternalDisplayCapturePolicyTest.java | 30 +++ .../usb/UsbTetherTransportFactoryTest.java | 22 +++ backend_ops.txt | 103 ----------- docs/COMMERCIAL.md | 27 +++ docs/ROADMAP.md | 117 ++++++++++++ docs/USB_HDMI_CAST.md | 51 ++++++ examples/crash_reporter/backend/README.md | 5 +- .../backend/nginx.apps.conf.fragment | 36 ++++ examples/crash_reporter/backend/nginx.vm.conf | 93 +++------- 29 files changed, 1295 insertions(+), 180 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/commercial/CommercialFeatures.java create mode 100644 app/src/main/java/com/foxx/androidcast/commercial/InAppAdCoordinator.java create mode 100644 app/src/main/java/com/foxx/androidcast/commercial/InAppPurchaseCoordinator.java create mode 100644 app/src/main/java/com/foxx/androidcast/commercial/PlayStoreIntegration.java create mode 100644 app/src/main/java/com/foxx/androidcast/display/ExternalDisplayCapturePolicy.java create mode 100644 app/src/main/java/com/foxx/androidcast/display/WiredDisplayMonitor.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherCastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherTransportFactory.java create mode 100644 app/src/test/java/com/foxx/androidcast/display/ExternalDisplayCapturePolicyTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/network/usb/UsbTetherTransportFactoryTest.java delete mode 100644 backend_ops.txt create mode 100644 docs/COMMERCIAL.md create mode 100644 docs/USB_HDMI_CAST.md create mode 100644 examples/crash_reporter/backend/nginx.apps.conf.fragment diff --git a/README.md b/README.md index f6a62dc..b3bbd47 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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. ## Features diff --git a/app/build.gradle b/app/build.gradle index a7ae619..bf163f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java index 3441d12..87fa241 100644 --- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java +++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java @@ -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); } } diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java index 793070e..1d5c8a8 100644 --- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java +++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/CastConfig.java b/app/src/main/java/com/foxx/androidcast/CastConfig.java index 318bca1..7f34fda 100644 --- a/app/src/main/java/com/foxx/androidcast/CastConfig.java +++ b/app/src/main/java/com/foxx/androidcast/CastConfig.java @@ -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). */ diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java index 67f8c22..c860e72 100644 --- a/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java +++ b/app/src/main/java/com/foxx/androidcast/CastSettingsBinder.java @@ -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 labelList = new java.util.ArrayList<>(); + java.util.ArrayList 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); diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java index b92e0b7..cabcd2b 100644 --- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java +++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java @@ -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 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() { diff --git a/app/src/main/java/com/foxx/androidcast/commercial/CommercialFeatures.java b/app/src/main/java/com/foxx/androidcast/commercial/CommercialFeatures.java new file mode 100644 index 0000000..40d63c8 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/commercial/CommercialFeatures.java @@ -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(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/commercial/InAppAdCoordinator.java b/app/src/main/java/com/foxx/androidcast/commercial/InAppAdCoordinator.java new file mode 100644 index 0000000..2d91511 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/commercial/InAppAdCoordinator.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/commercial/InAppPurchaseCoordinator.java b/app/src/main/java/com/foxx/androidcast/commercial/InAppPurchaseCoordinator.java new file mode 100644 index 0000000..382d30a --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/commercial/InAppPurchaseCoordinator.java @@ -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 purchases) { + if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK || purchases == null) { + return; + } + for (Purchase purchase : purchases) { + Log.i(TAG, "Purchase state: " + purchase.getPurchaseState() + " products=" + purchase.getProducts()); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/commercial/PlayStoreIntegration.java b/app/src/main/java/com/foxx/androidcast/commercial/PlayStoreIntegration.java new file mode 100644 index 0000000..aa27cae --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/commercial/PlayStoreIntegration.java @@ -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); + }); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/display/ExternalDisplayCapturePolicy.java b/app/src/main/java/com/foxx/androidcast/display/ExternalDisplayCapturePolicy.java new file mode 100644 index 0000000..cbe5b27 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/display/ExternalDisplayCapturePolicy.java @@ -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)."; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/display/WiredDisplayMonitor.java b/app/src/main/java/com/foxx/androidcast/display/WiredDisplayMonitor.java new file mode 100644 index 0000000..06e3cb4 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/display/WiredDisplayMonitor.java @@ -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 externalDisplays); + } + + private static volatile WiredDisplayMonitor instance; + + private final DisplayManager displayManager; + private final Handler mainHandler; + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private volatile List 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 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 next = scan(); + externalDisplays = next; + for (Listener l : listeners) { + l.onWiredDisplaysChanged(next); + } + } + + private List scan() { + if (displayManager == null) { + return Collections.emptyList(); + } + Display[] displays = displayManager.getDisplays(); + if (displays == null || displays.length == 0) { + return Collections.emptyList(); + } + List 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); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java index bb7742e..a04324a 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -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); diff --git a/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherCastTransport.java new file mode 100644 index 0000000..0999f81 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherCastTransport.java @@ -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() {} + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherTransportFactory.java new file mode 100644 index 0000000..54d8529 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/usb/UsbTetherTransportFactory.java @@ -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(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 506d4b2..2500186 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -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()) { diff --git a/app/src/main/res/layout/activity_developer_settings.xml b/app/src/main/res/layout/activity_developer_settings.xml index b04ad94..77dbf2b 100644 --- a/app/src/main/res/layout/activity_developer_settings.xml +++ b/app/src/main/res/layout/activity_developer_settings.xml @@ -178,5 +178,118 @@ android:layout_marginTop="8dp" android:text="@string/ota_section_hint" android:textSize="12sp" /> + + + + + + + + + + + + + + + + + + + + + + + + + +