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:
@@ -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')
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) ->
|
||||||
|
|||||||
@@ -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++) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
35
app/src/main/java/com/foxx/androidcast/CastTrayStatus.java
Normal file
35
app/src/main/java/com/foxx/androidcast/CastTrayStatus.java
Normal 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("");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)";
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
36
app/src/main/java/com/foxx/androidcast/ota/OtaDefaults.java
Normal file
36
app/src/main/java/com/foxx/androidcast/ota/OtaDefaults.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
45
app/src/main/res/layout/activity_incoming_cast.xml
Normal file
45
app/src/main/res/layout/activity_incoming_cast.xml
Normal 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>
|
||||||
47
app/src/main/res/layout/activity_incoming_cast_pin.xml
Normal file
47
app/src/main/res/layout/activity_incoming_cast_pin.xml
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 Wi‑Fi 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>
|
||||||
|
|||||||
@@ -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 Wi‑Fi",
|
"Waiting for Wi‑Fi",
|
||||||
"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 Wi‑Fi",
|
"Waiting for Wi‑Fi",
|
||||||
"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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user