1
0
mirror of git://f0xx.org/ac/ac-mobile-android synced 2026-08-12 18:12:13 +03:00

Fix unified tray notification and reverse live-cast receiver flow.

Use a single CastTrayNotifier FGS for receiver/sender status combined with
VPN and dev ADB/HTTP discovery lines; remove duplicate ID_RECEIVER tray.
Receiver accepts live casts from background, prompts join then sender PIN,
hides manual Receive button, and adds select-all-nearby on sender.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Anton Afanasyeu
2026-08-01 11:43:27 +02:00
parent 7d87f3c5c1
commit 47eb316d17
44 changed files with 1277 additions and 62 deletions

View File

@@ -29,7 +29,7 @@ def buildTimeDisplay = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", j
def otaMajor = 0 def otaMajor = 0
def otaMinor = 1 def otaMinor = 1
def otaBuild = 0 def otaBuild = 0
def otaChannelUrlDefault = '' def otaChannelUrlDefault = 'https://apps.f0xx.org/v0/ota/channel/stable.json'
def otaManifestUrlDefault = '' def otaManifestUrlDefault = ''
try { try {
def lpFile = rootProject.file('local.properties') def lpFile = rootProject.file('local.properties')

View File

@@ -21,8 +21,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.DUMP" <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
tools:ignore="ProtectedPermissions" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<application <application
android:name=".AndroidCastApplication" android:name=".AndroidCastApplication"
@@ -97,6 +97,22 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<activity
android:name=".receiver.IncomingCastActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/Theme.AndroidCast" />
<activity
android:name=".receiver.IncomingCastPinActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/Theme.AndroidCast" />
<activity <activity
android:name=".receiver.ReceiverPlaybackActivity" android:name=".receiver.ReceiverPlaybackActivity"
android:exported="false" android:exported="false"
@@ -135,7 +151,7 @@
<service <service
android:name=".receiver.ReceiverCastService" android:name=".receiver.ReceiverCastService"
android:exported="false" android:exported="false"
android:stopWithTask="true" android:stopWithTask="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback" />
<service <service
@@ -196,6 +212,24 @@
</intent-filter> </intent-filter>
</service> </service>
<receiver
android:name=".boot.BootCompletedReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".receiver.IncomingCastActionReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.foxx.androidcast.INCOMING_CAST_DECISION" />
</intent-filter>
</receiver>
<receiver <receiver
android:name=".TrayNotificationReceiver" android:name=".TrayNotificationReceiver"
android:exported="false" /> android:exported="false" />

View File

@@ -49,6 +49,6 @@ public class AndroidCastApplication extends Application {
EntitlementCoordinator.getInstance(this).bootstrap(); EntitlementCoordinator.getInstance(this).bootstrap();
RemoteAccessCoordinator.onBootIfNeeded(this); RemoteAccessCoordinator.onBootIfNeeded(this);
DevAdbWifiKeeper.ensureStarted(this); DevAdbWifiKeeper.ensureStarted(this);
CastTrayNotifier.ensureTrayService(this); com.foxx.androidcast.boot.CastBootCoordinator.onProcessStart(this);
} }
} }

View File

