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 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 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 @@ + + + + + + + +