mirror of
git://f0xx.org/android_cast
synced 2026-07-29 06:39:09 +03:00
snap
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.foxx.androidcast.commercial;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
|
||||
/**
|
||||
* Gates ads, in-app purchases, and Play Store integration. All off until enabled in
|
||||
* developer settings (commercial release prep).
|
||||
*/
|
||||
public final class CommercialFeatures {
|
||||
private CommercialFeatures() {}
|
||||
|
||||
public static boolean isAdsEnabled(Context context) {
|
||||
return AppPreferences.isDevCommercialAdsEnabled(context);
|
||||
}
|
||||
|
||||
public static boolean isInAppPurchaseEnabled(Context context) {
|
||||
return AppPreferences.isDevCommercialIapEnabled(context);
|
||||
}
|
||||
|
||||
public static boolean isPlayStoreIntegrationEnabled(Context context) {
|
||||
return AppPreferences.isDevCommercialPlayStoreEnabled(context);
|
||||
}
|
||||
|
||||
public static boolean isAnyCommercialFeatureEnabled(Context context) {
|
||||
return isAdsEnabled(context)
|
||||
|| isInAppPurchaseEnabled(context)
|
||||
|| isPlayStoreIntegrationEnabled(context);
|
||||
}
|
||||
|
||||
/** Lazy-init billing / ads / review helpers when flags are turned on. */
|
||||
public static void refreshFromPreferences(Context context) {
|
||||
Context app = context.getApplicationContext();
|
||||
if (isInAppPurchaseEnabled(app)) {
|
||||
InAppPurchaseCoordinator.getInstance(app).ensureInitialized();
|
||||
} else {
|
||||
InAppPurchaseCoordinator.getInstance(app).shutdown();
|
||||
}
|
||||
if (isAdsEnabled(app)) {
|
||||
InAppAdCoordinator.getInstance(app).ensureInitialized();
|
||||
} else {
|
||||
InAppAdCoordinator.getInstance(app).shutdown();
|
||||
}
|
||||
if (isPlayStoreIntegrationEnabled(app)) {
|
||||
PlayStoreIntegration.getInstance(app).ensureInitialized();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.foxx.androidcast.commercial;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import com.foxx.androidcast.R;
|
||||
|
||||
/**
|
||||
* In-app ads placeholder. No AdMob SDK wired yet — when {@link CommercialFeatures#isAdsEnabled}
|
||||
* is true, logs and reserves UI hooks for a future banner/interstitial integration.
|
||||
*/
|
||||
public final class InAppAdCoordinator {
|
||||
private static final String TAG = "InAppAdCoordinator";
|
||||
private static volatile InAppAdCoordinator instance;
|
||||
|
||||
private final Context appContext;
|
||||
private boolean initialized;
|
||||
|
||||
public static InAppAdCoordinator getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (InAppAdCoordinator.class) {
|
||||
if (instance == null) {
|
||||
instance = new InAppAdCoordinator(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private InAppAdCoordinator(Context appContext) {
|
||||
this.appContext = appContext;
|
||||
}
|
||||
|
||||
public void ensureInitialized() {
|
||||
if (!CommercialFeatures.isAdsEnabled(appContext)) {
|
||||
return;
|
||||
}
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
initialized = true;
|
||||
Log.i(TAG, "Ads enabled (developer) — integrate AdMob App ID in manifest when ready.");
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
/** Attach a placeholder banner container (no network ads until SDK + unit IDs are added). */
|
||||
public void attachBannerPlaceholder(Activity activity, ViewGroup container) {
|
||||
if (!CommercialFeatures.isAdsEnabled(appContext) || container == null) {
|
||||
if (container != null) {
|
||||
container.setVisibility(ViewGroup.GONE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ensureInitialized();
|
||||
container.setVisibility(ViewGroup.VISIBLE);
|
||||
if (container.getChildCount() == 0) {
|
||||
android.widget.TextView hint = new android.widget.TextView(activity);
|
||||
hint.setText(R.string.commercial_ads_placeholder);
|
||||
hint.setPadding(24, 16, 24, 16);
|
||||
container.addView(hint);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.foxx.androidcast.commercial;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.android.billingclient.api.BillingClient;
|
||||
import com.android.billingclient.api.BillingClientStateListener;
|
||||
import com.android.billingclient.api.BillingResult;
|
||||
import com.android.billingclient.api.PendingPurchasesParams;
|
||||
import com.android.billingclient.api.Purchase;
|
||||
import com.android.billingclient.api.PurchasesUpdatedListener;
|
||||
import com.android.billingclient.api.QueryProductDetailsParams;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Google Play Billing wrapper — connects only when developer settings enable IAP.
|
||||
* Product IDs are placeholders until Play Console SKUs exist.
|
||||
*/
|
||||
public final class InAppPurchaseCoordinator implements PurchasesUpdatedListener {
|
||||
private static final String TAG = "InAppPurchaseCoordinator";
|
||||
|
||||
/** Example SKU — create matching in-app product in Play Console before release. */
|
||||
public static final String SKU_PREMIUM = "androidcast_premium";
|
||||
|
||||
private static volatile InAppPurchaseCoordinator instance;
|
||||
|
||||
private final Context appContext;
|
||||
private BillingClient billingClient;
|
||||
private boolean initialized;
|
||||
|
||||
public static InAppPurchaseCoordinator getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (InAppPurchaseCoordinator.class) {
|
||||
if (instance == null) {
|
||||
instance = new InAppPurchaseCoordinator(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private InAppPurchaseCoordinator(Context appContext) {
|
||||
this.appContext = appContext;
|
||||
}
|
||||
|
||||
public void ensureInitialized() {
|
||||
if (!CommercialFeatures.isInAppPurchaseEnabled(appContext)) {
|
||||
return;
|
||||
}
|
||||
if (initialized && billingClient != null) {
|
||||
return;
|
||||
}
|
||||
billingClient = BillingClient.newBuilder(appContext)
|
||||
.setListener(this)
|
||||
.enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build())
|
||||
.build();
|
||||
billingClient.startConnection(new BillingClientStateListener() {
|
||||
@Override
|
||||
public void onBillingSetupFinished(BillingResult billingResult) {
|
||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
|
||||
initialized = true;
|
||||
Log.i(TAG, "Billing connected (developer IAP enabled)");
|
||||
} else {
|
||||
Log.w(TAG, "Billing setup: " + billingResult.getDebugMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBillingServiceDisconnected() {
|
||||
initialized = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if (billingClient != null) {
|
||||
billingClient.endConnection();
|
||||
billingClient = null;
|
||||
}
|
||||
initialized = false;
|
||||
}
|
||||
|
||||
public boolean isReady() {
|
||||
return initialized && billingClient != null && billingClient.isReady();
|
||||
}
|
||||
|
||||
/** Query placeholder SKU for developer testing (no UI yet). */
|
||||
public void queryPremiumProduct(Runnable onDone) {
|
||||
if (!isReady()) {
|
||||
if (onDone != null) {
|
||||
onDone.run();
|
||||
}
|
||||
return;
|
||||
}
|
||||
QueryProductDetailsParams.Product product = QueryProductDetailsParams.Product.newBuilder()
|
||||
.setProductId(SKU_PREMIUM)
|
||||
.setProductType(BillingClient.ProductType.INAPP)
|
||||
.build();
|
||||
QueryProductDetailsParams params = QueryProductDetailsParams.newBuilder()
|
||||
.setProductList(Collections.singletonList(product))
|
||||
.build();
|
||||
billingClient.queryProductDetailsAsync(params, (billingResult, productDetailsList) -> {
|
||||
if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK
|
||||
&& productDetailsList != null && !productDetailsList.isEmpty()) {
|
||||
Log.i(TAG, "Product available: " + SKU_PREMIUM);
|
||||
} else {
|
||||
Log.w(TAG, "Product query: " + billingResult.getDebugMessage());
|
||||
}
|
||||
if (onDone != null) {
|
||||
onDone.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
|
||||
if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK || purchases == null) {
|
||||
return;
|
||||
}
|
||||
for (Purchase purchase : purchases) {
|
||||
Log.i(TAG, "Purchase state: " + purchase.getPurchaseState() + " products=" + purchase.getProducts());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.foxx.androidcast.commercial;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.android.play.core.review.ReviewInfo;
|
||||
import com.google.android.play.core.review.ReviewManager;
|
||||
import com.google.android.play.core.review.ReviewManagerFactory;
|
||||
|
||||
/**
|
||||
* Play Store listing and in-app review — active only when developer settings enable Play integration.
|
||||
*/
|
||||
public final class PlayStoreIntegration {
|
||||
private static final String TAG = "PlayStoreIntegration";
|
||||
private static volatile PlayStoreIntegration instance;
|
||||
|
||||
private final Context appContext;
|
||||
private ReviewManager reviewManager;
|
||||
private boolean initialized;
|
||||
|
||||
public static PlayStoreIntegration getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (PlayStoreIntegration.class) {
|
||||
if (instance == null) {
|
||||
instance = new PlayStoreIntegration(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private PlayStoreIntegration(Context appContext) {
|
||||
this.appContext = appContext;
|
||||
}
|
||||
|
||||
public void ensureInitialized() {
|
||||
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext)) {
|
||||
return;
|
||||
}
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
reviewManager = ReviewManagerFactory.create(appContext);
|
||||
initialized = true;
|
||||
Log.i(TAG, "Play Store integration enabled (developer)");
|
||||
}
|
||||
|
||||
public void openAppListing(Activity activity) {
|
||||
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext)) {
|
||||
return;
|
||||
}
|
||||
String pkg = appContext.getPackageName();
|
||||
try {
|
||||
activity.startActivity(new Intent(Intent.ACTION_VIEW,
|
||||
Uri.parse("market://details?id=" + pkg)));
|
||||
} catch (ActivityNotFoundException e) {
|
||||
activity.startActivity(new Intent(Intent.ACTION_VIEW,
|
||||
Uri.parse("https://play.google.com/store/apps/details?id=" + pkg)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Launch Google Play in-app review flow when available (test via internal track). */
|
||||
public void requestInAppReviewIfAvailable(Activity activity) {
|
||||
if (!CommercialFeatures.isPlayStoreIntegrationEnabled(appContext) || reviewManager == null) {
|
||||
return;
|
||||
}
|
||||
reviewManager.requestReviewFlow().addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful()) {
|
||||
Log.w(TAG, "Review flow request failed");
|
||||
return;
|
||||
}
|
||||
ReviewInfo reviewInfo = task.getResult();
|
||||
reviewManager.launchReviewFlow(activity, reviewInfo);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.foxx.androidcast.display;
|
||||
|
||||
/**
|
||||
* Developer-only preference for which display MediaProjection should target once
|
||||
* multi-display capture is implemented (roadmap D).
|
||||
*/
|
||||
public enum ExternalDisplayCapturePolicy {
|
||||
/** Current production behavior: default display only. */
|
||||
DEFAULT_DISPLAY_ONLY,
|
||||
/**
|
||||
* When an external display is connected, prefer capturing it (not implemented —
|
||||
* falls back to default until wired capture lands).
|
||||
*/
|
||||
PREFER_EXTERNAL_WHEN_PRESENT,
|
||||
/**
|
||||
* Explicit secondary-display capture (future; requires API 34+ rules review).
|
||||
*/
|
||||
EXTERNAL_DISPLAY_EXPERIMENTAL;
|
||||
|
||||
public static ExternalDisplayCapturePolicy fromName(String name, ExternalDisplayCapturePolicy fallback) {
|
||||
if (name == null || name.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return valueOf(name);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable status for developer settings. */
|
||||
public String implementationNote() {
|
||||
switch (this) {
|
||||
case PREFER_EXTERNAL_WHEN_PRESENT:
|
||||
return "Planned: auto-route capture to HDMI when present (not active).";
|
||||
case EXTERNAL_DISPLAY_EXPERIMENTAL:
|
||||
return "Planned: explicit secondary-display VirtualDisplay (not active).";
|
||||
case DEFAULT_DISPLAY_ONLY:
|
||||
default:
|
||||
return "Active: captures the default display (standard cast).";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.foxx.androidcast.display;
|
||||
|
||||
import android.content.Context;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.Display;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Tracks wired / external displays (USB-C HDMI, presentation, etc.) for roadmap D.
|
||||
* Does not perform capture — informs UI and future capture routing.
|
||||
*/
|
||||
public final class WiredDisplayMonitor implements DisplayManager.DisplayListener {
|
||||
|
||||
public static final class ExternalDisplayInfo {
|
||||
public final int displayId;
|
||||
public final String name;
|
||||
public final int widthPx;
|
||||
public final int heightPx;
|
||||
public final int flags;
|
||||
|
||||
ExternalDisplayInfo(Display display) {
|
||||
displayId = display.getDisplayId();
|
||||
name = display.getName() != null ? display.getName() : ("Display " + displayId);
|
||||
DisplayMetrics m = new DisplayMetrics();
|
||||
display.getRealMetrics(m);
|
||||
widthPx = m.widthPixels;
|
||||
heightPx = m.heightPixels;
|
||||
flags = display.getFlags();
|
||||
}
|
||||
|
||||
public boolean likelyHdmiOrWired() {
|
||||
return (flags & Display.FLAG_PRESENTATION) != 0
|
||||
|| (flags & Display.FLAG_SECURE) != 0
|
||||
|| displayId != Display.DEFAULT_DISPLAY;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Listener {
|
||||
void onWiredDisplaysChanged(List<ExternalDisplayInfo> externalDisplays);
|
||||
}
|
||||
|
||||
private static volatile WiredDisplayMonitor instance;
|
||||
|
||||
private final DisplayManager displayManager;
|
||||
private final Handler mainHandler;
|
||||
private final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
|
||||
private volatile List<ExternalDisplayInfo> externalDisplays = Collections.emptyList();
|
||||
private boolean registered;
|
||||
|
||||
public static WiredDisplayMonitor getInstance(Context context) {
|
||||
if (instance == null) {
|
||||
synchronized (WiredDisplayMonitor.class) {
|
||||
if (instance == null) {
|
||||
instance = new WiredDisplayMonitor(context.getApplicationContext());
|
||||
}
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
private WiredDisplayMonitor(Context context) {
|
||||
displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE);
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void addListener(Listener listener) {
|
||||
if (listener != null) {
|
||||
listeners.add(listener);
|
||||
listener.onWiredDisplaysChanged(externalDisplays);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeListener(Listener listener) {
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
public synchronized void ensureRegistered() {
|
||||
if (registered || displayManager == null) {
|
||||
return;
|
||||
}
|
||||
displayManager.registerDisplayListener(this, mainHandler);
|
||||
registered = true;
|
||||
refresh();
|
||||
}
|
||||
|
||||
public synchronized void unregister() {
|
||||
if (!registered || displayManager == null) {
|
||||
return;
|
||||
}
|
||||
displayManager.unregisterDisplayListener(this);
|
||||
registered = false;
|
||||
}
|
||||
|
||||
public List<ExternalDisplayInfo> getExternalDisplays() {
|
||||
return externalDisplays;
|
||||
}
|
||||
|
||||
public boolean hasExternalDisplay() {
|
||||
return !externalDisplays.isEmpty();
|
||||
}
|
||||
|
||||
public String getSummaryLine() {
|
||||
if (externalDisplays.isEmpty()) {
|
||||
return "No external display detected (USB-C HDMI will not work until OS sees a second screen).";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(externalDisplays.size()).append(" external display(s): ");
|
||||
for (int i = 0; i < externalDisplays.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append("; ");
|
||||
}
|
||||
ExternalDisplayInfo info = externalDisplays.get(i);
|
||||
sb.append(info.name).append(" ").append(info.widthPx).append("×").append(info.heightPx);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayAdded(int displayId) {
|
||||
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayRemoved(int displayId) {
|
||||
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayChanged(int displayId) {
|
||||
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void refresh() {
|
||||
List<ExternalDisplayInfo> next = scan();
|
||||
externalDisplays = next;
|
||||
for (Listener l : listeners) {
|
||||
l.onWiredDisplaysChanged(next);
|
||||
}
|
||||
}
|
||||
|
||||
private List<ExternalDisplayInfo> scan() {
|
||||
if (displayManager == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Display[] displays = displayManager.getDisplays();
|
||||
if (displays == null || displays.length == 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ExternalDisplayInfo> out = new ArrayList<>();
|
||||
for (Display d : displays) {
|
||||
if (d == null || d.getDisplayId() == Display.DEFAULT_DISPLAY) {
|
||||
continue;
|
||||
}
|
||||
out.add(new ExternalDisplayInfo(d));
|
||||
}
|
||||
return Collections.unmodifiableList(out);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import android.content.Context;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.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);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.foxx.androidcast.network.usb;
|
||||
|
||||
import com.foxx.androidcast.network.CastProtocol;
|
||||
import com.foxx.androidcast.network.CastTransport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Future cast transport over USB tethering or ADB reverse port forward (roadmap E).
|
||||
* Same {@link CastProtocol} framing as TCP/UDP once a byte stream is available.
|
||||
*/
|
||||
public interface UsbTetherCastTransport extends CastTransport {
|
||||
|
||||
/** Returns true when {@link android.hardware.usb.UsbManager} reports tether active, etc. */
|
||||
boolean isUsbLinkAvailable();
|
||||
|
||||
static UsbTetherCastTransport createStub() {
|
||||
return new UsbTetherCastTransportStub();
|
||||
}
|
||||
|
||||
final class UsbTetherCastTransportStub implements UsbTetherCastTransport {
|
||||
@Override
|
||||
public boolean isUsbLinkAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listen(int port) throws IOException {
|
||||
throw new UnsupportedOperationException(
|
||||
"USB-tether cast is not implemented yet. Enable in developer settings only for "
|
||||
+ "future testing; use UDP/TCP over Wi-Fi for alpha.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(String host, int port) throws IOException {
|
||||
throw new UnsupportedOperationException(
|
||||
"USB-tether cast is not implemented yet (roadmap E).");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(byte type, byte[] payload) throws IOException {
|
||||
throw new UnsupportedOperationException("USB-tether cast is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CastProtocol.Message receive(int timeoutMs) throws IOException {
|
||||
throw new UnsupportedOperationException("USB-tether cast is not implemented yet");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModeLabel() {
|
||||
return "USB tether (stub)";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.foxx.androidcast.network.usb;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
public final class UsbTetherTransportFactory {
|
||||
private UsbTetherTransportFactory() {}
|
||||
|
||||
public static boolean isTransportId(String transport) {
|
||||
return CastConfig.TRANSPORT_USB_TETHER.equals(transport);
|
||||
}
|
||||
|
||||
public static UsbTetherCastTransport create() {
|
||||
return UsbTetherCastTransport.createStub();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import android.view.WindowManager;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 Wi‑Fi cast to receiver</string>
|
||||
<string name="transport_usb_tether">USB tether (experimental)</string>
|
||||
<string name="dev_commercial_section">Commercial (off until enabled here)</string>
|
||||
<string name="dev_commercial_ads">Enable in-app ads (placeholder)</string>
|
||||
<string name="dev_commercial_iap">Enable in-app purchases (Play Billing)</string>
|
||||
<string name="dev_commercial_play_store">Enable Play Store integration</string>
|
||||
<string name="dev_open_play_store_listing">Open Play Store listing</string>
|
||||
<string name="dev_request_play_review">Request in-app review flow</string>
|
||||
<string name="dev_query_iap_products">Query IAP products (log)</string>
|
||||
<string name="dev_commercial_hint">Ads, billing, and review stay disabled for daily use. Configure Play Console SKUs and AdMob before any public release.</string>
|
||||
<string name="commercial_ads_placeholder">[Ads placeholder — enable AdMob in manifest for production]</string>
|
||||
<string name="dev_commercial_enable_play_first">Enable Play Store integration above first</string>
|
||||
<string name="dev_commercial_enable_iap_first">Enable in-app purchases above first</string>
|
||||
<string name="dev_iap_query_done">IAP query finished — see logcat</string>
|
||||
<string name="dev_capture_policy_default">Default display only (active)</string>
|
||||
<string name="dev_capture_policy_prefer_external">Prefer external when present (planned)</string>
|
||||
<string name="dev_capture_policy_external_experimental">External display experimental (planned)</string>
|
||||
<string name="dev_show_receiver_debug_overlay">Show debug overlay on receiver</string>
|
||||
<string name="dev_show_codec_priority_scores">Show codec priority scores in settings</string>
|
||||
<string name="dev_grab_session_stats">Grab anonymous session stats</string>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.foxx.androidcast.display;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ExternalDisplayCapturePolicyTest {
|
||||
|
||||
@Test
|
||||
public void fromName_valid() {
|
||||
assertEquals(ExternalDisplayCapturePolicy.PREFER_EXTERNAL_WHEN_PRESENT,
|
||||
ExternalDisplayCapturePolicy.fromName("PREFER_EXTERNAL_WHEN_PRESENT",
|
||||
ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fromName_invalidFallsBack() {
|
||||
assertEquals(ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY,
|
||||
ExternalDisplayCapturePolicy.fromName("NOPE",
|
||||
ExternalDisplayCapturePolicy.DEFAULT_DISPLAY_ONLY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void implementationNote_nonEmpty() {
|
||||
for (ExternalDisplayCapturePolicy p : ExternalDisplayCapturePolicy.values()) {
|
||||
assertTrue(p.implementationNote().length() > 5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.foxx.androidcast.network.usb;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class UsbTetherTransportFactoryTest {
|
||||
|
||||
@Test
|
||||
public void recognizesUsbTetherId() {
|
||||
assertTrue(UsbTetherTransportFactory.isTransportId(CastConfig.TRANSPORT_USB_TETHER));
|
||||
assertFalse(UsbTetherTransportFactory.isTransportId(CastConfig.TRANSPORT_UDP));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stubNotLinkAvailable() {
|
||||
assertFalse(UsbTetherCastTransport.createStub().isUsbLinkAvailable());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user