diff --git a/app/build.gradle b/app/build.gradle
index cc953b8..9317b36 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -29,7 +29,7 @@ def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", j
def otaMajor = 0
def otaMinor = 1
def otaBuild = 0
-def otaChannelUrlDefault = ''
+def otaChannelUrlDefault = 'https://apps.f0xx.org/v0/ota/channel/stable.json'
def otaManifestUrlDefault = ''
try {
def lpFile = rootProject.file('local.properties')
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1d3180f..c008456 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -21,8 +21,8 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
index ccd0e74..6308e9f 100644
--- a/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
+++ b/app/src/main/java/com/foxx/androidcast/AndroidCastApplication.java
@@ -49,6 +49,6 @@ public class AndroidCastApplication extends Application {
EntitlementCoordinator.getInstance(this).bootstrap();
RemoteAccessCoordinator.onBootIfNeeded(this);
DevAdbWifiKeeper.ensureStarted(this);
- CastTrayNotifier.ensureTrayService(this);
+ com.foxx.androidcast.boot.CastBootCoordinator.onProcessStart(this);
}
}
diff --git a/app/src/main/java/com/foxx/androidcast/AppPreferences.java b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
index e1d77eb..56e4c02 100644
--- a/app/src/main/java/com/foxx/androidcast/AppPreferences.java
+++ b/app/src/main/java/com/foxx/androidcast/AppPreferences.java
@@ -33,6 +33,9 @@ public final class AppPreferences {
private static final String KEY_USERNAME = "username";
private static final String KEY_PIN = "pin";
private static final String KEY_SHOW_TRAY = "show_tray_icon";
+ private static final String KEY_TRAY_ON_DEMAND = "tray_icon_on_demand";
+ private static final String KEY_BACKGROUND_LISTEN = "background_listen_enabled";
+ private static final String KEY_BLOCK_REPORT_ENDPOINT = "block_report_endpoint";
private static final String KEY_THEME = "theme_mode";
private static final String KEY_LOCALE = "locale_mode";
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
@@ -125,6 +128,34 @@ public final class AppPreferences {
prefs(context).edit().putBoolean(KEY_SHOW_TRAY, show).apply();
}
+ /** When true, tray icon appears only while casting/listening or app is in foreground. */
+ public static boolean isTrayOnDemand(Context context) {
+ return prefs(context).getBoolean(KEY_TRAY_ON_DEMAND, false);
+ }
+
+ public static void setTrayOnDemand(Context context, boolean onDemand) {
+ prefs(context).edit().putBoolean(KEY_TRAY_ON_DEMAND, onDemand).apply();
+ }
+
+ /** Keep receiver FGS + LAN presence after reboot (no UI until incoming cast or user opens app). */
+ public static boolean isBackgroundListenEnabled(Context context) {
+ return prefs(context).getBoolean(KEY_BACKGROUND_LISTEN, true);
+ }
+
+ public static void setBackgroundListenEnabled(Context context, boolean enabled) {
+ prefs(context).edit().putBoolean(KEY_BACKGROUND_LISTEN, enabled).apply();
+ }
+
+ /** Optional BE URL for block-user spam reports (queued locally when empty). */
+ public static String getBlockReportEndpoint(Context context) {
+ return prefs(context).getString(KEY_BLOCK_REPORT_ENDPOINT, "").trim();
+ }
+
+ public static void setBlockReportEndpoint(Context context, String url) {
+ prefs(context).edit().putString(KEY_BLOCK_REPORT_ENDPOINT,
+ url != null ? url.trim() : "").apply();
+ }
+
public static CastThemeHelper.AppThemeMode getThemeMode(Context context) {
String name = prefs(context).getString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name());
try {
@@ -507,9 +538,9 @@ public final class AppPreferences {
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply();
}
- /** Developer: allow backend-provided NTP correction for diagnostics (default off). */
+ /** Developer: allow backend-provided NTP correction for diagnostics (on by default in debug). */
public static boolean isDevBackendNtpSyncEnabled(Context context) {
- return prefs(context).getBoolean(KEY_DEV_BACKEND_NTP_SYNC, false);
+ return prefs(context).getBoolean(KEY_DEV_BACKEND_NTP_SYNC, BuildConfig.DEBUG);
}
public static void setDevBackendNtpSyncEnabled(Context context, boolean enabled) {
diff --git a/app/src/main/java/com/foxx/androidcast/CastActiveState.java b/app/src/main/java/com/foxx/androidcast/CastActiveState.java
index 71b7024..b4b46c1 100644
--- a/app/src/main/java/com/foxx/androidcast/CastActiveState.java
+++ b/app/src/main/java/com/foxx/androidcast/CastActiveState.java
@@ -17,6 +17,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
public final class CastActiveState {
private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
private static final AtomicBoolean receiverListening = new AtomicBoolean(false);
+ /** True while a sender is connected or media is actively streaming to this device. */
+ private static final AtomicBoolean receiverStreaming = new AtomicBoolean(false);
private CastActiveState() {}
@@ -35,4 +37,12 @@ public final class CastActiveState {
public static boolean isReceiverListening() {
return receiverListening.get();
}
+
+ public static void setReceiverStreaming(boolean active) {
+ receiverStreaming.set(active);
+ }
+
+ public static boolean isReceiverStreaming() {
+ return receiverStreaming.get();
+ }
}
diff --git a/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java
index 4e33480..0c27c97 100644
--- a/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java
+++ b/app/src/main/java/com/foxx/androidcast/CastActivityTracker.java
@@ -18,16 +18,26 @@ import android.content.Intent;
/** Remembers the last foreground activity for tray / notification resume. */
public final class CastActivityTracker {
private static volatile Class extends Activity> lastActivityClass;
+ private static volatile boolean anyActivityForeground;
private CastActivityTracker() {}
public static void onResume(Activity activity) {
if (activity != null) {
lastActivityClass = activity.getClass();
+ anyActivityForeground = true;
CastTrayNotifier.syncWhenForeground(activity);
}
}
+ public static void onPause(Activity activity) {
+ anyActivityForeground = false;
+ }
+
+ public static boolean isAnyActivityForeground() {
+ return anyActivityForeground;
+ }
+
public static Intent resumeIntent(Context context) {
Class extends Activity> cls = lastActivityClass;
if (cls != null) {
diff --git a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
index 58fa6d9..44d6afb 100644
--- a/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
+++ b/app/src/main/java/com/foxx/androidcast/CastSettingsFragment.java
@@ -89,6 +89,21 @@ public class CastSettingsFragment extends Fragment {
AppPreferences.setShowTrayIcon(requireContext(), checked);
CastTrayNotifier.onPreferenceChanged(requireContext());
});
+ CheckBox trayOnDemandCheck = view.findViewById(R.id.check_tray_on_demand);
+ if (trayOnDemandCheck != null) {
+ trayOnDemandCheck.setChecked(AppPreferences.isTrayOnDemand(requireContext()));
+ trayOnDemandCheck.setOnCheckedChangeListener((btn, checked) -> {
+ AppPreferences.setTrayOnDemand(requireContext(), checked);
+ CastTrayNotifier.onPreferenceChanged(requireContext());
+ });
+ }
+ CheckBox backgroundListenCheck = view.findViewById(R.id.check_background_listen);
+ if (backgroundListenCheck != null) {
+ backgroundListenCheck.setChecked(AppPreferences.isBackgroundListenEnabled(requireContext()));
+ backgroundListenCheck.setOnCheckedChangeListener((btn, checked) ->
+ com.foxx.androidcast.boot.CastBootCoordinator.applyBackgroundListenPreference(
+ requireContext(), checked));
+ }
if (crashLogsCheck != null) {
crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext()));
crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->
diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayContent.java b/app/src/main/java/com/foxx/androidcast/CastTrayContent.java
index 4299903..60b1626 100644
--- a/app/src/main/java/com/foxx/androidcast/CastTrayContent.java
+++ b/app/src/main/java/com/foxx/androidcast/CastTrayContent.java
@@ -23,20 +23,31 @@ public final class CastTrayContent {
public static Body build(
String idleTapToOpen,
String castActive,
+ String listeningFallback,
+ String receiverStatusLine,
+ String senderStatusLine,
String adbWaiting,
String adbLineFormat,
+ String devHttpLineFormat,
+ String devHttpHost,
+ int devHttpPort,
String raPollFormat,
String raConnected,
String raSessionFormat,
boolean casting,
+ boolean listening,
boolean adbEnabled,
String adbConnectLine,
RemoteAccessMode raMode,
RemoteAccessStatusStore.Snapshot raSnap) {
if (casting) {
- return new Body(castActive, castActive);
+ String line = pickFirstNonEmpty(senderStatusLine, receiverStatusLine, castActive);
+ return new Body(line, line);
}
List lines = new ArrayList<>();
+ if (listening) {
+ lines.add(pickFirstNonEmpty(receiverStatusLine, listeningFallback));
+ }
String raLine = remoteAccessLine(raPollFormat, raConnected, raSessionFormat, raMode, raSnap);
if (raLine != null && !raLine.isEmpty()) {
lines.add(raLine);
@@ -47,6 +58,9 @@ public final class CastTrayContent {
: String.format(adbLineFormat, adbConnectLine);
lines.add(adbLine);
}
+ if (devHttpHost != null && !devHttpHost.isEmpty() && devHttpPort > 0) {
+ lines.add(String.format(devHttpLineFormat, devHttpHost, devHttpPort));
+ }
if (!lines.isEmpty()) {
String joined = joinLines(lines);
return new Body(lines.get(0), joined);
@@ -79,6 +93,15 @@ public final class CastTrayContent {
return String.format(pollFormat, mode.name());
}
+ private static String pickFirstNonEmpty(String... candidates) {
+ for (String c : candidates) {
+ if (c != null && !c.isEmpty()) {
+ return c;
+ }
+ }
+ return "";
+ }
+
private static String joinLines(List lines) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines.size(); i++) {
diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java
index 66e546d..5605c22 100644
--- a/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java
+++ b/app/src/main/java/com/foxx/androidcast/CastTrayNotifier.java
@@ -30,6 +30,8 @@ public final class CastTrayNotifier {
public static final int LEGACY_DEV_ADB_ID = 50391;
public static final int LEGACY_RA_ID = 0x7a00;
public static final int LEGACY_RA_VPN_ID = 0x7a01;
+ public static final int LEGACY_RECEIVER_ID = 2;
+ public static final int LEGACY_SENDER_ID = 1;
private CastTrayNotifier() {}
@@ -57,6 +59,12 @@ public final class CastTrayNotifier {
}
public static boolean shouldMaintainTray(Context context) {
+ if (AppPreferences.isTrayOnDemand(context)) {
+ return CastActiveState.isSenderCasting()
+ || CastActiveState.isReceiverListening()
+ || CastActivityTracker.isAnyActivityForeground()
+ || hasDevTrayContent(context);
+ }
if (AppPreferences.isShowTrayIcon(context)) {
return true;
}
@@ -78,6 +86,11 @@ public final class CastTrayNotifier {
if (!shouldMaintainTray(app)) {
return;
}
+ // ReceiverCastService / ScreenCastService already hold the unified tray as FGS.
+ if (CastActiveState.isReceiverListening() || CastActiveState.isSenderCasting()) {
+ publish(app);
+ return;
+ }
Intent intent = new Intent(app, TrayForegroundService.class);
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -131,7 +144,8 @@ public final class CastTrayNotifier {
public static Notification buildNotification(Context context) {
createChannel(context);
- boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening();
+ boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverStreaming();
+ boolean listening = CastActiveState.isReceiverListening() && !casting;
boolean adbEnabled = BuildConfig.DEBUG && AppPreferences.isDevAdbWifiKeeperEnabled(context);
RemoteAccessMode raMode = BuildConfig.DEBUG
? AppPreferences.getDevRemoteAccessMode(context)
@@ -139,16 +153,27 @@ public final class CastTrayNotifier {
RemoteAccessStatusStore.Snapshot raSnap = RemoteAccessStatusStore.load(context);
DevAdbConnectInfo adbInfo = adbEnabled ? DevAdbWifiKeeper.getLastInfo(context) : null;
String adbLine = adbInfo != null ? adbInfo.primaryConnectLine() : "";
+ String devHttpHost = "";
+ if (adbInfo != null && !adbInfo.ips.isEmpty()) {
+ devHttpHost = adbInfo.ips.get(0);
+ }
CastTrayContent.Body body = CastTrayContent.build(
context.getString(R.string.tray_cast_idle),
context.getString(R.string.tray_cast_active),
+ context.getString(R.string.tray_listening),
+ CastTrayStatus.getReceiverLine(),
+ CastTrayStatus.getSenderLine(),
context.getString(R.string.dev_adb_wifi_notification_waiting),
context.getString(R.string.dev_adb_wifi_notification_line),
+ context.getString(R.string.tray_dev_http_line),
+ devHttpHost,
+ DevAdbConnectInfo.HTTP_PORT,
context.getString(R.string.dev_remote_access_notification_poll),
context.getString(R.string.dev_remote_access_notification_connected),
context.getString(R.string.dev_remote_access_notification_session),
casting,
+ listening,
adbEnabled,
adbLine,
raMode,
@@ -184,7 +209,7 @@ public final class CastTrayNotifier {
return builder.build();
}
- static void publish(Context context) {
+ public static void publish(Context context) {
if (!shouldMaintainTray(context)) {
return;
}
@@ -202,6 +227,8 @@ public final class CastTrayNotifier {
nm.cancel(LEGACY_DEV_ADB_ID);
nm.cancel(LEGACY_RA_ID);
nm.cancel(LEGACY_RA_VPN_ID);
+ nm.cancel(LEGACY_RECEIVER_ID);
+ nm.cancel(LEGACY_SENDER_ID);
}
private static void hide(Context context) {
diff --git a/app/src/main/java/com/foxx/androidcast/CastTrayStatus.java b/app/src/main/java/com/foxx/androidcast/CastTrayStatus.java
new file mode 100644
index 0000000..e5cef02
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/CastTrayStatus.java
@@ -0,0 +1,35 @@
+package com.foxx.androidcast;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Process-wide tray status lines for foreground cast services. */
+public final class CastTrayStatus {
+ private static final AtomicReference receiverLine = new AtomicReference<>("");
+ private static final AtomicReference senderLine = new AtomicReference<>("");
+
+ private CastTrayStatus() {}
+
+ public static void setReceiverLine(String line) {
+ receiverLine.set(line != null ? line : "");
+ }
+
+ public static String getReceiverLine() {
+ return receiverLine.get();
+ }
+
+ public static void clearReceiverLine() {
+ receiverLine.set("");
+ }
+
+ public static void setSenderLine(String line) {
+ senderLine.set(line != null ? line : "");
+ }
+
+ public static String getSenderLine() {
+ return senderLine.get();
+ }
+
+ public static void clearSenderLine() {
+ senderLine.set("");
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
index 2ed2601..1db8f29 100644
--- a/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DeveloperSettingsActivity.java
@@ -30,6 +30,7 @@ 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.OtaDefaults;
import com.foxx.androidcast.ota.OtaUpdateChecker;
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
@@ -117,6 +118,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
checkOta.setOnClickListener(v -> {
String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : "";
+ if (url.isEmpty()) {
+ url = OtaDefaults.effectiveChannelUrl(this);
+ }
AppPreferences.setOtaChannelUrl(this, url);
OtaUpdateCoordinator.requestManualCheck(this);
});
@@ -692,14 +696,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
OtaUpdateChecker.installedVersionCode(this)));
}
if (manifestUrl != null) {
- String url = AppPreferences.getOtaChannelUrl(this);
- if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
- url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
- }
- if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
- url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
- }
- manifestUrl.setText(url);
+ manifestUrl.setText(OtaDefaults.effectiveChannelUrl(this));
}
if (otaOnLaunch != null) {
otaOnLaunch.setOnCheckedChangeListener(null);
diff --git a/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java
index 142bbc1..a14f193 100644
--- a/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/DrawerHostActivity.java
@@ -115,6 +115,12 @@ public abstract class DrawerHostActivity extends AppCompatActivity {
CastActivityTracker.onResume(this);
}
+ @Override
+ protected void onPause() {
+ CastActivityTracker.onPause(this);
+ super.onPause();
+ }
+
protected void openSettingsDrawer() {
if (drawerLayout != null) {
drawerLayout.openDrawer(GravityCompat.START);
diff --git a/app/src/main/java/com/foxx/androidcast/MainActivity.java b/app/src/main/java/com/foxx/androidcast/MainActivity.java
index db618c8..2f5d9cc 100644
--- a/app/src/main/java/com/foxx/androidcast/MainActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/MainActivity.java
@@ -16,6 +16,7 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
+import android.view.View;
import android.os.Bundle;
import com.foxx.androidcast.dev.DevInstallWakeHelper;
@@ -67,11 +68,7 @@ public class MainActivity extends DrawerHostActivity {
findViewById(R.id.btn_send).setOnClickListener(v ->
startActivity(new Intent(this, SenderActivity.class)));
- findViewById(R.id.btn_receive).setOnClickListener(v -> {
- Intent i = new Intent(this, ReceiverPlaybackActivity.class);
- i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
- startActivity(i);
- });
+ findViewById(R.id.btn_receive).setVisibility(View.GONE);
}
@Override
diff --git a/app/src/main/java/com/foxx/androidcast/boot/BootCompletedReceiver.java b/app/src/main/java/com/foxx/androidcast/boot/BootCompletedReceiver.java
new file mode 100644
index 0000000..b776e80
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/boot/BootCompletedReceiver.java
@@ -0,0 +1,26 @@
+package com.foxx.androidcast.boot;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+/** Starts background cast services after reboot without opening any activity. */
+public final class BootCompletedReceiver extends BroadcastReceiver {
+ private static final String TAG = "BootCompletedReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || intent.getAction() == null) {
+ return;
+ }
+ String action = intent.getAction();
+ if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
+ && !"android.intent.action.QUICKBOOT_POWERON".equals(action)
+ && !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) {
+ return;
+ }
+ Log.i(TAG, "boot event: " + action);
+ CastBootCoordinator.onDeviceBoot(context);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/boot/CastBootCoordinator.java b/app/src/main/java/com/foxx/androidcast/boot/CastBootCoordinator.java
new file mode 100644
index 0000000..315fc67
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/boot/CastBootCoordinator.java
@@ -0,0 +1,58 @@
+package com.foxx.androidcast.boot;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import androidx.core.content.ContextCompat;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.CastTrayNotifier;
+import com.foxx.androidcast.receiver.ReceiverCastService;
+
+/** Restores background cast services after reboot or cold process start (no UI). */
+public final class CastBootCoordinator {
+ private static final String TAG = "CastBootCoordinator";
+
+ private CastBootCoordinator() {}
+
+ /** Called from {@link com.foxx.androidcast.AndroidCastApplication} on main process start. */
+ public static void onProcessStart(Context context) {
+ Context app = context.getApplicationContext();
+ if (AppPreferences.isBackgroundListenEnabled(app)) {
+ startHeadlessReceiver(app, "process_start");
+ }
+ CastTrayNotifier.refresh(app);
+ }
+
+ /** Called from {@link BootCompletedReceiver} after device reboot. */
+ public static void onDeviceBoot(Context context) {
+ Context app = context.getApplicationContext();
+ Log.i(TAG, "device boot — background_listen=" + AppPreferences.isBackgroundListenEnabled(app));
+ onProcessStart(app);
+ }
+
+ public static void applyBackgroundListenPreference(Context context, boolean enabled) {
+ Context app = context.getApplicationContext();
+ AppPreferences.setBackgroundListenEnabled(app, enabled);
+ if (enabled) {
+ startHeadlessReceiver(app, "pref_enabled");
+ } else {
+ ReceiverCastService.stop(app);
+ }
+ CastTrayNotifier.refresh(app);
+ }
+
+ private static void startHeadlessReceiver(Context app, String reason) {
+ try {
+ Intent intent = new Intent(app, ReceiverCastService.class);
+ intent.setAction(ReceiverCastService.ACTION_START);
+ intent.putExtra(ReceiverCastService.EXTRA_HEADLESS, true);
+ intent.putExtra(ReceiverCastService.EXTRA_REQUIRE_INCOMING_PROMPT, true);
+ ContextCompat.startForegroundService(app, intent);
+ Log.i(TAG, "headless receiver started (" + reason + ")");
+ } catch (Exception e) {
+ Log.w(TAG, "headless receiver start failed: " + e.getMessage());
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java
index c1f95ce..2133127 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevDiagnosticsProbe.java
@@ -32,10 +32,14 @@ import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.transport.StreamProtectionCapability;
import com.foxx.androidcast.sender.CodecCatalog;
+import com.foxx.androidcast.ota.OtaDefaults;
+
import java.io.IOException;
+import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -166,7 +170,7 @@ public final class DevDiagnosticsProbe {
StringBuilder sb = new StringBuilder();
sb.append(connectivitySummary(context)).append('\n');
String crashUrl = resolveCrashUploadUrl(context);
- sb.append('\n').append(probeHttp("Crash upload", crashUrl, cancel));
+ sb.append('\n').append(probeCrashUpload("Crash upload", crashUrl, cancel));
appendOtaProbe(sb, context, cancel);
sb.append('\n').append("-----").append('\n');
sb.append(DevVpnStatusProbe.formatForDiagnostics(context));
@@ -366,6 +370,52 @@ public final class DevDiagnosticsProbe {
return "Connectivity: " + (parts.isEmpty() ? "other" : String.join(", ", parts));
}
+ /** POST-only upload endpoint — reject probe body with 400 when reachable. */
+ private static String probeCrashUpload(String label, String url, AtomicBoolean cancel) {
+ if (cancel != null && cancel.get()) {
+ return label + ": stopped";
+ }
+ if (url == null || url.trim().isEmpty()) {
+ return label + ": (no URL configured)";
+ }
+ String trimmed = url.trim();
+ long t0 = System.currentTimeMillis();
+ try {
+ String host = Uri.parse(trimmed).getHost();
+ if (host == null || host.isEmpty()) {
+ return label + ": invalid URL";
+ }
+ InetAddress addr = InetAddress.getByName(host);
+ long dnsMs = System.currentTimeMillis() - t0;
+ if (cancel != null && cancel.get()) {
+ return label + ": stopped";
+ }
+ HttpURLConnection conn = (HttpURLConnection) new URL(trimmed).openConnection();
+ conn.setConnectTimeout(HTTP_TIMEOUT_MS);
+ conn.setReadTimeout(HTTP_TIMEOUT_MS);
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setInstanceFollowRedirects(true);
+ conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+ byte[] body = "{\"schema_version\":0}".getBytes(StandardCharsets.UTF_8);
+ try (OutputStream out = conn.getOutputStream()) {
+ out.write(body);
+ }
+ int code = conn.getResponseCode();
+ long totalMs = System.currentTimeMillis() - t0;
+ conn.disconnect();
+ if (code == 400) {
+ return label + ": reachable (HTTP 400 on probe — POST-only endpoint), DNS "
+ + dnsMs + " ms, total " + totalMs + " ms (" + addr.getHostAddress() + ")";
+ }
+ return label + ": HTTP " + code + ", DNS " + dnsMs + " ms, total " + totalMs + " ms"
+ + " (" + addr.getHostAddress() + ")";
+ } catch (IOException e) {
+ return label + ": fail — " + e.getClass().getSimpleName() + ": " + e.getMessage()
+ + " (" + (System.currentTimeMillis() - t0) + " ms)";
+ }
+ }
+
private static String probeHttp(String label, String url, AtomicBoolean cancel) {
if (cancel != null && cancel.get()) {
return label + ": stopped";
@@ -406,11 +456,7 @@ public final class DevDiagnosticsProbe {
}
private static String resolveOtaUrl(Context context) {
- String url = AppPreferences.getOtaChannelUrl(context);
- if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
- url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
- }
- return url;
+ return OtaDefaults.effectiveChannelUrl(context);
}
private static String miracastSummary(PackageManager pm) {
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java
index 8fb471a..c87748e 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevNetworkSelfTestProbe.java
@@ -12,6 +12,7 @@ import android.os.PowerManager;
import android.telephony.TelephonyManager;
import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.crash.CrashSettingsStore;
import com.foxx.androidcast.discovery.CastLanPeerProbe;
@@ -248,7 +249,8 @@ public final class DevNetworkSelfTestProbe {
r.itemPass("local-tz-offset", tzMin + " min");
boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context);
r.itemBool(enabled ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.NA,
- "be-ntp-sync", enabled, "enabled (±3 min threshold)", "disabled (dev setting)");
+ "be-ntp-sync", enabled, "enabled (±3 min threshold)",
+ BuildConfig.DEBUG ? "disabled (dev setting; default on in debug)" : "disabled (dev setting)");
if (!enabled) {
return;
}
diff --git a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
index 54d49e9..5b8551b 100644
--- a/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
+++ b/app/src/main/java/com/foxx/androidcast/dev/DevVpnStatusProbe.java
@@ -88,7 +88,7 @@ public final class DevVpnStatusProbe {
} else {
tunnelLevel = DevNetworkSelfTestReport.Level.WARN;
}
- String tunnelLabel = formatTunnelStateLabel(tunnelState, last);
+ String tunnelLabel = formatTunnelStateLabel(mode, tunnelState, last);
r.item(tunnelLevel, "tunnel-state", tunnelLabel);
if (mode != RemoteAccessMode.DISABLED
@@ -247,7 +247,13 @@ public final class DevVpnStatusProbe {
return "not_whitelisted".equals(detail);
}
- private static String formatTunnelStateLabel(String tunnelState, RemoteAccessStatusStore.Snapshot last) {
+ private static String formatTunnelStateLabel(
+ RemoteAccessMode mode,
+ String tunnelState,
+ RemoteAccessStatusStore.Snapshot last) {
+ if (mode == RemoteAccessMode.DISABLED) {
+ return "off (remote access disabled)";
+ }
if (RemoteAccessStatusStore.STATE_WAIT.equals(last.state)) {
if (isAwaitingWhitelist(last.detail)) {
return "idle (awaiting whitelist — no tunnel expected)";
diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSession.java b/app/src/main/java/com/foxx/androidcast/network/CastSession.java
index c1af5cd..aff5916 100644
--- a/app/src/main/java/com/foxx/androidcast/network/CastSession.java
+++ b/app/src/main/java/com/foxx/androidcast/network/CastSession.java
@@ -12,6 +12,9 @@ package com.foxx.androidcast.network;
* Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025
**********************************************************************/
import com.foxx.androidcast.CastSettings;
+import com.foxx.androidcast.receiver.BlockedSendersStore;
+import com.foxx.androidcast.receiver.IncomingCastGate;
+import com.foxx.androidcast.receiver.IncomingCastPinGate;
import com.foxx.androidcast.media.AudioNegotiator;
import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.CodecPriorityCatalog;
@@ -23,6 +26,8 @@ import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.IOException;
import java.util.List;
+import android.content.Context;
+
/** Auth handshake and message I/O over a {@link CastTransport}. */
public class CastSession {
public static final class HandshakeResult {
@@ -112,10 +117,16 @@ public class CastSession {
}
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
+ return serverHandshake(expectedPin, localSettings, null, false);
+ }
+
+ public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings,
+ Context appContext, boolean promptIncoming) throws IOException {
String senderName = "Sender";
boolean authed = false;
CastSettings remote = null;
List senderCaps = null;
+ boolean incomingPromptDone = false;
long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) {
CastProtocol.Message msg = receive(2_000);
@@ -125,12 +136,51 @@ public class CastSession {
switch (msg.type) {
case CastProtocol.MSG_HELLO:
senderName = CastProtocol.payloadAsUtf8(msg.payload);
+ if (promptIncoming && appContext != null && !incomingPromptDone) {
+ incomingPromptDone = true;
+ if (BlockedSendersStore.isBlocked(appContext, senderName)) {
+ transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ throw new IOException("Blocked sender");
+ }
+ try {
+ IncomingCastGate.Decision decision =
+ IncomingCastGate.awaitDecision(appContext, senderName);
+ if (decision != IncomingCastGate.Decision.ACCEPT) {
+ transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ throw new IOException("Incoming cast declined");
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Incoming cast interrupted");
+ }
+ }
break;
case CastProtocol.MSG_AUTH:
- if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
+ String pinForAuth = expectedPin;
+ if (promptIncoming && appContext != null) {
+ try {
+ String entered = IncomingCastPinGate.awaitPin(
+ appContext, senderName, msg.payload);
+ if (entered == null) {
+ transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
+ throw new IOException("PIN entry cancelled");
+ }
+ pinForAuth = entered;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("PIN entry interrupted");
+ }
+ }
+ if (CastProtocol.pinMatches(msg.payload, pinForAuth)) {
authed = true;
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
+ if (promptIncoming && appContext != null) {
+ IncomingCastGate.onAccepted(appContext);
+ }
} else {
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaDefaults.java b/app/src/main/java/com/foxx/androidcast/ota/OtaDefaults.java
new file mode 100644
index 0000000..fc5f201
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/ota/OtaDefaults.java
@@ -0,0 +1,36 @@
+package com.foxx.androidcast.ota;
+
+import android.content.Context;
+
+import com.foxx.androidcast.AppPreferences;
+import com.foxx.androidcast.BuildConfig;
+
+/** Built-in OTA channel fallback when prefs / BuildConfig / settings.json omit a URL. */
+public final class OtaDefaults {
+ /** Canonical stable channel on the public apps host. */
+ public static final String FALLBACK_CHANNEL_URL =
+ "https://apps.f0xx.org/v0/ota/channel/stable.json";
+
+ private OtaDefaults() {}
+
+ /** Preference → BuildConfig channel → BuildConfig manifest → {@link #FALLBACK_CHANNEL_URL}. */
+ public static String effectiveChannelUrl(Context context) {
+ String url = AppPreferences.getOtaChannelUrl(context);
+ if (!url.isEmpty()) {
+ return url;
+ }
+ if (BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
+ url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
+ if (!url.isEmpty()) {
+ return url;
+ }
+ }
+ if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
+ url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
+ if (!url.isEmpty()) {
+ return url;
+ }
+ }
+ return FALLBACK_CHANNEL_URL;
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java b/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java
index fbd0151..9ac1792 100644
--- a/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java
+++ b/app/src/main/java/com/foxx/androidcast/ota/OtaSettingsStore.java
@@ -57,6 +57,7 @@ final class OtaSettingsStore {
}
}
}
+ addSource(dedup, OtaDefaults.FALLBACK_CHANNEL_URL);
List urls = new ArrayList<>(dedup.keySet());
long interval = OtaRuntimeSettings.DEFAULT_CHECK_INTERVAL_MS;
@@ -136,7 +137,7 @@ final class OtaSettingsStore {
if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
return BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
}
- return "https://foxx.org/v0/ota/channel/stable.json";
+ return OtaDefaults.FALLBACK_CHANNEL_URL;
}
static long parseDurationMs(String raw, long fallback) {
diff --git a/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java
index 842bf11..874f1b1 100644
--- a/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java
+++ b/app/src/main/java/com/foxx/androidcast/ota/OtaUpdateCoordinator.java
@@ -34,15 +34,7 @@ public final class OtaUpdateCoordinator {
private OtaUpdateCoordinator() {}
public static String resolveEntryUrl(Context context) {
- String url = AppPreferences.getOtaChannelUrl(context);
- if (url.isEmpty()) {
- url = BuildConfig.OTA_CHANNEL_URL_DEFAULT != null
- ? BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim() : "";
- }
- if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
- url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
- }
- return url;
+ return OtaDefaults.effectiveChannelUrl(context);
}
public static void requestCheckOnLaunch(Context context) {
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/BlockReportUploader.java b/app/src/main/java/com/foxx/androidcast/receiver/BlockReportUploader.java
new file mode 100644
index 0000000..e6c9207
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/BlockReportUploader.java
@@ -0,0 +1,98 @@
+package com.foxx.androidcast.receiver;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import com.foxx.androidcast.AppPreferences;
+
+/** Queues block reports locally; POST to BE when configured (spam protection pipeline). */
+public final class BlockReportUploader {
+ private static final String TAG = "BlockReportUploader";
+ private static final String PREFS = "cast_block_reports";
+ private static final String KEY_QUEUE = "queue_json";
+ private static final ExecutorService EXEC = Executors.newSingleThreadExecutor();
+
+ private BlockReportUploader() {}
+
+ public static void enqueue(Context context, String blockedSender) {
+ Context app = context.getApplicationContext();
+ JSONArray queue = loadQueue(app);
+ JSONObject row = new JSONObject();
+ try {
+ row.put("blocked_sender", blockedSender);
+ row.put("blocked_fingerprint", BlockedSendersStore.fingerprint(blockedSender));
+ row.put("reporter_device", AppPreferences.getUsername(app));
+ row.put("created_ms", System.currentTimeMillis());
+ queue.put(row);
+ saveQueue(app, queue);
+ } catch (Exception e) {
+ Log.w(TAG, "queue block report failed", e);
+ return;
+ }
+ EXEC.execute(() -> flush(app));
+ }
+
+ static void flush(Context context) {
+ Context app = context.getApplicationContext();
+ String endpoint = AppPreferences.getBlockReportEndpoint(app);
+ if (endpoint == null || endpoint.isEmpty()) {
+ return;
+ }
+ JSONArray queue = loadQueue(app);
+ if (queue.length() == 0) {
+ return;
+ }
+ try {
+ URL url = new URL(endpoint);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setConnectTimeout(8000);
+ conn.setReadTimeout(12000);
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+ byte[] body = queue.toString().getBytes(StandardCharsets.UTF_8);
+ conn.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(body);
+ }
+ int code = conn.getResponseCode();
+ if (code >= 200 && code < 300) {
+ saveQueue(app, new JSONArray());
+ Log.i(TAG, "block reports delivered (" + queue.length() + ")");
+ } else {
+ Log.w(TAG, "block report HTTP " + code);
+ }
+ conn.disconnect();
+ } catch (Exception e) {
+ Log.w(TAG, "block report upload failed: " + e.getMessage());
+ }
+ }
+
+ private static JSONArray loadQueue(Context app) {
+ String raw = prefs(app).getString(KEY_QUEUE, "[]");
+ try {
+ return new JSONArray(raw);
+ } catch (Exception e) {
+ return new JSONArray();
+ }
+ }
+
+ private static void saveQueue(Context app, JSONArray queue) {
+ prefs(app).edit().putString(KEY_QUEUE, queue.toString()).apply();
+ }
+
+ private static SharedPreferences prefs(Context app) {
+ return app.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/BlockedSendersStore.java b/app/src/main/java/com/foxx/androidcast/receiver/BlockedSendersStore.java
new file mode 100644
index 0000000..dacf54e
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/BlockedSendersStore.java
@@ -0,0 +1,63 @@
+package com.foxx.androidcast.receiver;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.text.TextUtils;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/** Local blacklist for cast senders (fingerprint = SHA-256 of normalized sender name). */
+public final class BlockedSendersStore {
+ private static final String PREFS = "cast_blocked_senders";
+ private static final String KEY_SET = "fingerprints";
+
+ private BlockedSendersStore() {}
+
+ public static String fingerprint(String senderName) {
+ String normalized = senderName != null ? senderName.trim().toLowerCase() : "";
+ if (normalized.isEmpty()) {
+ normalized = "unknown";
+ }
+ try {
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ byte[] hash = md.digest(normalized.getBytes(StandardCharsets.UTF_8));
+ StringBuilder sb = new StringBuilder(hash.length * 2);
+ for (byte b : hash) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ return normalized;
+ }
+ }
+
+ public static boolean isBlocked(Context context, String senderName) {
+ return prefs(context).getStringSet(KEY_SET, Collections.emptySet())
+ .contains(fingerprint(senderName));
+ }
+
+ public static void block(Context context, String senderName) {
+ if (TextUtils.isEmpty(senderName)) {
+ return;
+ }
+ SharedPreferences p = prefs(context);
+ Set next = new HashSet<>(p.getStringSet(KEY_SET, Collections.emptySet()));
+ next.add(fingerprint(senderName));
+ p.edit().putStringSet(KEY_SET, next).apply();
+ }
+
+ public static void unblock(Context context, String senderName) {
+ SharedPreferences p = prefs(context);
+ Set next = new HashSet<>(p.getStringSet(KEY_SET, Collections.emptySet()));
+ next.remove(fingerprint(senderName));
+ p.edit().putStringSet(KEY_SET, next).apply();
+ }
+
+ private static SharedPreferences prefs(Context context) {
+ return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActionReceiver.java b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActionReceiver.java
new file mode 100644
index 0000000..25883f8
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActionReceiver.java
@@ -0,0 +1,27 @@
+package com.foxx.androidcast.receiver;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+/** Notification action buttons for {@link IncomingCastGate}. */
+public final class IncomingCastActionReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent == null || !IncomingCastGate.ACTION_DECISION.equals(intent.getAction())) {
+ return;
+ }
+ String sender = intent.getStringExtra(IncomingCastGate.EXTRA_SENDER);
+ String raw = intent.getStringExtra(IncomingCastGate.EXTRA_DECISION);
+ IncomingCastGate.Decision decision;
+ try {
+ decision = IncomingCastGate.Decision.valueOf(raw != null ? raw : "DECLINE");
+ } catch (Exception e) {
+ decision = IncomingCastGate.Decision.DECLINE;
+ }
+ IncomingCastGate.deliverDecision(context, sender, decision);
+ if (decision == IncomingCastGate.Decision.ACCEPT) {
+ // Playback opens after PIN auth in CastSession.serverHandshake.
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActivity.java
new file mode 100644
index 0000000..c17d3b2
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastActivity.java
@@ -0,0 +1,44 @@
+package com.foxx.androidcast.receiver;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.TextView;
+
+import com.foxx.androidcast.R;
+
+/** Full-screen prompt for an incoming live cast (Accept / Decline / Block user). */
+public final class IncomingCastActivity extends Activity {
+ public static final String EXTRA_SENDER = IncomingCastGate.EXTRA_SENDER;
+
+ public static Intent intent(Context context, String senderName) {
+ Intent i = new Intent(context, IncomingCastActivity.class);
+ i.putExtra(EXTRA_SENDER, senderName);
+ return i;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_incoming_cast);
+ String senderRaw = getIntent().getStringExtra(EXTRA_SENDER);
+ final String sender = (senderRaw == null || senderRaw.isEmpty())
+ ? getString(R.string.incoming_cast_unknown_sender)
+ : senderRaw;
+ TextView message = findViewById(R.id.incoming_cast_message);
+ message.setText(getString(R.string.incoming_cast_message, sender));
+ Button accept = findViewById(R.id.incoming_cast_accept);
+ Button decline = findViewById(R.id.incoming_cast_decline);
+ Button block = findViewById(R.id.incoming_cast_block);
+ accept.setOnClickListener(v -> finishWith(IncomingCastGate.Decision.ACCEPT, senderRaw));
+ decline.setOnClickListener(v -> finishWith(IncomingCastGate.Decision.DECLINE, senderRaw));
+ block.setOnClickListener(v -> finishWith(IncomingCastGate.Decision.BLOCK, senderRaw));
+ }
+
+ private void finishWith(IncomingCastGate.Decision decision, String sender) {
+ IncomingCastGate.deliverDecision(this, sender, decision);
+ finish();
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastGate.java b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastGate.java
new file mode 100644
index 0000000..3db27a6
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastGate.java
@@ -0,0 +1,174 @@
+package com.foxx.androidcast.receiver;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+import com.foxx.androidcast.R;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Blocks receiver handshake until the user accepts, declines, or blocks an incoming cast.
+ * Shows a heads-up notification and optional full-screen {@link IncomingCastActivity}.
+ */
+public final class IncomingCastGate {
+ private static final String TAG = "IncomingCastGate";
+ public static final String CHANNEL = "cast_incoming";
+ public static final int NOTIFICATION_ID = 0x7ac01;
+
+ public static final String ACTION_DECISION = "com.foxx.androidcast.INCOMING_CAST_DECISION";
+ public static final String EXTRA_SENDER = "sender_name";
+ public static final String EXTRA_DECISION = "decision";
+
+ public enum Decision {
+ ACCEPT, DECLINE, BLOCK, TIMEOUT
+ }
+
+ private static final AtomicReference ACTIVE = new AtomicReference<>();
+
+ private IncomingCastGate() {}
+
+ public static Decision awaitDecision(Context context, String senderName) throws InterruptedException {
+ Context app = context.getApplicationContext();
+ if (BlockedSendersStore.isBlocked(app, senderName)) {
+ return Decision.BLOCK;
+ }
+ PendingRequest req = new PendingRequest(senderName);
+ PendingRequest prev = ACTIVE.getAndSet(req);
+ if (prev != null) {
+ prev.complete(Decision.DECLINE);
+ }
+ showPrompt(app, senderName, req);
+ Decision decision = req.await(60, TimeUnit.SECONDS);
+ ACTIVE.compareAndSet(req, null);
+ cancelPrompt(app);
+ if (decision == Decision.BLOCK) {
+ BlockedSendersStore.block(app, senderName);
+ BlockReportUploader.enqueue(app, senderName);
+ }
+ return decision;
+ }
+
+ public static void deliverDecision(Context context, String senderName, Decision decision) {
+ PendingRequest req = ACTIVE.get();
+ if (req == null || !req.senderName.equals(senderName)) {
+ return;
+ }
+ req.complete(decision);
+ cancelPrompt(context.getApplicationContext());
+ }
+
+ public static void onAccepted(Context context) {
+ Context app = context.getApplicationContext();
+ Intent playback = new Intent(app, ReceiverCastService.class);
+ playback.setAction(ReceiverCastService.ACTION_PLAYBACK_UI);
+ playback.putExtra(ReceiverCastService.EXTRA_PLAYBACK_UI_VISIBLE, true);
+ app.startService(playback);
+ Intent ui = new Intent(app, ReceiverPlaybackActivity.class);
+ ui.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ ui.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
+ ui.putExtra(ReceiverPlaybackActivity.EXTRA_SHOW_AWAITING, true);
+ ui.putExtra(ReceiverPlaybackActivity.EXTRA_AWAITING_MESSAGE, R.string.playback_awaiting_stream);
+ app.startActivity(ui);
+ }
+
+ private static void showPrompt(Context app, String senderName, PendingRequest req) {
+ ensureChannel(app);
+ Intent full = IncomingCastActivity.intent(app, senderName);
+ full.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ PendingIntent fullPi = PendingIntent.getActivity(app, 0, full,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ Notification notification = new NotificationCompat.Builder(app, CHANNEL)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(app.getString(R.string.incoming_cast_title))
+ .setContentText(app.getString(R.string.incoming_cast_message, senderName))
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .setAutoCancel(true)
+ .setContentIntent(fullPi)
+ .setFullScreenIntent(fullPi, true)
+ .addAction(R.drawable.ic_launcher_foreground,
+ app.getString(R.string.incoming_cast_accept),
+ actionPending(app, senderName, Decision.ACCEPT, 1))
+ .addAction(R.drawable.ic_launcher_foreground,
+ app.getString(R.string.incoming_cast_decline),
+ actionPending(app, senderName, Decision.DECLINE, 2))
+ .build();
+ NotificationManager nm = app.getSystemService(NotificationManager.class);
+ if (nm != null) {
+ nm.notify(NOTIFICATION_ID, notification);
+ }
+ new Handler(Looper.getMainLooper()).post(() -> {
+ try {
+ app.startActivity(full);
+ } catch (Exception e) {
+ Log.w(TAG, "full-screen incoming cast activity skipped: " + e.getMessage());
+ }
+ });
+ }
+
+ private static PendingIntent actionPending(Context app, String senderName, Decision d, int reqCode) {
+ Intent intent = new Intent(app, IncomingCastActionReceiver.class);
+ intent.setAction(ACTION_DECISION);
+ intent.putExtra(EXTRA_SENDER, senderName);
+ intent.putExtra(EXTRA_DECISION, d.name());
+ return PendingIntent.getBroadcast(app, reqCode, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ }
+
+ private static void cancelPrompt(Context app) {
+ NotificationManager nm = app.getSystemService(NotificationManager.class);
+ if (nm != null) {
+ nm.cancel(NOTIFICATION_ID);
+ }
+ }
+
+ private static void ensureChannel(Context context) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ return;
+ }
+ NotificationManager nm = context.getSystemService(NotificationManager.class);
+ if (nm == null) {
+ return;
+ }
+ NotificationChannel ch = new NotificationChannel(CHANNEL,
+ context.getString(R.string.incoming_cast_channel),
+ NotificationManager.IMPORTANCE_HIGH);
+ ch.setDescription(context.getString(R.string.incoming_cast_channel_desc));
+ nm.createNotificationChannel(ch);
+ }
+
+ private static final class PendingRequest {
+ final String senderName;
+ private final CountDownLatch latch = new CountDownLatch(1);
+ private volatile Decision decision = Decision.TIMEOUT;
+
+ PendingRequest(String senderName) {
+ this.senderName = senderName != null ? senderName : "Sender";
+ }
+
+ Decision await(long timeout, TimeUnit unit) throws InterruptedException {
+ if (!latch.await(timeout, unit)) {
+ return Decision.TIMEOUT;
+ }
+ return decision;
+ }
+
+ void complete(Decision d) {
+ decision = d != null ? d : Decision.DECLINE;
+ latch.countDown();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinActivity.java
new file mode 100644
index 0000000..5eeb91a
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinActivity.java
@@ -0,0 +1,45 @@
+package com.foxx.androidcast.receiver;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import com.foxx.androidcast.R;
+
+/** PIN entry after the user accepted an incoming live cast from a protected sender. */
+public final class IncomingCastPinActivity extends Activity {
+ public static Intent intent(Context context, String senderName) {
+ Intent i = new Intent(context, IncomingCastPinActivity.class);
+ i.putExtra(IncomingCastPinGate.EXTRA_SENDER, senderName);
+ return i;
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_incoming_cast_pin);
+ String sender = getIntent().getStringExtra(IncomingCastPinGate.EXTRA_SENDER);
+ if (sender == null || sender.isEmpty()) {
+ sender = getString(R.string.incoming_cast_unknown_sender);
+ }
+ TextView message = findViewById(R.id.incoming_pin_message);
+ message.setText(getString(R.string.incoming_cast_pin_message, sender));
+ EditText pinInput = findViewById(R.id.incoming_pin_input);
+ Button submit = findViewById(R.id.incoming_pin_submit);
+ Button cancel = findViewById(R.id.incoming_pin_cancel);
+ submit.setOnClickListener(v -> {
+ IncomingCastPinGate.deliverPin(
+ getIntent().getStringExtra(IncomingCastPinGate.EXTRA_SENDER),
+ pinInput.getText().toString().trim());
+ finish();
+ });
+ cancel.setOnClickListener(v -> {
+ IncomingCastPinGate.cancel(getIntent().getStringExtra(IncomingCastPinGate.EXTRA_SENDER));
+ finish();
+ });
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinGate.java b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinGate.java
new file mode 100644
index 0000000..4771e3b
--- /dev/null
+++ b/app/src/main/java/com/foxx/androidcast/receiver/IncomingCastPinGate.java
@@ -0,0 +1,94 @@
+package com.foxx.androidcast.receiver;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Prompts the user for the sender's cast PIN after accepting an incoming live cast. */
+public final class IncomingCastPinGate {
+ public static final String EXTRA_SENDER = "sender_name";
+
+ private static final AtomicReference ACTIVE = new AtomicReference<>();
+
+ private IncomingCastPinGate() {}
+
+ /**
+ * Blocks until the user submits a PIN or cancels. Returns {@code null} on cancel/timeout.
+ * Skips UI when the sender sent an empty PIN (no protection).
+ */
+ public static String awaitPin(Context context, String senderName, byte[] authPayload)
+ throws InterruptedException {
+ if (authPayload != null && CastProtocolEmptyPin.isEmptyPinAuth(authPayload)) {
+ return "";
+ }
+ Context app = context.getApplicationContext();
+ PendingPin req = new PendingPin(senderName);
+ PendingPin prev = ACTIVE.getAndSet(req);
+ if (prev != null) {
+ prev.complete(null);
+ }
+ new Handler(Looper.getMainLooper()).post(() -> {
+ Intent i = IncomingCastPinActivity.intent(app, senderName);
+ i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ app.startActivity(i);
+ } catch (Exception ignored) {
+ req.complete(null);
+ }
+ });
+ String pin = req.await(120, TimeUnit.SECONDS);
+ ACTIVE.compareAndSet(req, null);
+ return pin;
+ }
+
+ static void deliverPin(String senderName, String pin) {
+ PendingPin req = ACTIVE.get();
+ if (req == null || !req.senderName.equals(senderName)) {
+ return;
+ }
+ req.complete(pin);
+ }
+
+ static void cancel(String senderName) {
+ PendingPin req = ACTIVE.get();
+ if (req != null && req.senderName.equals(senderName)) {
+ req.complete(null);
+ }
+ }
+
+ /** Detects SHA-256 hash of empty PIN in {@link com.foxx.androidcast.network.CastProtocol#MSG_AUTH}. */
+ static final class CastProtocolEmptyPin {
+ private CastProtocolEmptyPin() {}
+
+ static boolean isEmptyPinAuth(byte[] authHashUtf8) {
+ return com.foxx.androidcast.network.CastProtocol.pinMatches(authHashUtf8, "");
+ }
+ }
+
+ private static final class PendingPin {
+ final String senderName;
+ private final CountDownLatch latch = new CountDownLatch(1);
+ private volatile String pin;
+
+ PendingPin(String senderName) {
+ this.senderName = senderName != null ? senderName : "Sender";
+ }
+
+ String await(long timeout, TimeUnit unit) throws InterruptedException {
+ if (!latch.await(timeout, unit)) {
+ return null;
+ }
+ return pin;
+ }
+
+ void complete(String value) {
+ pin = value;
+ latch.countDown();
+ }
+ }
+}
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
index 21efe28..2fab4a9 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java
@@ -13,6 +13,7 @@ package com.foxx.androidcast.receiver;
**********************************************************************/
import android.app.NotificationManager;
import android.app.Service;
+import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
@@ -30,6 +31,7 @@ import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTrayNotifier;
+import com.foxx.androidcast.CastTrayStatus;
import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras;
@@ -77,6 +79,14 @@ public class ReceiverCastService extends Service {
public static final String EXTRA_PLAYBACK_UI_VISIBLE = "playback_ui_visible";
public static final String EXTRA_PIN = "pin";
public static final String EXTRA_ALLOW_AUDIO = "allow_audio";
+ public static final String EXTRA_HEADLESS = "headless";
+ public static final String EXTRA_REQUIRE_INCOMING_PROMPT = "require_incoming_prompt";
+
+ public static void stop(Context context) {
+ Intent intent = new Intent(context, ReceiverCastService.class);
+ intent.setAction(ACTION_STOP);
+ context.startService(intent);
+ }
private enum Phase {
IDLE, LISTENING, CONNECTED, STREAMING
@@ -114,6 +124,8 @@ public class ReceiverCastService extends Service {
private int decoderHeight;
private boolean decoderActive;
+ /** Prompt Accept/Decline/Block before completing handshake (background receive mode). */
+ private volatile boolean requireIncomingPrompt;
/** Set from receiver UI before each listen session. */
private boolean allowIncomingAudio = true;
/** null until first audio config this session; then mirrors user preference. */
@@ -210,12 +222,19 @@ public class ReceiverCastService extends Service {
}
if (intent != null && ACTION_PLAYBACK_UI.equals(intent.getAction())) {
playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false);
+ if (playbackUiVisible) {
+ requireIncomingPrompt = false;
+ }
if (!playbackUiVisible) {
playbackLaunched = false;
}
return START_STICKY;
}
if (intent != null && ACTION_START.equals(intent.getAction())) {
+ requireIncomingPrompt = intent.getBooleanExtra(EXTRA_REQUIRE_INCOMING_PROMPT, false);
+ if (intent.getBooleanExtra(EXTRA_HEADLESS, false)) {
+ playbackUiVisible = false;
+ }
String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null) {
pin = AppPreferences.getPin(this);
@@ -256,10 +275,16 @@ public class ReceiverCastService extends Service {
playbackLaunched = false;
phase = Phase.LISTENING;
acquireWakeLock();
- startForeground(CastNotifications.ID_RECEIVER,
- CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
+ String statusLine = getString(R.string.receiver_listening);
+ CastTrayStatus.setReceiverLine(statusLine);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ CastTrayNotifier.startForeground(this,
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
+ } else {
+ CastTrayNotifier.startForeground(this, 0);
+ }
CastActiveState.setReceiverListening(true);
- CastTrayNotifier.refresh(this);
+ CastActiveState.setReceiverStreaming(false);
if (videoDecoder != null) {
videoDecoder.release();
@@ -462,7 +487,7 @@ public class ReceiverCastService extends Service {
public void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
mainHandler.post(() -> applyCodecRenegotiation(selection));
}
- }, this::enrichReceiverStats);
+ }, this::enrichReceiverStats, requireIncomingPrompt);
session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
openPlaybackAwaiting(R.string.playback_listening);
@@ -472,6 +497,8 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
phase = Phase.CONNECTED;
castEnded = false;
+ CastActiveState.setReceiverStreaming(true);
+ CastTrayNotifier.publish(this);
if (AppPreferences.isGrabSessionStats(this)) {
if (sessionStatsRecorder == null) {
sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
@@ -564,6 +591,8 @@ public class ReceiverCastService extends Service {
private void onSenderDisconnected() {
stopAudioPlaybackImmediate();
phase = Phase.LISTENING;
+ CastActiveState.setReceiverStreaming(false);
+ CastTrayNotifier.publish(this);
audioAccepted = null;
remoteCastSettings = null;
streamIdle = false;
@@ -698,6 +727,8 @@ public class ReceiverCastService extends Service {
boolean decoderWasActive = decoderActive;
phase = Phase.STREAMING;
streamIdle = false;
+ CastActiveState.setReceiverStreaming(true);
+ CastTrayNotifier.publish(this);
if (firstConfig) {
streamMetrics.reset();
}
@@ -1118,9 +1149,8 @@ public class ReceiverCastService extends Service {
private void updateStatus(String s) {
status = s;
broadcastStatus(s);
- NotificationManager nm = getSystemService(NotificationManager.class);
- nm.notify(CastNotifications.ID_RECEIVER,
- CastNotifications.receiver(this, s, ReceiverCastService.class));
+ CastTrayStatus.setReceiverLine(s);
+ CastTrayNotifier.publish(this);
}
private void broadcastStatus(String s) {
@@ -1200,6 +1230,8 @@ public class ReceiverCastService extends Service {
private void releaseAll() {
finishSessionStatsAsync();
CastActiveState.setReceiverListening(false);
+ CastActiveState.setReceiverStreaming(false);
+ CastTrayStatus.clearReceiverLine();
CastTrayNotifier.refresh(this);
phase = Phase.IDLE;
resetStreamState();
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
index 3fd59ad..1f77a7d 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java
@@ -222,6 +222,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
intent.setAction(ReceiverCastService.ACTION_START);
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
+ intent.putExtra(ReceiverCastService.EXTRA_REQUIRE_INCOMING_PROMPT, false);
intent.putExtra(CastSettings.EXTRA, settings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent);
diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
index a31d1bb..96e9aec 100644
--- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
+++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java
@@ -108,19 +108,26 @@ public class ReceiverSession {
private final CastSessionGate inboundSessionGate = new CastSessionGate();
private boolean loggedFirstVideoFrame;
private NetworkFeedbackManager networkFeedback;
- private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
+ private final PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
+ private final boolean requireIncomingPrompt;
public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener) {
- this(context, pin, localSettings, listener, null);
+ this(context, pin, localSettings, listener, null, false);
}
public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
PassThroughNetworkControlPlane.StatsEnricher statsEnricher) {
+ this(context, pin, localSettings, listener, statsEnricher, false);
+ }
+
+ public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
+ PassThroughNetworkControlPlane.StatsEnricher statsEnricher, boolean requireIncomingPrompt) {
this.appContext = context.getApplicationContext();
this.pin = pin;
this.localSettings = localSettings;
this.listener = listener;
this.statsEnricher = statsEnricher;
+ this.requireIncomingPrompt = requireIncomingPrompt;
}
public void start() {
@@ -197,7 +204,8 @@ public class ReceiverSession {
listener::onDiagPingSample);
}
session.acceptClient();
- CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
+ CastSession.HandshakeResult hs = session.serverHandshake(
+ pin, localSettings, appContext, requireIncomingPrompt);
applyNegotiatedProtection(hs.settings);
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
postStatus("Streaming from " + hs.senderName);
diff --git a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java
index 5136764..1d02aa6 100644
--- a/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java
+++ b/app/src/main/java/com/foxx/androidcast/remoteaccess/RemoteAccessHeartbeatClient.java
@@ -5,6 +5,7 @@ import android.util.Log;
import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.crash.CrashSettingsStore;
+import com.foxx.androidcast.ota.OtaDefaults;
import org.json.JSONObject;
@@ -31,7 +32,7 @@ public final class RemoteAccessHeartbeatClient {
return derived;
}
}
- String ota = com.foxx.androidcast.AppPreferences.getOtaChannelUrl(context);
+ String ota = OtaDefaults.effectiveChannelUrl(context);
if (ota != null && !ota.trim().isEmpty()) {
String derived = toHeartbeatUrl(normalizeBackendBase(ota.trim()));
if (!derived.isEmpty()) {
diff --git a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
index a4ba14d..4936350 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/DeviceListAdapter.java
@@ -122,6 +122,17 @@ public class DeviceListAdapter extends BaseAdapter {
return false;
}
+ public void selectAllVisible() {
+ selectedPositions.clear();
+ for (int i = 0; i < devices.size(); i++) {
+ selectedPositions.add(i);
+ }
+ if (!multiSelect && !devices.isEmpty()) {
+ selectedPosition = 0;
+ }
+ notifyDataSetChanged();
+ }
+
@Override
public int getCount() {
return devices.size();
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 9f93839..08ecc98 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java
@@ -37,6 +37,7 @@ 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.CastTrayStatus;
import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution;
@@ -1046,6 +1047,7 @@ public class ScreenCastService extends Service implements
stopping.set(true);
casting.set(false);
CastActiveState.setSenderCasting(false);
+ CastTrayStatus.clearSenderLine();
CastTrayNotifier.refresh(this);
networkFeedback = null;
calibrationMode = false;
@@ -1130,15 +1132,18 @@ public class ScreenCastService extends Service implements
}
private void startSenderForeground(String text, boolean withAudio) {
- android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
+ CastTrayStatus.setSenderLine(text);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
if (withAudio) {
fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
}
- startForeground(CastNotifications.ID_SENDER, n, fgsType);
+ CastTrayNotifier.startForeground(this, fgsType);
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ CastTrayNotifier.startForeground(this,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
} else {
- startForeground(CastNotifications.ID_SENDER, n);
+ CastTrayNotifier.startForeground(this, 0);
}
}
@@ -1189,9 +1194,8 @@ public class ScreenCastService extends Service implements
private void updateStatus(String text) {
status = text;
broadcastStatus(text);
- android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class);
- nm.notify(CastNotifications.ID_SENDER,
- CastNotifications.sender(this, text, ScreenCastService.class));
+ CastTrayStatus.setSenderLine(text);
+ CastTrayNotifier.publish(this);
}
private void broadcastStatus(String s) {
diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
index d4b06e0..c0b3df8 100644
--- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
+++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java
@@ -126,6 +126,20 @@ public class SenderActivity extends DrawerHostActivity {
? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE);
deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED);
listView.setAdapter(deviceAdapter);
+ Button selectAll = findViewById(R.id.btn_select_all_nearby);
+ if (selectAll != null) {
+ selectAll.setVisibility(CastConfig.MULTI_RECEIVER_ENABLED ? View.VISIBLE : View.GONE);
+ selectAll.setOnClickListener(v -> {
+ deviceAdapter.selectAllVisible();
+ java.util.List all =
+ deviceAdapter.getSelectedDevices();
+ if (statusText != null) {
+ statusText.setText(all.isEmpty()
+ ? getString(R.string.no_compatible_receivers)
+ : getString(R.string.devices_selected, all.size()));
+ }
+ });
+ }
listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position);
java.util.List selectedList =
diff --git a/app/src/main/res/layout/activity_incoming_cast.xml b/app/src/main/res/layout/activity_incoming_cast.xml
new file mode 100644
index 0000000..dc3dc7d
--- /dev/null
+++ b/app/src/main/res/layout/activity_incoming_cast.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_incoming_cast_pin.xml b/app/src/main/res/layout/activity_incoming_cast_pin.xml
new file mode 100644
index 0000000..fe58625
--- /dev/null
+++ b/app/src/main/res/layout/activity_incoming_cast_pin.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_sender.xml b/app/src/main/res/layout/activity_sender.xml
index b95cefa..3db117d 100644
--- a/app/src/main/res/layout/activity_sender.xml
+++ b/app/src/main/res/layout/activity_sender.xml
@@ -27,6 +27,13 @@
android:layout_weight="0.4"
android:choiceMode="singleChoice" />
+
+
+
+
+
+
PIN не может быть изменен посреди трансляции
Трансляция активна — нажмите чтобы открыть
Android Cast — нажмите чтобы открыть
+ Ожидание трансляции — нажмите чтобы открыть
Cast tray
Всегда показывать иконку статуса в строке состояния
Проведите по левому краю чтобы перейти к настройкам
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4952cda..714ef2d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,7 +3,7 @@
Android Cast
Send screen (tablet / phone)
Receive on TV / projector
- Same Wi‑Fi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices.
+ This device listens for live casts in the background. Use Send to pick nearby receivers (or select all). Match transport in settings if needed.
Cast to receiver
Waiting for cast
PIN
@@ -144,6 +144,21 @@
Receiver settings
Username
Always show icon in system bar
+ Tray icon only while casting or app open
+ Keep listening in background after reboot
+ Live cast invitation
+ %1$s goes live — would you like to join?
+ Enter cast PIN
+ %1$s protected this cast — enter PIN to join
+ Join cast
+ curl http://%1$s:%2$d/adb.json
+ Select all nearby
+ Unknown sender
+ Accept
+ Decline
+ Block user
+ Incoming casts
+ Alerts when another device tries to cast to this receiver
Send anonymous crash logs
Leave empty for anonymous cast
Resolution
@@ -188,6 +203,7 @@
PIN cannot be changed while casting
Cast active — tap to open app
Android Cast — tap to open app
+ Listening for casts — tap to open app
Cast tray
Persistent cast status icon in the notification bar
Swipe from the left edge for settings
diff --git a/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java b/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java
index 3ebcebe..1570155 100644
--- a/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java
+++ b/app/src/test/java/com/foxx/androidcast/CastTrayContentTest.java
@@ -15,13 +15,20 @@ public class CastTrayContentTest {
CastTrayContent.Body body = CastTrayContent.build(
"Tap to open",
"Cast active",
+ "Listening",
+ "",
+ "",
"Waiting for Wi‑Fi",
"adb connect %1$s",
+ "curl http://%1$s:%2$d/adb.json",
+ "",
+ 5039,
"Polling BE (%1$s)",
"Connected",
"Session %1$s",
false,
false,
+ false,
"",
RemoteAccessMode.DISABLED,
null);
@@ -29,22 +36,56 @@ public class CastTrayContentTest {
assertEquals("Tap to open", body.bigText);
}
+ @Test
+ public void listening_combinesReceiverStatusWithDevLines() {
+ CastTrayContent.Body body = CastTrayContent.build(
+ "Tap to open",
+ "Cast active",
+ "Listening for casts",
+ "Ready — PIN 1234 · USB",
+ "",
+ "Waiting",
+ "adb connect %1$s",
+ "curl http://%1$s:%2$d/adb.json",
+ "192.168.1.10",
+ 5039,
+ "Poll %1$s",
+ "Connected",
+ "Session %1$s",
+ false,
+ true,
+ true,
+ "192.168.1.10:5555",
+ RemoteAccessMode.DISABLED,
+ null);
+ assertEquals("Ready — PIN 1234 · USB", body.summary);
+ assertTrue(body.bigText.contains("adb connect 192.168.1.10:5555"));
+ assertTrue(body.bigText.contains("curl http://192.168.1.10:5039/adb.json"));
+ }
+
@Test
public void casting_overridesDevLines() {
CastTrayContent.Body body = CastTrayContent.build(
"Tap to open",
"Cast active",
+ "Listening",
+ "",
+ "Casting to TV",
"Waiting",
"adb %1$s",
+ "curl http://%1$s:%2$d/adb.json",
+ "192.168.1.1",
+ 5039,
"Poll %1$s",
"Connected",
"Session %1$s",
true,
+ false,
true,
"192.168.1.1:5555",
RemoteAccessMode.RSSH,
snapshot(RemoteAccessStatusStore.STATE_WAIT, ""));
- assertEquals("Cast active", body.summary);
+ assertEquals("Casting to TV", body.summary);
}
@Test
@@ -54,12 +95,19 @@ public class CastTrayContentTest {
CastTrayContent.Body body = CastTrayContent.build(
"Tap to open",
"Cast active",
+ "Listening",
+ "",
+ "",
"Waiting for Wi‑Fi",
"adb connect %1$s",
+ "curl http://%1$s:%2$d/adb.json",
+ "",
+ 5039,
"Polling BE (%1$s)",
"Connected",
"Session %1$s",
false,
+ false,
true,
"10.0.0.5:5555",
RemoteAccessMode.RSSH,
diff --git a/app/src/test/java/com/foxx/androidcast/receiver/BlockedSendersStoreTest.java b/app/src/test/java/com/foxx/androidcast/receiver/BlockedSendersStoreTest.java
new file mode 100644
index 0000000..ff98640
--- /dev/null
+++ b/app/src/test/java/com/foxx/androidcast/receiver/BlockedSendersStoreTest.java
@@ -0,0 +1,18 @@
+package com.foxx.androidcast.receiver;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+
+import org.junit.Test;
+
+public class BlockedSendersStoreTest {
+ @Test
+ public void fingerprintIsStableAndCaseInsensitive() {
+ String a = BlockedSendersStore.fingerprint("Alice");
+ String b = BlockedSendersStore.fingerprint(" alice ");
+ assertEquals(a, b);
+ assertFalse(a.isEmpty());
+ assertNotEquals(BlockedSendersStore.fingerprint("Bob"), a);
+ }
+}