@@ -33,6 +33,9 @@ public final class AppPreferences {
private static final String KEY_USERNAME = "username"; private static final String KEY_USERNAME = "username";
private static final String KEY_PIN = "pin"; private static final String KEY_PIN = "pin";
private static final String KEY_SHOW_TRAY = "show_tray_icon"; 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_THEME = "theme_mode";
private static final String KEY_LOCALE = "locale_mode"; private static final String KEY_LOCALE = "locale_mode";
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio"; 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(); 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) { public static CastThemeHelper.AppThemeMode getThemeMode(Context context) {
String name = prefs(context).getString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name()); String name = prefs(context).getString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name());
try { try {
@@ -507,9 +538,9 @@ public final class AppPreferences {
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply(); 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) { 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) { public static void setDevBackendNtpSyncEnabled(Context context, boolean enabled) {

View File

@@ -17,6 +17,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
public final class CastActiveState { public final class CastActiveState {
private static final AtomicBoolean senderCasting = new AtomicBoolean(false); private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
private static final AtomicBoolean receiverListening = 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() {} private CastActiveState() {}
@@ -35,4 +37,12 @@ public final class CastActiveState {
public static boolean isReceiverListening() { public static boolean isReceiverListening() {
return receiverListening.get(); return receiverListening.get();
} }
public static void setReceiverStreaming(boolean active) {
receiverStreaming.set(active);
}
public static boolean isReceiverStreaming() {
return receiverStreaming.get();
}
} }

View File

@@ -18,16 +18,26 @@ import android.content.Intent;
/** Remembers the last foreground activity for tray / notification resume. */ /** Remembers the last foreground activity for tray / notification resume. */
public final class CastActivityTracker { public final class CastActivityTracker {
private static volatile Class<? extends Activity> lastActivityClass; private static volatile Class<? extends Activity> lastActivityClass;
private static volatile boolean anyActivityForeground;
private CastActivityTracker() {} private CastActivityTracker() {}
public static void onResume(Activity activity) { public static void onResume(Activity activity) {
if (activity != null) { if (activity != null) {
lastActivityClass = activity.getClass(); lastActivityClass = activity.getClass();
anyActivityForeground = true;
CastTrayNotifier.syncWhenForeground(activity); CastTrayNotifier.syncWhenForeground(activity);
} }
} }
public static void onPause(Activity activity) {
anyActivityForeground = false;
}
public static boolean isAnyActivityForeground() {
return anyActivityForeground;
}
public static Intent resumeIntent(Context context) { public static Intent resumeIntent(Context context) {
Class<? extends Activity> cls = lastActivityClass; Class<? extends Activity> cls = lastActivityClass;
if (cls != null) { if (cls != null) {

View File

@@ -89,6 +89,21 @@ public class CastSettingsFragment extends Fragment {
AppPreferences.setShowTrayIcon(requireContext(), checked); AppPreferences.setShowTrayIcon(requireContext(), checked);
CastTrayNotifier.onPreferenceChanged(requireContext()); 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) { if (crashLogsCheck != null) {
crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext())); crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext()));
crashLogsCheck.setOnCheckedChangeListener((btn, checked) -> crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->

View File

@@ -23,20 +23,31 @@ public final class CastTrayContent {
public static Body build( public static Body build(
String idleTapToOpen, String idleTapToOpen,
String castActive, String castActive,
String listeningFallback,
String receiverStatusLine,
String senderStatusLine,
String adbWaiting, String adbWaiting,
String adbLineFormat, String adbLineFormat,
String devHttpLineFormat,
String devHttpHost,
int devHttpPort,
String raPollFormat, String raPollFormat,
String raConnected, String raConnected,
String raSessionFormat, String raSessionFormat,
boolean casting, boolean casting,
boolean listening,
boolean adbEnabled, boolean adbEnabled,
String adbConnectLine, String adbConnectLine,
RemoteAccessMode raMode, RemoteAccessMode raMode,
RemoteAccessStatusStore.Snapshot raSnap) { RemoteAccessStatusStore.Snapshot raSnap) {
if (casting) { if (casting) {
return new Body(castActive, castActive); String line = pickFirstNonEmpty(senderStatusLine, receiverStatusLine, castActive);
return new Body(line, line);
} }
List<String> lines = new ArrayList<>(); List<String> lines = new ArrayList<>();
if (listening) {
lines.add(pickFirstNonEmpty(receiverStatusLine, listeningFallback));
}
String raLine = remoteAccessLine(raPollFormat, raConnected, raSessionFormat, raMode, raSnap); String raLine = remoteAccessLine(raPollFormat, raConnected, raSessionFormat, raMode, raSnap);
if (raLine != null && !raLine.isEmpty()) { if (raLine != null && !raLine.isEmpty()) {
lines.add(raLine); lines.add(raLine);
@@ -47,6 +58,9 @@ public final class CastTrayContent {
: String.format(adbLineFormat, adbConnectLine); : String.format(adbLineFormat, adbConnectLine);
lines.add(adbLine); lines.add(adbLine);
} }
if (devHttpHost != null && !devHttpHost.isEmpty() && devHttpPort > 0) {
lines.add(String.format(devHttpLineFormat, devHttpHost, devHttpPort));
}
if (!lines.isEmpty()) { if (!lines.isEmpty()) {
String joined = joinLines(lines); String joined = joinLines(lines);
return new Body(lines.get(0), joined); return new Body(lines.get(0), joined);
@@ -79,6 +93,15 @@ public final class CastTrayContent {
return String.format(pollFormat, mode.name()); 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<String> lines) { private static String joinLines(List<String> lines) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines.size(); i++) { for (int i = 0; i < lines.size(); i++) {

View File

@@ -30,6 +30,8 @@ public final class CastTrayNotifier {
public static final int LEGACY_DEV_ADB_ID = 50391; public static final int LEGACY_DEV_ADB_ID = 50391;
public static final int LEGACY_RA_ID = 0x7a00; public static final int LEGACY_RA_ID = 0x7a00;
public static final int LEGACY_RA_VPN_ID = 0x7a01; 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() {} private CastTrayNotifier() {}
@@ -57,6 +59,12 @@ public final class CastTrayNotifier {
} }
public static boolean shouldMaintainTray(Context context) { public static boolean shouldMaintainTray(Context context) {
if (AppPreferences.isTrayOnDemand(context)) {
return CastActiveState.isSenderCasting()
|| CastActiveState.isReceiverListening()
|| CastActivityTracker.isAnyActivityForeground()
|| hasDevTrayContent(context);
}
if (AppPreferences.isShowTrayIcon(context)) { if (AppPreferences.isShowTrayIcon(context)) {
return true; return true;
} }
@@ -78,6 +86,11 @@ public final class CastTrayNotifier {
if (!shouldMaintainTray(app)) { if (!shouldMaintainTray(app)) {
return; 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); Intent intent = new Intent(app, TrayForegroundService.class);
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -131,7 +144,8 @@ public final class CastTrayNotifier {
public static Notification buildNotification(Context context) { public static Notification buildNotification(Context context) {
createChannel(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); boolean adbEnabled = BuildConfig.DEBUG && AppPreferences.isDevAdbWifiKeeperEnabled(context);
RemoteAccessMode raMode = BuildConfig.DEBUG RemoteAccessMode raMode = BuildConfig.DEBUG
? AppPreferences.getDevRemoteAccessMode(context) ? AppPreferences.getDevRemoteAccessMode(context)
@@ -139,16 +153,27 @@ public final class CastTrayNotifier {
RemoteAccessStatusStore.Snapshot raSnap = RemoteAccessStatusStore.load(context); RemoteAccessStatusStore.Snapshot raSnap = RemoteAccessStatusStore.load(context);
DevAdbConnectInfo adbInfo = adbEnabled ? DevAdbWifiKeeper.getLastInfo(context) : null; DevAdbConnectInfo adbInfo = adbEnabled ? DevAdbWifiKeeper.getLastInfo(context) : null;
String adbLine = adbInfo != null ? adbInfo.primaryConnectLine() : ""; String adbLine = adbInfo != null ? adbInfo.primaryConnectLine() : "";
String devHttpHost = "";
if (adbInfo != null && !adbInfo.ips.isEmpty()) {
devHttpHost = adbInfo.ips.get(0);
}
CastTrayContent.Body body = CastTrayContent.build( CastTrayContent.Body body = CastTrayContent.build(
context.getString(R.string.tray_cast_idle), context.getString(R.string.tray_cast_idle),
context.getString(R.string.tray_cast_active), 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_waiting),
context.getString(R.string.dev_adb_wifi_notification_line), 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_poll),
context.getString(R.string.dev_remote_access_notification_connected), context.getString(R.string.dev_remote_access_notification_connected),
context.getString(R.string.dev_remote_access_notification_session), context.getString(R.string.dev_remote_access_notification_session),
casting, casting,
listening,
adbEnabled, adbEnabled,
adbLine, adbLine,
raMode, raMode,
@@ -184,7 +209,7 @@ public final class CastTrayNotifier {
return builder.build(); return builder.build();
} }
static void publish(Context context) { public static void publish(Context context) {
if (!shouldMaintainTray(context)) { if (!shouldMaintainTray(context)) {
return; return;
} }
@@ -202,6 +227,8 @@ public final class CastTrayNotifier {
nm.cancel(LEGACY_DEV_ADB_ID); nm.cancel(LEGACY_DEV_ADB_ID);
nm.cancel(LEGACY_RA_ID); nm.cancel(LEGACY_RA_ID);
nm.cancel(LEGACY_RA_VPN_ID); nm.cancel(LEGACY_RA_VPN_ID);
nm.cancel(LEGACY_RECEIVER_ID);
nm.cancel(LEGACY_SENDER_ID);
} }
private static void hide(Context context) { private static void hide(Context context) {

View File

@@ -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<String> receiverLine = new AtomicReference<>("");
private static final AtomicReference<String> 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("");
}
}

View File

@@ -30,6 +30,7 @@ import com.foxx.androidcast.commercial.PlayStoreIntegration;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy; import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.display.WiredDisplayMonitor; import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
import com.foxx.androidcast.ota.OtaDefaults;
import com.foxx.androidcast.ota.OtaUpdateChecker; import com.foxx.androidcast.ota.OtaUpdateChecker;
import com.foxx.androidcast.ota.OtaUpdateCoordinator; import com.foxx.androidcast.ota.OtaUpdateCoordinator;
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset; import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
@@ -117,6 +118,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
checkOta.setOnClickListener(v -> { checkOta.setOnClickListener(v -> {
String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : ""; String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : "";
if (url.isEmpty()) {
url = OtaDefaults.effectiveChannelUrl(this);
}
AppPreferences.setOtaChannelUrl(this, url); AppPreferences.setOtaChannelUrl(this, url);
OtaUpdateCoordinator.requestManualCheck(this); OtaUpdateCoordinator.requestManualCheck(this);
}); });
@@ -692,14 +696,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
OtaUpdateChecker.installedVersionCode(this))); OtaUpdateChecker.installedVersionCode(this)));
} }
if (manifestUrl != null) { if (manifestUrl != null) {
String url = AppPreferences.getOtaChannelUrl(this); manifestUrl.setText(OtaDefaults.effectiveChannelUrl(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);
} }
if (otaOnLaunch != null) { if (otaOnLaunch != null) {
otaOnLaunch.setOnCheckedChangeListener(null); otaOnLaunch.setOnCheckedChangeListener(null);

View File

@@ -115,6 +115,12 @@ public abstract class DrawerHostActivity extends AppCompatActivity {
CastActivityTracker.onResume(this); CastActivityTracker.onResume(this);
} }
@Override
protected void onPause() {
CastActivityTracker.onPause(this);
super.onPause();
}
protected void openSettingsDrawer() { protected void openSettingsDrawer() {
if (drawerLayout != null) { if (drawerLayout != null) {
drawerLayout.openDrawer(GravityCompat.START); drawerLayout.openDrawer(GravityCompat.START);

View File

@@ -16,6 +16,7 @@ import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.IntentFilter; import android.content.IntentFilter;
import android.os.Build; import android.os.Build;
import android.view.View;
import android.os.Bundle; import android.os.Bundle;
import com.foxx.androidcast.dev.DevInstallWakeHelper; import com.foxx.androidcast.dev.DevInstallWakeHelper;
@@ -67,11 +68,7 @@ public class MainActivity extends DrawerHostActivity {
findViewById(R.id.btn_send).setOnClickListener(v -> findViewById(R.id.btn_send).setOnClickListener(v ->
startActivity(new Intent(this, SenderActivity.class))); startActivity(new Intent(this, SenderActivity.class)));
findViewById(R.id.btn_receive).setOnClickListener(v -> { findViewById(R.id.btn_receive).setVisibility(View.GONE);
Intent i = new Intent(this, ReceiverPlaybackActivity.class);
i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
startActivity(i);
});
} }
@Override @Override

View File

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

View File

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

View File

@@ -32,10 +32,14 @@ import com.foxx.androidcast.network.CastCodecFlags;
import com.foxx.androidcast.network.transport.StreamProtectionCapability; import com.foxx.androidcast.network.transport.StreamProtectionCapability;
import com.foxx.androidcast.sender.CodecCatalog; import com.foxx.androidcast.sender.CodecCatalog;
import com.foxx.androidcast.ota.OtaDefaults;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.InetAddress; import java.net.InetAddress;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -166,7 +170,7 @@ public final class DevDiagnosticsProbe {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append(connectivitySummary(context)).append('\n'); sb.append(connectivitySummary(context)).append('\n');
String crashUrl = resolveCrashUploadUrl(context); 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); appendOtaProbe(sb, context, cancel);
sb.append('\n').append("-----").append('\n'); sb.append('\n').append("-----").append('\n');
sb.append(DevVpnStatusProbe.formatForDiagnostics(context)); sb.append(DevVpnStatusProbe.formatForDiagnostics(context));
@@ -366,6 +370,52 @@ public final class DevDiagnosticsProbe {
return "Connectivity: " + (parts.isEmpty() ? "other" : String.join(", ", parts)); 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) { private static String probeHttp(String label, String url, AtomicBoolean cancel) {
if (cancel != null && cancel.get()) { if (cancel != null && cancel.get()) {
return label + ": stopped"; return label + ": stopped";
@@ -406,11 +456,7 @@ public final class DevDiagnosticsProbe {
} }
private static String resolveOtaUrl(Context context) { private static String resolveOtaUrl(Context context) {
String url = AppPreferences.getOtaChannelUrl(context); return OtaDefaults.effectiveChannelUrl(context);
if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
}
return url;
} }
private static String miracastSummary(PackageManager pm) { private static String miracastSummary(PackageManager pm) {

View File

@@ -12,6 +12,7 @@ import android.os.PowerManager;
import android.telephony.TelephonyManager; import android.telephony.TelephonyManager;
import com.foxx.androidcast.AppPreferences; import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.crash.CrashSettingsStore; import com.foxx.androidcast.crash.CrashSettingsStore;
import com.foxx.androidcast.discovery.CastLanPeerProbe; import com.foxx.androidcast.discovery.CastLanPeerProbe;
@@ -248,7 +249,8 @@ public final class DevNetworkSelfTestProbe {
r.itemPass("local-tz-offset", tzMin + " min"); r.itemPass("local-tz-offset", tzMin + " min");
boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context); boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context);
r.itemBool(enabled ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.NA, 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) { if (!enabled) {
return; return;
} }

View File

@@ -88,7 +88,7 @@ public final class DevVpnStatusProbe {
} else { } else {
tunnelLevel = DevNetworkSelfTestReport.Level.WARN; tunnelLevel = DevNetworkSelfTestReport.Level.WARN;
} }
String tunnelLabel = formatTunnelStateLabel(tunnelState, last); String tunnelLabel = formatTunnelStateLabel(mode, tunnelState, last);
r.item(tunnelLevel, "tunnel-state", tunnelLabel); r.item(tunnelLevel, "tunnel-state", tunnelLabel);
if (mode != RemoteAccessMode.DISABLED if (mode != RemoteAccessMode.DISABLED
@@ -247,7 +247,13 @@ public final class DevVpnStatusProbe {
return "not_whitelisted".equals(detail); 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 (RemoteAccessStatusStore.STATE_WAIT.equals(last.state)) {
if (isAwaitingWhitelist(last.detail)) { if (isAwaitingWhitelist(last.detail)) {
return "idle (awaiting whitelist — no tunnel expected)"; return "idle (awaiting whitelist — no tunnel expected)";

View File

@@ -12,6 +12,9 @@ package com.foxx.androidcast.network;
* Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025 * Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025
**********************************************************************/ **********************************************************************/
import com.foxx.androidcast.CastSettings; 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.AudioNegotiator;
import com.foxx.androidcast.media.CodecNegotiator; import com.foxx.androidcast.media.CodecNegotiator;
import com.foxx.androidcast.media.CodecPriorityCatalog; import com.foxx.androidcast.media.CodecPriorityCatalog;
@@ -23,6 +26,8 @@ import com.foxx.androidcast.stats.SessionStatsContext;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import android.content.Context;
/** Auth handshake and message I/O over a {@link CastTransport}. */ /** Auth handshake and message I/O over a {@link CastTransport}. */
public class CastSession { public class CastSession {
public static final class HandshakeResult { public static final class HandshakeResult {
@@ -112,10 +117,16 @@ public class CastSession {
} }
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException { 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"; String senderName = "Sender";
boolean authed = false; boolean authed = false;
CastSettings remote = null; CastSettings remote = null;
List<String> senderCaps = null; List<String> senderCaps = null;
boolean incomingPromptDone = false;
long deadline = System.currentTimeMillis() + 30_000; long deadline = System.currentTimeMillis() + 30_000;
while (System.currentTimeMillis() < deadline) { while (System.currentTimeMillis() < deadline) {
CastProtocol.Message msg = receive(2_000); CastProtocol.Message msg = receive(2_000);
@@ -125,12 +136,51 @@ public class CastSession {
switch (msg.type) { switch (msg.type) {
case CastProtocol.MSG_HELLO: case CastProtocol.MSG_HELLO:
senderName = CastProtocol.payloadAsUtf8(msg.payload); 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; break;
case CastProtocol.MSG_AUTH: 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; authed = true;
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]); transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]); recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
if (promptIncoming && appContext != null) {
IncomingCastGate.onAccepted(appContext);
}
} else { } else {
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]); transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]); recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);

View File

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

View File

@@ -57,6 +57,7 @@ final class OtaSettingsStore {
} }
} }
} }
addSource(dedup, OtaDefaults.FALLBACK_CHANNEL_URL);
List<String> urls = new ArrayList<>(dedup.keySet()); List<String> urls = new ArrayList<>(dedup.keySet());
long interval = OtaRuntimeSettings.DEFAULT_CHECK_INTERVAL_MS; long interval = OtaRuntimeSettings.DEFAULT_CHECK_INTERVAL_MS;
@@ -136,7 +137,7 @@ final class OtaSettingsStore {
if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) { if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
return BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim(); 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) { static long parseDurationMs(String raw, long fallback) {

View File

@@ -34,15 +34,7 @@ public final class OtaUpdateCoordinator {
private OtaUpdateCoordinator() {} private OtaUpdateCoordinator() {}
public static String resolveEntryUrl(Context context) { public static String resolveEntryUrl(Context context) {
String url = AppPreferences.getOtaChannelUrl(context); return OtaDefaults.effectiveChannelUrl(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;
} }
public static void requestCheckOnLaunch(Context context) { public static void requestCheckOnLaunch(Context context) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ package com.foxx.androidcast.receiver;
**********************************************************************/ **********************************************************************/
import android.app.NotificationManager; import android.app.NotificationManager;
import android.app.Service; import android.app.Service;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Build; import android.os.Build;
import android.os.Handler; import android.os.Handler;
@@ -30,6 +31,7 @@ import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastSettings; import com.foxx.androidcast.CastSettings;
import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastTrayStatus;
import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastReceiverService;
import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.ICastStatusCallback;
import com.foxx.androidcast.IntentExtras; 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_PLAYBACK_UI_VISIBLE = "playback_ui_visible";
public static final String EXTRA_PIN = "pin"; public static final String EXTRA_PIN = "pin";
public static final String EXTRA_ALLOW_AUDIO = "allow_audio"; 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 { private enum Phase {
IDLE, LISTENING, CONNECTED, STREAMING IDLE, LISTENING, CONNECTED, STREAMING
@@ -114,6 +124,8 @@ public class ReceiverCastService extends Service {
private int decoderHeight; private int decoderHeight;
private boolean decoderActive; 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. */ /** Set from receiver UI before each listen session. */
private boolean allowIncomingAudio = true; private boolean allowIncomingAudio = true;
/** null until first audio config this session; then mirrors user preference. */ /** 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())) { if (intent != null && ACTION_PLAYBACK_UI.equals(intent.getAction())) {
playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false); playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false);
if (playbackUiVisible) {
requireIncomingPrompt = false;
}
if (!playbackUiVisible) { if (!playbackUiVisible) {
playbackLaunched = false; playbackLaunched = false;
} }
return START_STICKY; return START_STICKY;
} }
if (intent != null && ACTION_START.equals(intent.getAction())) { 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); String pin = intent.getStringExtra(EXTRA_PIN);
if (pin == null) { if (pin == null) {
pin = AppPreferences.getPin(this); pin = AppPreferences.getPin(this);
@@ -256,10 +275,16 @@ public class ReceiverCastService extends Service {
playbackLaunched = false; playbackLaunched = false;
phase = Phase.LISTENING; phase = Phase.LISTENING;
acquireWakeLock(); acquireWakeLock();
startForeground(CastNotifications.ID_RECEIVER, String statusLine = getString(R.string.receiver_listening);
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class)); 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); CastActiveState.setReceiverListening(true);
CastTrayNotifier.refresh(this); CastActiveState.setReceiverStreaming(false);
if (videoDecoder != null) { if (videoDecoder != null) {
videoDecoder.release(); videoDecoder.release();
@@ -462,7 +487,7 @@ public class ReceiverCastService extends Service {
public void onCodecRenegotiation(CastProtocol.CodecSelection selection) { public void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
mainHandler.post(() -> applyCodecRenegotiation(selection)); mainHandler.post(() -> applyCodecRenegotiation(selection));
} }
}, this::enrichReceiverStats); }, this::enrichReceiverStats, requireIncomingPrompt);
session.start(); session.start();
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase())); updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
openPlaybackAwaiting(R.string.playback_listening); openPlaybackAwaiting(R.string.playback_listening);
@@ -472,6 +497,8 @@ public class ReceiverCastService extends Service {
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) { private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
phase = Phase.CONNECTED; phase = Phase.CONNECTED;
castEnded = false; castEnded = false;
CastActiveState.setReceiverStreaming(true);
CastTrayNotifier.publish(this);
if (AppPreferences.isGrabSessionStats(this)) { if (AppPreferences.isGrabSessionStats(this)) {
if (sessionStatsRecorder == null) { if (sessionStatsRecorder == null) {
sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport()); sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
@@ -564,6 +591,8 @@ public class ReceiverCastService extends Service {
private void onSenderDisconnected() { private void onSenderDisconnected() {
stopAudioPlaybackImmediate(); stopAudioPlaybackImmediate();
phase = Phase.LISTENING; phase = Phase.LISTENING;
CastActiveState.setReceiverStreaming(false);
CastTrayNotifier.publish(this);
audioAccepted = null; audioAccepted = null;
remoteCastSettings = null; remoteCastSettings = null;
streamIdle = false; streamIdle = false;
@@ -698,6 +727,8 @@ public class ReceiverCastService extends Service {
boolean decoderWasActive = decoderActive; boolean decoderWasActive = decoderActive;
phase = Phase.STREAMING; phase = Phase.STREAMING;
streamIdle = false; streamIdle = false;
CastActiveState.setReceiverStreaming(true);
CastTrayNotifier.publish(this);
if (firstConfig) { if (firstConfig) {
streamMetrics.reset(); streamMetrics.reset();
} }
@@ -1118,9 +1149,8 @@ public class ReceiverCastService extends Service {
private void updateStatus(String s) { private void updateStatus(String s) {
status = s; status = s;
broadcastStatus(s); broadcastStatus(s);
NotificationManager nm = getSystemService(NotificationManager.class); CastTrayStatus.setReceiverLine(s);
nm.notify(CastNotifications.ID_RECEIVER, CastTrayNotifier.publish(this);
CastNotifications.receiver(this, s, ReceiverCastService.class));
} }
private void broadcastStatus(String s) { private void broadcastStatus(String s) {
@@ -1200,6 +1230,8 @@ public class ReceiverCastService extends Service {
private void releaseAll() { private void releaseAll() {
finishSessionStatsAsync(); finishSessionStatsAsync();
CastActiveState.setReceiverListening(false); CastActiveState.setReceiverListening(false);
CastActiveState.setReceiverStreaming(false);
CastTrayStatus.clearReceiverLine();
CastTrayNotifier.refresh(this); CastTrayNotifier.refresh(this);
phase = Phase.IDLE; phase = Phase.IDLE;
resetStreamState(); resetStreamState();

View File

@@ -222,6 +222,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
intent.setAction(ReceiverCastService.ACTION_START); intent.setAction(ReceiverCastService.ACTION_START);
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin); intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio); intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
intent.putExtra(ReceiverCastService.EXTRA_REQUIRE_INCOMING_PROMPT, false);
intent.putExtra(CastSettings.EXTRA, settings); intent.putExtra(CastSettings.EXTRA, settings);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent); startForegroundService(intent);

View File

@@ -108,19 +108,26 @@ public class ReceiverSession {
private final CastSessionGate inboundSessionGate = new CastSessionGate(); private final CastSessionGate inboundSessionGate = new CastSessionGate();
private boolean loggedFirstVideoFrame; private boolean loggedFirstVideoFrame;
private NetworkFeedbackManager networkFeedback; 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) { 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, public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
PassThroughNetworkControlPlane.StatsEnricher statsEnricher) { 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.appContext = context.getApplicationContext();
this.pin = pin; this.pin = pin;
this.localSettings = localSettings; this.localSettings = localSettings;
this.listener = listener; this.listener = listener;
this.statsEnricher = statsEnricher; this.statsEnricher = statsEnricher;
this.requireIncomingPrompt = requireIncomingPrompt;
} }
public void start() { public void start() {
@@ -197,7 +204,8 @@ public class ReceiverSession {
listener::onDiagPingSample); listener::onDiagPingSample);
} }
session.acceptClient(); session.acceptClient();
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings); CastSession.HandshakeResult hs = session.serverHandshake(
pin, localSettings, appContext, requireIncomingPrompt);
applyNegotiatedProtection(hs.settings); applyNegotiatedProtection(hs.settings);
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime); listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
postStatus("Streaming from " + hs.senderName); postStatus("Streaming from " + hs.senderName);

View File

@@ -5,6 +5,7 @@ import android.util.Log;
import com.foxx.androidcast.BuildConfig; import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.crash.CrashSettingsStore; import com.foxx.androidcast.crash.CrashSettingsStore;
import com.foxx.androidcast.ota.OtaDefaults;
import org.json.JSONObject; import org.json.JSONObject;
@@ -31,7 +32,7 @@ public final class RemoteAccessHeartbeatClient {
return derived; return derived;
} }
} }
String ota = com.foxx.androidcast.AppPreferences.getOtaChannelUrl(context); String ota = OtaDefaults.effectiveChannelUrl(context);
if (ota != null && !ota.trim().isEmpty()) { if (ota != null && !ota.trim().isEmpty()) {
String derived = toHeartbeatUrl(normalizeBackendBase(ota.trim())); String derived = toHeartbeatUrl(normalizeBackendBase(ota.trim()));
if (!derived.isEmpty()) { if (!derived.isEmpty()) {

View File

@@ -122,6 +122,17 @@ public class DeviceListAdapter extends BaseAdapter {
return false; 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 @Override
public int getCount() { public int getCount() {
return devices.size(); return devices.size();

View File

@@ -37,6 +37,7 @@ import com.foxx.androidcast.CastActiveState;
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy; import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
import com.foxx.androidcast.display.WiredDisplayMonitor; import com.foxx.androidcast.display.WiredDisplayMonitor;
import com.foxx.androidcast.CastTrayNotifier; import com.foxx.androidcast.CastTrayNotifier;
import com.foxx.androidcast.CastTrayStatus;
import com.foxx.androidcast.CastNotifications; import com.foxx.androidcast.CastNotifications;
import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastConfig;
import com.foxx.androidcast.CastResolution; import com.foxx.androidcast.CastResolution;
@@ -1046,6 +1047,7 @@ public class ScreenCastService extends Service implements
stopping.set(true); stopping.set(true);
casting.set(false); casting.set(false);
CastActiveState.setSenderCasting(false); CastActiveState.setSenderCasting(false);
CastTrayStatus.clearSenderLine();
CastTrayNotifier.refresh(this); CastTrayNotifier.refresh(this);
networkFeedback = null; networkFeedback = null;
calibrationMode = false; calibrationMode = false;
@@ -1130,15 +1132,18 @@ public class ScreenCastService extends Service implements
} }
private void startSenderForeground(String text, boolean withAudio) { 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION; int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
if (withAudio) { if (withAudio) {
fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE; 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 { } 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) { private void updateStatus(String text) {
status = text; status = text;
broadcastStatus(text); broadcastStatus(text);
android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class); CastTrayStatus.setSenderLine(text);
nm.notify(CastNotifications.ID_SENDER, CastTrayNotifier.publish(this);
CastNotifications.sender(this, text, ScreenCastService.class));
} }
private void broadcastStatus(String s) { private void broadcastStatus(String s) {

View File

@@ -126,6 +126,20 @@ public class SenderActivity extends DrawerHostActivity {
? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE); ? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE);
deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED); deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED);
listView.setAdapter(deviceAdapter); 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<DiscoveryManager.DiscoveredDevice> 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) -> { listView.setOnItemClickListener((parent, view, position, id) -> {
deviceAdapter.setSelectedPosition(position); deviceAdapter.setSelectedPosition(position);
java.util.List<DiscoveryManager.DiscoveredDevice> selectedList = java.util.List<DiscoveryManager.DiscoveredDevice> selectedList =

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/incoming_cast_title"
android:textSize="22sp"
android:textStyle="bold" />
<TextView
android:id="@+id/incoming_cast_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:textSize="16sp" />
<Button
android:id="@+id/incoming_cast_accept"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/incoming_cast_accept" />
<Button
android:id="@+id/incoming_cast_decline"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/incoming_cast_decline" />
<Button
android:id="@+id/incoming_cast_block"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/incoming_cast_block" />
</LinearLayout>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="@string/incoming_cast_pin_title"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/incoming_pin_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:textSize="16sp" />
<EditText
android:id="@+id/incoming_pin_input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/pin_hint"
android:inputType="numberPassword"
android:maxLength="32" />
<Button
android:id="@+id/incoming_pin_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/incoming_cast_pin_submit" />
<Button
android:id="@+id/incoming_pin_cancel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/incoming_cast_decline" />
</LinearLayout>

View File

@@ -27,6 +27,13 @@
android:layout_weight="0.4" android:layout_weight="0.4"
android:choiceMode="singleChoice" /> android:choiceMode="singleChoice" />
<Button
android:id="@+id/btn_select_all_nearby"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/select_all_nearby" />
<FrameLayout <FrameLayout
android:id="@+id/panel_preview" android:id="@+id/panel_preview"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -87,6 +87,21 @@
android:checked="true" android:checked="true"
android:text="@string/label_show_tray_icon" /> android:text="@string/label_show_tray_icon" />
<CheckBox
android:id="@+id/check_tray_on_demand"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/label_tray_on_demand" />
<CheckBox
android:id="@+id/check_background_listen"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:checked="true"
android:text="@string/label_background_listen" />
<CheckBox <CheckBox
android:id="@+id/check_send_crash_logs" android:id="@+id/check_send_crash_logs"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -169,6 +169,7 @@
<string name="cast_in_progress_pin_locked">PIN не может быть изменен посреди трансляции</string> <string name="cast_in_progress_pin_locked">PIN не может быть изменен посреди трансляции</string>
<string name="tray_cast_active">Трансляция активна — нажмите чтобы открыть</string> <string name="tray_cast_active">Трансляция активна — нажмите чтобы открыть</string>
<string name="tray_cast_idle">Android Cast — нажмите чтобы открыть</string> <string name="tray_cast_idle">Android Cast — нажмите чтобы открыть</string>
<string name="tray_listening">Ожидание трансляции — нажмите чтобы открыть</string>
<string name="tray_channel_name">Cast tray</string> <string name="tray_channel_name">Cast tray</string>
<string name="tray_channel_description">Всегда показывать иконку статуса в строке состояния</string> <string name="tray_channel_description">Всегда показывать иконку статуса в строке состояния</string>
<string name="swipe_for_settings">Проведите по левому краю чтобы перейти к настройкам</string> <string name="swipe_for_settings">Проведите по левому краю чтобы перейти к настройкам</string>

View File

@@ -3,7 +3,7 @@
<string name="app_name">Android Cast</string> <string name="app_name">Android Cast</string>
<string name="send_screen">Send screen (tablet / phone)</string> <string name="send_screen">Send screen (tablet / phone)</string>
<string name="receive_screen">Receive on TV / projector</string> <string name="receive_screen">Receive on TV / projector</string>
<string name="main_hint">Same WiFi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices.</string> <string name="main_hint">This device listens for live casts in the background. Use Send to pick nearby receivers (or select all). Match transport in settings if needed.</string>
<string name="sender_title">Cast to receiver</string> <string name="sender_title">Cast to receiver</string>
<string name="receiver_title">Waiting for cast</string> <string name="receiver_title">Waiting for cast</string>
<string name="pin_hint">PIN</string> <string name="pin_hint">PIN</string>
@@ -144,6 +144,21 @@
<string name="settings_section_receiver">Receiver settings</string> <string name="settings_section_receiver">Receiver settings</string>
<string name="label_username">Username</string> <string name="label_username">Username</string>
<string name="label_show_tray_icon">Always show icon in system bar</string> <string name="label_show_tray_icon">Always show icon in system bar</string>
<string name="label_tray_on_demand">Tray icon only while casting or app open</string>
<string name="label_background_listen">Keep listening in background after reboot</string>
<string name="incoming_cast_title">Live cast invitation</string>
<string name="incoming_cast_message">%1$s goes live — would you like to join?</string>
<string name="incoming_cast_pin_title">Enter cast PIN</string>
<string name="incoming_cast_pin_message">%1$s protected this cast — enter PIN to join</string>
<string name="incoming_cast_pin_submit">Join cast</string>
<string name="tray_dev_http_line">curl http://%1$s:%2$d/adb.json</string>
<string name="select_all_nearby">Select all nearby</string>
<string name="incoming_cast_unknown_sender">Unknown sender</string>
<string name="incoming_cast_accept">Accept</string>
<string name="incoming_cast_decline">Decline</string>
<string name="incoming_cast_block">Block user</string>
<string name="incoming_cast_channel">Incoming casts</string>
<string name="incoming_cast_channel_desc">Alerts when another device tries to cast to this receiver</string>
<string name="label_send_anonymous_crash_logs">Send anonymous crash logs</string> <string name="label_send_anonymous_crash_logs">Send anonymous crash logs</string>
<string name="pin_anonymous_hint">Leave empty for anonymous cast</string> <string name="pin_anonymous_hint">Leave empty for anonymous cast</string>
<string name="label_receiver_display">Resolution</string> <string name="label_receiver_display">Resolution</string>
@@ -188,6 +203,7 @@
<string name="cast_in_progress_pin_locked">PIN cannot be changed while casting</string> <string name="cast_in_progress_pin_locked">PIN cannot be changed while casting</string>
<string name="tray_cast_active">Cast active — tap to open app</string> <string name="tray_cast_active">Cast active — tap to open app</string>
<string name="tray_cast_idle">Android Cast — tap to open app</string> <string name="tray_cast_idle">Android Cast — tap to open app</string>
<string name="tray_listening">Listening for casts — tap to open app</string>
<string name="tray_channel_name">Cast tray</string> <string name="tray_channel_name">Cast tray</string>
<string name="tray_channel_description">Persistent cast status icon in the notification bar</string> <string name="tray_channel_description">Persistent cast status icon in the notification bar</string>
<string name="swipe_for_settings">Swipe from the left edge for settings</string> <string name="swipe_for_settings">Swipe from the left edge for settings</string>

View File

@@ -15,13 +15,20 @@ public class CastTrayContentTest {
CastTrayContent.Body body = CastTrayContent.build( CastTrayContent.Body body = CastTrayContent.build(
"Tap to open", "Tap to open",
"Cast active", "Cast active",
"Listening",
"",
"",
"Waiting for WiFi", "Waiting for WiFi",
"adb connect %1$s", "adb connect %1$s",
"curl http://%1$s:%2$d/adb.json",
"",
5039,
"Polling BE (%1$s)", "Polling BE (%1$s)",
"Connected", "Connected",
"Session %1$s", "Session %1$s",
false, false,
false, false,
false,
"", "",
RemoteAccessMode.DISABLED, RemoteAccessMode.DISABLED,
null); null);
@@ -29,22 +36,56 @@ public class CastTrayContentTest {
assertEquals("Tap to open", body.bigText); 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 @Test
public void casting_overridesDevLines() { public void casting_overridesDevLines() {
CastTrayContent.Body body = CastTrayContent.build( CastTrayContent.Body body = CastTrayContent.build(
"Tap to open", "Tap to open",
"Cast active", "Cast active",
"Listening",
"",
"Casting to TV",
"Waiting", "Waiting",
"adb %1$s", "adb %1$s",
"curl http://%1$s:%2$d/adb.json",
"192.168.1.1",
5039,
"Poll %1$s", "Poll %1$s",
"Connected", "Connected",
"Session %1$s", "Session %1$s",
true, true,
false,
true, true,
"192.168.1.1:5555", "192.168.1.1:5555",
RemoteAccessMode.RSSH, RemoteAccessMode.RSSH,
snapshot(RemoteAccessStatusStore.STATE_WAIT, "")); snapshot(RemoteAccessStatusStore.STATE_WAIT, ""));
assertEquals("Cast active", body.summary); assertEquals("Casting to TV", body.summary);
} }
@Test @Test
@@ -54,12 +95,19 @@ public class CastTrayContentTest {
CastTrayContent.Body body = CastTrayContent.build( CastTrayContent.Body body = CastTrayContent.build(
"Tap to open", "Tap to open",
"Cast active", "Cast active",
"Listening",
"",
"",
"Waiting for WiFi", "Waiting for WiFi",
"adb connect %1$s", "adb connect %1$s",
"curl http://%1$s:%2$d/adb.json",
"",
5039,
"Polling BE (%1$s)", "Polling BE (%1$s)",
"Connected", "Connected",
"Session %1$s", "Session %1$s",
false, false,
false,
true, true,
"10.0.0.5:5555", "10.0.0.5:5555",
RemoteAccessMode.RSSH, RemoteAccessMode.RSSH,

View File

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