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 otaMinor = 1
|
||||
def otaBuild = 0
|
||||
def otaChannelUrlDefault = ''
|
||||
def otaChannelUrlDefault = 'https://apps.f0xx.org/v0/ota/channel/stable.json'
|
||||
def otaManifestUrlDefault = ''
|
||||
try {
|
||||
def lpFile = rootProject.file('local.properties')
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.DUMP"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<application
|
||||
android:name=".AndroidCastApplication"
|
||||
@@ -97,6 +97,22 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</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
|
||||
android:name=".receiver.ReceiverPlaybackActivity"
|
||||
android:exported="false"
|
||||
@@ -135,7 +151,7 @@
|
||||
<service
|
||||
android:name=".receiver.ReceiverCastService"
|
||||
android:exported="false"
|
||||
android:stopWithTask="true"
|
||||
android:stopWithTask="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<service
|
||||
@@ -196,6 +212,24 @@
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name=".TrayNotificationReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
@@ -49,6 +49,6 @@ public class AndroidCastApplication extends Application {
|
||||
EntitlementCoordinator.getInstance(this).bootstrap();
|
||||
RemoteAccessCoordinator.onBootIfNeeded(this);
|
||||
DevAdbWifiKeeper.ensureStarted(this);
|
||||
CastTrayNotifier.ensureTrayService(this);
|
||||
com.foxx.androidcast.boot.CastBootCoordinator.onProcessStart(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public final class AppPreferences {
|
||||
private static final String KEY_USERNAME = "username";
|
||||
private static final String KEY_PIN = "pin";
|
||||
private static final String KEY_SHOW_TRAY = "show_tray_icon";
|
||||
private static final String KEY_TRAY_ON_DEMAND = "tray_icon_on_demand";
|
||||
private static final String KEY_BACKGROUND_LISTEN = "background_listen_enabled";
|
||||
private static final String KEY_BLOCK_REPORT_ENDPOINT = "block_report_endpoint";
|
||||
private static final String KEY_THEME = "theme_mode";
|
||||
private static final String KEY_LOCALE = "locale_mode";
|
||||
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
|
||||
@@ -125,6 +128,34 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_SHOW_TRAY, show).apply();
|
||||
}
|
||||
|
||||
/** When true, tray icon appears only while casting/listening or app is in foreground. */
|
||||
public static boolean isTrayOnDemand(Context context) {
|
||||
return prefs(context).getBoolean(KEY_TRAY_ON_DEMAND, false);
|
||||
}
|
||||
|
||||
public static void setTrayOnDemand(Context context, boolean onDemand) {
|
||||
prefs(context).edit().putBoolean(KEY_TRAY_ON_DEMAND, onDemand).apply();
|
||||
}
|
||||
|
||||
/** Keep receiver FGS + LAN presence after reboot (no UI until incoming cast or user opens app). */
|
||||
public static boolean isBackgroundListenEnabled(Context context) {
|
||||
return prefs(context).getBoolean(KEY_BACKGROUND_LISTEN, true);
|
||||
}
|
||||
|
||||
public static void setBackgroundListenEnabled(Context context, boolean enabled) {
|
||||
prefs(context).edit().putBoolean(KEY_BACKGROUND_LISTEN, enabled).apply();
|
||||
}
|
||||
|
||||
/** Optional BE URL for block-user spam reports (queued locally when empty). */
|
||||
public static String getBlockReportEndpoint(Context context) {
|
||||
return prefs(context).getString(KEY_BLOCK_REPORT_ENDPOINT, "").trim();
|
||||
}
|
||||
|
||||
public static void setBlockReportEndpoint(Context context, String url) {
|
||||
prefs(context).edit().putString(KEY_BLOCK_REPORT_ENDPOINT,
|
||||
url != null ? url.trim() : "").apply();
|
||||
}
|
||||
|
||||
public static CastThemeHelper.AppThemeMode getThemeMode(Context context) {
|
||||
String name = prefs(context).getString(KEY_THEME, CastThemeHelper.AppThemeMode.SYSTEM.name());
|
||||
try {
|
||||
@@ -507,9 +538,9 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putInt(KEY_DEV_DIAG_PING_RESPONSE_MODE, v).apply();
|
||||
}
|
||||
|
||||
/** Developer: allow backend-provided NTP correction for diagnostics (default off). */
|
||||
/** Developer: allow backend-provided NTP correction for diagnostics (on by default in debug). */
|
||||
public static boolean isDevBackendNtpSyncEnabled(Context context) {
|
||||
return prefs(context).getBoolean(KEY_DEV_BACKEND_NTP_SYNC, false);
|
||||
return prefs(context).getBoolean(KEY_DEV_BACKEND_NTP_SYNC, BuildConfig.DEBUG);
|
||||
}
|
||||
|
||||
public static void setDevBackendNtpSyncEnabled(Context context, boolean enabled) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
public final class CastActiveState {
|
||||
private static final AtomicBoolean senderCasting = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean receiverListening = new AtomicBoolean(false);
|
||||
/** True while a sender is connected or media is actively streaming to this device. */
|
||||
private static final AtomicBoolean receiverStreaming = new AtomicBoolean(false);
|
||||
|
||||
private CastActiveState() {}
|
||||
|
||||
@@ -35,4 +37,12 @@ public final class CastActiveState {
|
||||
public static boolean isReceiverListening() {
|
||||
return receiverListening.get();
|
||||
}
|
||||
|
||||
public static void setReceiverStreaming(boolean active) {
|
||||
receiverStreaming.set(active);
|
||||
}
|
||||
|
||||
public static boolean isReceiverStreaming() {
|
||||
return receiverStreaming.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,26 @@ import android.content.Intent;
|
||||
/** Remembers the last foreground activity for tray / notification resume. */
|
||||
public final class CastActivityTracker {
|
||||
private static volatile Class<? extends Activity> lastActivityClass;
|
||||
private static volatile boolean anyActivityForeground;
|
||||
|
||||
private CastActivityTracker() {}
|
||||
|
||||
public static void onResume(Activity activity) {
|
||||
if (activity != null) {
|
||||
lastActivityClass = activity.getClass();
|
||||
anyActivityForeground = true;
|
||||
CastTrayNotifier.syncWhenForeground(activity);
|
||||
}
|
||||
}
|
||||
|
||||
public static void onPause(Activity activity) {
|
||||
anyActivityForeground = false;
|
||||
}
|
||||
|
||||
public static boolean isAnyActivityForeground() {
|
||||
return anyActivityForeground;
|
||||
}
|
||||
|
||||
public static Intent resumeIntent(Context context) {
|
||||
Class<? extends Activity> cls = lastActivityClass;
|
||||
if (cls != null) {
|
||||
|
||||
@@ -89,6 +89,21 @@ public class CastSettingsFragment extends Fragment {
|
||||
AppPreferences.setShowTrayIcon(requireContext(), checked);
|
||||
CastTrayNotifier.onPreferenceChanged(requireContext());
|
||||
});
|
||||
CheckBox trayOnDemandCheck = view.findViewById(R.id.check_tray_on_demand);
|
||||
if (trayOnDemandCheck != null) {
|
||||
trayOnDemandCheck.setChecked(AppPreferences.isTrayOnDemand(requireContext()));
|
||||
trayOnDemandCheck.setOnCheckedChangeListener((btn, checked) -> {
|
||||
AppPreferences.setTrayOnDemand(requireContext(), checked);
|
||||
CastTrayNotifier.onPreferenceChanged(requireContext());
|
||||
});
|
||||
}
|
||||
CheckBox backgroundListenCheck = view.findViewById(R.id.check_background_listen);
|
||||
if (backgroundListenCheck != null) {
|
||||
backgroundListenCheck.setChecked(AppPreferences.isBackgroundListenEnabled(requireContext()));
|
||||
backgroundListenCheck.setOnCheckedChangeListener((btn, checked) ->
|
||||
com.foxx.androidcast.boot.CastBootCoordinator.applyBackgroundListenPreference(
|
||||
requireContext(), checked));
|
||||
}
|
||||
if (crashLogsCheck != null) {
|
||||
crashLogsCheck.setChecked(AppPreferences.isSendAnonymousCrashLogs(requireContext()));
|
||||
crashLogsCheck.setOnCheckedChangeListener((btn, checked) ->
|
||||
|
||||
@@ -23,20 +23,31 @@ public final class CastTrayContent {
|
||||
public static Body build(
|
||||
String idleTapToOpen,
|
||||
String castActive,
|
||||
String listeningFallback,
|
||||
String receiverStatusLine,
|
||||
String senderStatusLine,
|
||||
String adbWaiting,
|
||||
String adbLineFormat,
|
||||
String devHttpLineFormat,
|
||||
String devHttpHost,
|
||||
int devHttpPort,
|
||||
String raPollFormat,
|
||||
String raConnected,
|
||||
String raSessionFormat,
|
||||
boolean casting,
|
||||
boolean listening,
|
||||
boolean adbEnabled,
|
||||
String adbConnectLine,
|
||||
RemoteAccessMode raMode,
|
||||
RemoteAccessStatusStore.Snapshot raSnap) {
|
||||
if (casting) {
|
||||
return new Body(castActive, castActive);
|
||||
String line = pickFirstNonEmpty(senderStatusLine, receiverStatusLine, castActive);
|
||||
return new Body(line, line);
|
||||
}
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (listening) {
|
||||
lines.add(pickFirstNonEmpty(receiverStatusLine, listeningFallback));
|
||||
}
|
||||
String raLine = remoteAccessLine(raPollFormat, raConnected, raSessionFormat, raMode, raSnap);
|
||||
if (raLine != null && !raLine.isEmpty()) {
|
||||
lines.add(raLine);
|
||||
@@ -47,6 +58,9 @@ public final class CastTrayContent {
|
||||
: String.format(adbLineFormat, adbConnectLine);
|
||||
lines.add(adbLine);
|
||||
}
|
||||
if (devHttpHost != null && !devHttpHost.isEmpty() && devHttpPort > 0) {
|
||||
lines.add(String.format(devHttpLineFormat, devHttpHost, devHttpPort));
|
||||
}
|
||||
if (!lines.isEmpty()) {
|
||||
String joined = joinLines(lines);
|
||||
return new Body(lines.get(0), joined);
|
||||
@@ -79,6 +93,15 @@ public final class CastTrayContent {
|
||||
return String.format(pollFormat, mode.name());
|
||||
}
|
||||
|
||||
private static String pickFirstNonEmpty(String... candidates) {
|
||||
for (String c : candidates) {
|
||||
if (c != null && !c.isEmpty()) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String joinLines(List<String> lines) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
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_RA_ID = 0x7a00;
|
||||
public static final int LEGACY_RA_VPN_ID = 0x7a01;
|
||||
public static final int LEGACY_RECEIVER_ID = 2;
|
||||
public static final int LEGACY_SENDER_ID = 1;
|
||||
|
||||
private CastTrayNotifier() {}
|
||||
|
||||
@@ -57,6 +59,12 @@ public final class CastTrayNotifier {
|
||||
}
|
||||
|
||||
public static boolean shouldMaintainTray(Context context) {
|
||||
if (AppPreferences.isTrayOnDemand(context)) {
|
||||
return CastActiveState.isSenderCasting()
|
||||
|| CastActiveState.isReceiverListening()
|
||||
|| CastActivityTracker.isAnyActivityForeground()
|
||||
|| hasDevTrayContent(context);
|
||||
}
|
||||
if (AppPreferences.isShowTrayIcon(context)) {
|
||||
return true;
|
||||
}
|
||||
@@ -78,6 +86,11 @@ public final class CastTrayNotifier {
|
||||
if (!shouldMaintainTray(app)) {
|
||||
return;
|
||||
}
|
||||
// ReceiverCastService / ScreenCastService already hold the unified tray as FGS.
|
||||
if (CastActiveState.isReceiverListening() || CastActiveState.isSenderCasting()) {
|
||||
publish(app);
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(app, TrayForegroundService.class);
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
@@ -131,7 +144,8 @@ public final class CastTrayNotifier {
|
||||
|
||||
public static Notification buildNotification(Context context) {
|
||||
createChannel(context);
|
||||
boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverListening();
|
||||
boolean casting = CastActiveState.isSenderCasting() || CastActiveState.isReceiverStreaming();
|
||||
boolean listening = CastActiveState.isReceiverListening() && !casting;
|
||||
boolean adbEnabled = BuildConfig.DEBUG && AppPreferences.isDevAdbWifiKeeperEnabled(context);
|
||||
RemoteAccessMode raMode = BuildConfig.DEBUG
|
||||
? AppPreferences.getDevRemoteAccessMode(context)
|
||||
@@ -139,16 +153,27 @@ public final class CastTrayNotifier {
|
||||
RemoteAccessStatusStore.Snapshot raSnap = RemoteAccessStatusStore.load(context);
|
||||
DevAdbConnectInfo adbInfo = adbEnabled ? DevAdbWifiKeeper.getLastInfo(context) : null;
|
||||
String adbLine = adbInfo != null ? adbInfo.primaryConnectLine() : "";
|
||||
String devHttpHost = "";
|
||||
if (adbInfo != null && !adbInfo.ips.isEmpty()) {
|
||||
devHttpHost = adbInfo.ips.get(0);
|
||||
}
|
||||
|
||||
CastTrayContent.Body body = CastTrayContent.build(
|
||||
context.getString(R.string.tray_cast_idle),
|
||||
context.getString(R.string.tray_cast_active),
|
||||
context.getString(R.string.tray_listening),
|
||||
CastTrayStatus.getReceiverLine(),
|
||||
CastTrayStatus.getSenderLine(),
|
||||
context.getString(R.string.dev_adb_wifi_notification_waiting),
|
||||
context.getString(R.string.dev_adb_wifi_notification_line),
|
||||
context.getString(R.string.tray_dev_http_line),
|
||||
devHttpHost,
|
||||
DevAdbConnectInfo.HTTP_PORT,
|
||||
context.getString(R.string.dev_remote_access_notification_poll),
|
||||
context.getString(R.string.dev_remote_access_notification_connected),
|
||||
context.getString(R.string.dev_remote_access_notification_session),
|
||||
casting,
|
||||
listening,
|
||||
adbEnabled,
|
||||
adbLine,
|
||||
raMode,
|
||||
@@ -184,7 +209,7 @@ public final class CastTrayNotifier {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static void publish(Context context) {
|
||||
public static void publish(Context context) {
|
||||
if (!shouldMaintainTray(context)) {
|
||||
return;
|
||||
}
|
||||
@@ -202,6 +227,8 @@ public final class CastTrayNotifier {
|
||||
nm.cancel(LEGACY_DEV_ADB_ID);
|
||||
nm.cancel(LEGACY_RA_ID);
|
||||
nm.cancel(LEGACY_RA_VPN_ID);
|
||||
nm.cancel(LEGACY_RECEIVER_ID);
|
||||
nm.cancel(LEGACY_SENDER_ID);
|
||||
}
|
||||
|
||||
private static void hide(Context context) {
|
||||
|
||||
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.WiredDisplayMonitor;
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
import com.foxx.androidcast.ota.OtaDefaults;
|
||||
import com.foxx.androidcast.ota.OtaUpdateChecker;
|
||||
import com.foxx.androidcast.ota.OtaUpdateCoordinator;
|
||||
import com.foxx.androidcast.receiver.av.ReceiverAudioPreset;
|
||||
@@ -117,6 +118,9 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
|
||||
checkOta.setOnClickListener(v -> {
|
||||
String url = manifestUrl.getText() != null ? manifestUrl.getText().toString().trim() : "";
|
||||
if (url.isEmpty()) {
|
||||
url = OtaDefaults.effectiveChannelUrl(this);
|
||||
}
|
||||
AppPreferences.setOtaChannelUrl(this, url);
|
||||
OtaUpdateCoordinator.requestManualCheck(this);
|
||||
});
|
||||
@@ -692,14 +696,7 @@ public class DeveloperSettingsActivity extends AppCompatActivity {
|
||||
OtaUpdateChecker.installedVersionCode(this)));
|
||||
}
|
||||
if (manifestUrl != null) {
|
||||
String url = AppPreferences.getOtaChannelUrl(this);
|
||||
if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
|
||||
}
|
||||
if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
|
||||
}
|
||||
manifestUrl.setText(url);
|
||||
manifestUrl.setText(OtaDefaults.effectiveChannelUrl(this));
|
||||
}
|
||||
if (otaOnLaunch != null) {
|
||||
otaOnLaunch.setOnCheckedChangeListener(null);
|
||||
|
||||
@@ -115,6 +115,12 @@ public abstract class DrawerHostActivity extends AppCompatActivity {
|
||||
CastActivityTracker.onResume(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
CastActivityTracker.onPause(this);
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
protected void openSettingsDrawer() {
|
||||
if (drawerLayout != null) {
|
||||
drawerLayout.openDrawer(GravityCompat.START);
|
||||
|
||||
@@ -16,6 +16,7 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.view.View;
|
||||
import android.os.Bundle;
|
||||
|
||||
import com.foxx.androidcast.dev.DevInstallWakeHelper;
|
||||
@@ -67,11 +68,7 @@ public class MainActivity extends DrawerHostActivity {
|
||||
|
||||
findViewById(R.id.btn_send).setOnClickListener(v ->
|
||||
startActivity(new Intent(this, SenderActivity.class)));
|
||||
findViewById(R.id.btn_receive).setOnClickListener(v -> {
|
||||
Intent i = new Intent(this, ReceiverPlaybackActivity.class);
|
||||
i.putExtra(ReceiverPlaybackActivity.EXTRA_AUTO_START_LISTENING, true);
|
||||
startActivity(i);
|
||||
});
|
||||
findViewById(R.id.btn_receive).setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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.sender.CodecCatalog;
|
||||
|
||||
import com.foxx.androidcast.ota.OtaDefaults;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -166,7 +170,7 @@ public final class DevDiagnosticsProbe {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(connectivitySummary(context)).append('\n');
|
||||
String crashUrl = resolveCrashUploadUrl(context);
|
||||
sb.append('\n').append(probeHttp("Crash upload", crashUrl, cancel));
|
||||
sb.append('\n').append(probeCrashUpload("Crash upload", crashUrl, cancel));
|
||||
appendOtaProbe(sb, context, cancel);
|
||||
sb.append('\n').append("-----").append('\n');
|
||||
sb.append(DevVpnStatusProbe.formatForDiagnostics(context));
|
||||
@@ -366,6 +370,52 @@ public final class DevDiagnosticsProbe {
|
||||
return "Connectivity: " + (parts.isEmpty() ? "other" : String.join(", ", parts));
|
||||
}
|
||||
|
||||
/** POST-only upload endpoint — reject probe body with 400 when reachable. */
|
||||
private static String probeCrashUpload(String label, String url, AtomicBoolean cancel) {
|
||||
if (cancel != null && cancel.get()) {
|
||||
return label + ": stopped";
|
||||
}
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
return label + ": (no URL configured)";
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
long t0 = System.currentTimeMillis();
|
||||
try {
|
||||
String host = Uri.parse(trimmed).getHost();
|
||||
if (host == null || host.isEmpty()) {
|
||||
return label + ": invalid URL";
|
||||
}
|
||||
InetAddress addr = InetAddress.getByName(host);
|
||||
long dnsMs = System.currentTimeMillis() - t0;
|
||||
if (cancel != null && cancel.get()) {
|
||||
return label + ": stopped";
|
||||
}
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(trimmed).openConnection();
|
||||
conn.setConnectTimeout(HTTP_TIMEOUT_MS);
|
||||
conn.setReadTimeout(HTTP_TIMEOUT_MS);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
byte[] body = "{\"schema_version\":0}".getBytes(StandardCharsets.UTF_8);
|
||||
try (OutputStream out = conn.getOutputStream()) {
|
||||
out.write(body);
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
long totalMs = System.currentTimeMillis() - t0;
|
||||
conn.disconnect();
|
||||
if (code == 400) {
|
||||
return label + ": reachable (HTTP 400 on probe — POST-only endpoint), DNS "
|
||||
+ dnsMs + " ms, total " + totalMs + " ms (" + addr.getHostAddress() + ")";
|
||||
}
|
||||
return label + ": HTTP " + code + ", DNS " + dnsMs + " ms, total " + totalMs + " ms"
|
||||
+ " (" + addr.getHostAddress() + ")";
|
||||
} catch (IOException e) {
|
||||
return label + ": fail — " + e.getClass().getSimpleName() + ": " + e.getMessage()
|
||||
+ " (" + (System.currentTimeMillis() - t0) + " ms)";
|
||||
}
|
||||
}
|
||||
|
||||
private static String probeHttp(String label, String url, AtomicBoolean cancel) {
|
||||
if (cancel != null && cancel.get()) {
|
||||
return label + ": stopped";
|
||||
@@ -406,11 +456,7 @@ public final class DevDiagnosticsProbe {
|
||||
}
|
||||
|
||||
private static String resolveOtaUrl(Context context) {
|
||||
String url = AppPreferences.getOtaChannelUrl(context);
|
||||
if (url.isEmpty() && BuildConfig.OTA_CHANNEL_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim();
|
||||
}
|
||||
return url;
|
||||
return OtaDefaults.effectiveChannelUrl(context);
|
||||
}
|
||||
|
||||
private static String miracastSummary(PackageManager pm) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.os.PowerManager;
|
||||
import android.telephony.TelephonyManager;
|
||||
|
||||
import com.foxx.androidcast.AppPreferences;
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.discovery.CastLanPeerProbe;
|
||||
@@ -248,7 +249,8 @@ public final class DevNetworkSelfTestProbe {
|
||||
r.itemPass("local-tz-offset", tzMin + " min");
|
||||
boolean enabled = AppPreferences.isDevBackendNtpSyncEnabled(context);
|
||||
r.itemBool(enabled ? DevNetworkSelfTestReport.Level.PASS : DevNetworkSelfTestReport.Level.NA,
|
||||
"be-ntp-sync", enabled, "enabled (±3 min threshold)", "disabled (dev setting)");
|
||||
"be-ntp-sync", enabled, "enabled (±3 min threshold)",
|
||||
BuildConfig.DEBUG ? "disabled (dev setting; default on in debug)" : "disabled (dev setting)");
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ public final class DevVpnStatusProbe {
|
||||
} else {
|
||||
tunnelLevel = DevNetworkSelfTestReport.Level.WARN;
|
||||
}
|
||||
String tunnelLabel = formatTunnelStateLabel(tunnelState, last);
|
||||
String tunnelLabel = formatTunnelStateLabel(mode, tunnelState, last);
|
||||
r.item(tunnelLevel, "tunnel-state", tunnelLabel);
|
||||
|
||||
if (mode != RemoteAccessMode.DISABLED
|
||||
@@ -247,7 +247,13 @@ public final class DevVpnStatusProbe {
|
||||
return "not_whitelisted".equals(detail);
|
||||
}
|
||||
|
||||
private static String formatTunnelStateLabel(String tunnelState, RemoteAccessStatusStore.Snapshot last) {
|
||||
private static String formatTunnelStateLabel(
|
||||
RemoteAccessMode mode,
|
||||
String tunnelState,
|
||||
RemoteAccessStatusStore.Snapshot last) {
|
||||
if (mode == RemoteAccessMode.DISABLED) {
|
||||
return "off (remote access disabled)";
|
||||
}
|
||||
if (RemoteAccessStatusStore.STATE_WAIT.equals(last.state)) {
|
||||
if (isAwaitingWhitelist(last.detail)) {
|
||||
return "idle (awaiting whitelist — no tunnel expected)";
|
||||
|
||||
@@ -12,6 +12,9 @@ package com.foxx.androidcast.network;
|
||||
* Digest: SHA256 dbf1f38a83f982eeae65310c6c91e22b86fcee043fdee0b33260a400e8331025
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.receiver.BlockedSendersStore;
|
||||
import com.foxx.androidcast.receiver.IncomingCastGate;
|
||||
import com.foxx.androidcast.receiver.IncomingCastPinGate;
|
||||
import com.foxx.androidcast.media.AudioNegotiator;
|
||||
import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.CodecPriorityCatalog;
|
||||
@@ -23,6 +26,8 @@ import com.foxx.androidcast.stats.SessionStatsContext;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/** Auth handshake and message I/O over a {@link CastTransport}. */
|
||||
public class CastSession {
|
||||
public static final class HandshakeResult {
|
||||
@@ -112,10 +117,16 @@ public class CastSession {
|
||||
}
|
||||
|
||||
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings) throws IOException {
|
||||
return serverHandshake(expectedPin, localSettings, null, false);
|
||||
}
|
||||
|
||||
public HandshakeResult serverHandshake(String expectedPin, CastSettings localSettings,
|
||||
Context appContext, boolean promptIncoming) throws IOException {
|
||||
String senderName = "Sender";
|
||||
boolean authed = false;
|
||||
CastSettings remote = null;
|
||||
List<String> senderCaps = null;
|
||||
boolean incomingPromptDone = false;
|
||||
long deadline = System.currentTimeMillis() + 30_000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
CastProtocol.Message msg = receive(2_000);
|
||||
@@ -125,12 +136,51 @@ public class CastSession {
|
||||
switch (msg.type) {
|
||||
case CastProtocol.MSG_HELLO:
|
||||
senderName = CastProtocol.payloadAsUtf8(msg.payload);
|
||||
if (promptIncoming && appContext != null && !incomingPromptDone) {
|
||||
incomingPromptDone = true;
|
||||
if (BlockedSendersStore.isBlocked(appContext, senderName)) {
|
||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
throw new IOException("Blocked sender");
|
||||
}
|
||||
try {
|
||||
IncomingCastGate.Decision decision =
|
||||
IncomingCastGate.awaitDecision(appContext, senderName);
|
||||
if (decision != IncomingCastGate.Decision.ACCEPT) {
|
||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
throw new IOException("Incoming cast declined");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Incoming cast interrupted");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CastProtocol.MSG_AUTH:
|
||||
if (CastProtocol.pinMatches(msg.payload, expectedPin)) {
|
||||
String pinForAuth = expectedPin;
|
||||
if (promptIncoming && appContext != null) {
|
||||
try {
|
||||
String entered = IncomingCastPinGate.awaitPin(
|
||||
appContext, senderName, msg.payload);
|
||||
if (entered == null) {
|
||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
throw new IOException("PIN entry cancelled");
|
||||
}
|
||||
pinForAuth = entered;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("PIN entry interrupted");
|
||||
}
|
||||
}
|
||||
if (CastProtocol.pinMatches(msg.payload, pinForAuth)) {
|
||||
authed = true;
|
||||
transport.send(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_OK, new byte[0]);
|
||||
if (promptIncoming && appContext != null) {
|
||||
IncomingCastGate.onAccepted(appContext);
|
||||
}
|
||||
} else {
|
||||
transport.send(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
recordOutbound(CastProtocol.MSG_AUTH_FAIL, new byte[0]);
|
||||
|
||||
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());
|
||||
long interval = OtaRuntimeSettings.DEFAULT_CHECK_INTERVAL_MS;
|
||||
@@ -136,7 +137,7 @@ final class OtaSettingsStore {
|
||||
if (BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
|
||||
return BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
|
||||
}
|
||||
return "https://foxx.org/v0/ota/channel/stable.json";
|
||||
return OtaDefaults.FALLBACK_CHANNEL_URL;
|
||||
}
|
||||
|
||||
static long parseDurationMs(String raw, long fallback) {
|
||||
|
||||
@@ -34,15 +34,7 @@ public final class OtaUpdateCoordinator {
|
||||
private OtaUpdateCoordinator() {}
|
||||
|
||||
public static String resolveEntryUrl(Context context) {
|
||||
String url = AppPreferences.getOtaChannelUrl(context);
|
||||
if (url.isEmpty()) {
|
||||
url = BuildConfig.OTA_CHANNEL_URL_DEFAULT != null
|
||||
? BuildConfig.OTA_CHANNEL_URL_DEFAULT.trim() : "";
|
||||
}
|
||||
if (url.isEmpty() && BuildConfig.OTA_MANIFEST_URL_DEFAULT != null) {
|
||||
url = BuildConfig.OTA_MANIFEST_URL_DEFAULT.trim();
|
||||
}
|
||||
return url;
|
||||
return OtaDefaults.effectiveChannelUrl(context);
|
||||
}
|
||||
|
||||
public static void requestCheckOnLaunch(Context context) {
|
||||
|
||||
@@ -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.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
@@ -30,6 +31,7 @@ import com.foxx.androidcast.CastActiveState;
|
||||
import com.foxx.androidcast.CastNotifications;
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.CastTrayNotifier;
|
||||
import com.foxx.androidcast.CastTrayStatus;
|
||||
import com.foxx.androidcast.ICastReceiverService;
|
||||
import com.foxx.androidcast.ICastStatusCallback;
|
||||
import com.foxx.androidcast.IntentExtras;
|
||||
@@ -77,6 +79,14 @@ public class ReceiverCastService extends Service {
|
||||
public static final String EXTRA_PLAYBACK_UI_VISIBLE = "playback_ui_visible";
|
||||
public static final String EXTRA_PIN = "pin";
|
||||
public static final String EXTRA_ALLOW_AUDIO = "allow_audio";
|
||||
public static final String EXTRA_HEADLESS = "headless";
|
||||
public static final String EXTRA_REQUIRE_INCOMING_PROMPT = "require_incoming_prompt";
|
||||
|
||||
public static void stop(Context context) {
|
||||
Intent intent = new Intent(context, ReceiverCastService.class);
|
||||
intent.setAction(ACTION_STOP);
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
private enum Phase {
|
||||
IDLE, LISTENING, CONNECTED, STREAMING
|
||||
@@ -114,6 +124,8 @@ public class ReceiverCastService extends Service {
|
||||
private int decoderHeight;
|
||||
private boolean decoderActive;
|
||||
|
||||
/** Prompt Accept/Decline/Block before completing handshake (background receive mode). */
|
||||
private volatile boolean requireIncomingPrompt;
|
||||
/** Set from receiver UI before each listen session. */
|
||||
private boolean allowIncomingAudio = true;
|
||||
/** null until first audio config this session; then mirrors user preference. */
|
||||
@@ -210,12 +222,19 @@ public class ReceiverCastService extends Service {
|
||||
}
|
||||
if (intent != null && ACTION_PLAYBACK_UI.equals(intent.getAction())) {
|
||||
playbackUiVisible = intent.getBooleanExtra(EXTRA_PLAYBACK_UI_VISIBLE, false);
|
||||
if (playbackUiVisible) {
|
||||
requireIncomingPrompt = false;
|
||||
}
|
||||
if (!playbackUiVisible) {
|
||||
playbackLaunched = false;
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
if (intent != null && ACTION_START.equals(intent.getAction())) {
|
||||
requireIncomingPrompt = intent.getBooleanExtra(EXTRA_REQUIRE_INCOMING_PROMPT, false);
|
||||
if (intent.getBooleanExtra(EXTRA_HEADLESS, false)) {
|
||||
playbackUiVisible = false;
|
||||
}
|
||||
String pin = intent.getStringExtra(EXTRA_PIN);
|
||||
if (pin == null) {
|
||||
pin = AppPreferences.getPin(this);
|
||||
@@ -256,10 +275,16 @@ public class ReceiverCastService extends Service {
|
||||
playbackLaunched = false;
|
||||
phase = Phase.LISTENING;
|
||||
acquireWakeLock();
|
||||
startForeground(CastNotifications.ID_RECEIVER,
|
||||
CastNotifications.receiver(this, getString(R.string.receiver_listening), ReceiverCastService.class));
|
||||
String statusLine = getString(R.string.receiver_listening);
|
||||
CastTrayStatus.setReceiverLine(statusLine);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CastTrayNotifier.startForeground(this,
|
||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
CastTrayNotifier.startForeground(this, 0);
|
||||
}
|
||||
CastActiveState.setReceiverListening(true);
|
||||
CastTrayNotifier.refresh(this);
|
||||
CastActiveState.setReceiverStreaming(false);
|
||||
|
||||
if (videoDecoder != null) {
|
||||
videoDecoder.release();
|
||||
@@ -462,7 +487,7 @@ public class ReceiverCastService extends Service {
|
||||
public void onCodecRenegotiation(CastProtocol.CodecSelection selection) {
|
||||
mainHandler.post(() -> applyCodecRenegotiation(selection));
|
||||
}
|
||||
}, this::enrichReceiverStats);
|
||||
}, this::enrichReceiverStats, requireIncomingPrompt);
|
||||
session.start();
|
||||
updateStatus(getString(R.string.receiver_ready, pin, settings.getTransport().toUpperCase()));
|
||||
openPlaybackAwaiting(R.string.playback_listening);
|
||||
@@ -472,6 +497,8 @@ public class ReceiverCastService extends Service {
|
||||
private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) {
|
||||
phase = Phase.CONNECTED;
|
||||
castEnded = false;
|
||||
CastActiveState.setReceiverStreaming(true);
|
||||
CastTrayNotifier.publish(this);
|
||||
if (AppPreferences.isGrabSessionStats(this)) {
|
||||
if (sessionStatsRecorder == null) {
|
||||
sessionStatsRecorder = new SessionStatsRecorder("recv", settings.getTransport());
|
||||
@@ -564,6 +591,8 @@ public class ReceiverCastService extends Service {
|
||||
private void onSenderDisconnected() {
|
||||
stopAudioPlaybackImmediate();
|
||||
phase = Phase.LISTENING;
|
||||
CastActiveState.setReceiverStreaming(false);
|
||||
CastTrayNotifier.publish(this);
|
||||
audioAccepted = null;
|
||||
remoteCastSettings = null;
|
||||
streamIdle = false;
|
||||
@@ -698,6 +727,8 @@ public class ReceiverCastService extends Service {
|
||||
boolean decoderWasActive = decoderActive;
|
||||
phase = Phase.STREAMING;
|
||||
streamIdle = false;
|
||||
CastActiveState.setReceiverStreaming(true);
|
||||
CastTrayNotifier.publish(this);
|
||||
if (firstConfig) {
|
||||
streamMetrics.reset();
|
||||
}
|
||||
@@ -1118,9 +1149,8 @@ public class ReceiverCastService extends Service {
|
||||
private void updateStatus(String s) {
|
||||
status = s;
|
||||
broadcastStatus(s);
|
||||
NotificationManager nm = getSystemService(NotificationManager.class);
|
||||
nm.notify(CastNotifications.ID_RECEIVER,
|
||||
CastNotifications.receiver(this, s, ReceiverCastService.class));
|
||||
CastTrayStatus.setReceiverLine(s);
|
||||
CastTrayNotifier.publish(this);
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
@@ -1200,6 +1230,8 @@ public class ReceiverCastService extends Service {
|
||||
private void releaseAll() {
|
||||
finishSessionStatsAsync();
|
||||
CastActiveState.setReceiverListening(false);
|
||||
CastActiveState.setReceiverStreaming(false);
|
||||
CastTrayStatus.clearReceiverLine();
|
||||
CastTrayNotifier.refresh(this);
|
||||
phase = Phase.IDLE;
|
||||
resetStreamState();
|
||||
|
||||
@@ -222,6 +222,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
intent.setAction(ReceiverCastService.ACTION_START);
|
||||
intent.putExtra(ReceiverCastService.EXTRA_PIN, pin);
|
||||
intent.putExtra(ReceiverCastService.EXTRA_ALLOW_AUDIO, allowAudio);
|
||||
intent.putExtra(ReceiverCastService.EXTRA_REQUIRE_INCOMING_PROMPT, false);
|
||||
intent.putExtra(CastSettings.EXTRA, settings);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent);
|
||||
|
||||
@@ -108,19 +108,26 @@ public class ReceiverSession {
|
||||
private final CastSessionGate inboundSessionGate = new CastSessionGate();
|
||||
private boolean loggedFirstVideoFrame;
|
||||
private NetworkFeedbackManager networkFeedback;
|
||||
private PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||
private final PassThroughNetworkControlPlane.StatsEnricher statsEnricher;
|
||||
private final boolean requireIncomingPrompt;
|
||||
|
||||
public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener) {
|
||||
this(context, pin, localSettings, listener, null);
|
||||
this(context, pin, localSettings, listener, null, false);
|
||||
}
|
||||
|
||||
public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
|
||||
PassThroughNetworkControlPlane.StatsEnricher statsEnricher) {
|
||||
this(context, pin, localSettings, listener, statsEnricher, false);
|
||||
}
|
||||
|
||||
public ReceiverSession(Context context, String pin, CastSettings localSettings, Listener listener,
|
||||
PassThroughNetworkControlPlane.StatsEnricher statsEnricher, boolean requireIncomingPrompt) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.pin = pin;
|
||||
this.localSettings = localSettings;
|
||||
this.listener = listener;
|
||||
this.statsEnricher = statsEnricher;
|
||||
this.requireIncomingPrompt = requireIncomingPrompt;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
@@ -197,7 +204,8 @@ public class ReceiverSession {
|
||||
listener::onDiagPingSample);
|
||||
}
|
||||
session.acceptClient();
|
||||
CastSession.HandshakeResult hs = session.serverHandshake(pin, localSettings);
|
||||
CastSession.HandshakeResult hs = session.serverHandshake(
|
||||
pin, localSettings, appContext, requireIncomingPrompt);
|
||||
applyNegotiatedProtection(hs.settings);
|
||||
listener.onAuthenticated(hs.senderName, hs.settings, hs.negotiatedVideoMime);
|
||||
postStatus("Streaming from " + hs.senderName);
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.BuildConfig;
|
||||
import com.foxx.androidcast.crash.CrashSettingsStore;
|
||||
import com.foxx.androidcast.ota.OtaDefaults;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
@@ -31,7 +32,7 @@ public final class RemoteAccessHeartbeatClient {
|
||||
return derived;
|
||||
}
|
||||
}
|
||||
String ota = com.foxx.androidcast.AppPreferences.getOtaChannelUrl(context);
|
||||
String ota = OtaDefaults.effectiveChannelUrl(context);
|
||||
if (ota != null && !ota.trim().isEmpty()) {
|
||||
String derived = toHeartbeatUrl(normalizeBackendBase(ota.trim()));
|
||||
if (!derived.isEmpty()) {
|
||||
|
||||
@@ -122,6 +122,17 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void selectAllVisible() {
|
||||
selectedPositions.clear();
|
||||
for (int i = 0; i < devices.size(); i++) {
|
||||
selectedPositions.add(i);
|
||||
}
|
||||
if (!multiSelect && !devices.isEmpty()) {
|
||||
selectedPosition = 0;
|
||||
}
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return devices.size();
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.foxx.androidcast.CastActiveState;
|
||||
import com.foxx.androidcast.display.ExternalDisplayCapturePolicy;
|
||||
import com.foxx.androidcast.display.WiredDisplayMonitor;
|
||||
import com.foxx.androidcast.CastTrayNotifier;
|
||||
import com.foxx.androidcast.CastTrayStatus;
|
||||
import com.foxx.androidcast.CastNotifications;
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
import com.foxx.androidcast.CastResolution;
|
||||
@@ -1046,6 +1047,7 @@ public class ScreenCastService extends Service implements
|
||||
stopping.set(true);
|
||||
casting.set(false);
|
||||
CastActiveState.setSenderCasting(false);
|
||||
CastTrayStatus.clearSenderLine();
|
||||
CastTrayNotifier.refresh(this);
|
||||
networkFeedback = null;
|
||||
calibrationMode = false;
|
||||
@@ -1130,15 +1132,18 @@ public class ScreenCastService extends Service implements
|
||||
}
|
||||
|
||||
private void startSenderForeground(String text, boolean withAudio) {
|
||||
android.app.Notification n = CastNotifications.sender(this, text, ScreenCastService.class);
|
||||
CastTrayStatus.setSenderLine(text);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
int fgsType = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION;
|
||||
if (withAudio) {
|
||||
fgsType |= ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE;
|
||||
}
|
||||
startForeground(CastNotifications.ID_SENDER, n, fgsType);
|
||||
CastTrayNotifier.startForeground(this, fgsType);
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
CastTrayNotifier.startForeground(this,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
|
||||
} else {
|
||||
startForeground(CastNotifications.ID_SENDER, n);
|
||||
CastTrayNotifier.startForeground(this, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1189,9 +1194,8 @@ public class ScreenCastService extends Service implements
|
||||
private void updateStatus(String text) {
|
||||
status = text;
|
||||
broadcastStatus(text);
|
||||
android.app.NotificationManager nm = getSystemService(android.app.NotificationManager.class);
|
||||
nm.notify(CastNotifications.ID_SENDER,
|
||||
CastNotifications.sender(this, text, ScreenCastService.class));
|
||||
CastTrayStatus.setSenderLine(text);
|
||||
CastTrayNotifier.publish(this);
|
||||
}
|
||||
|
||||
private void broadcastStatus(String s) {
|
||||
|
||||
@@ -126,6 +126,20 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
? ListView.CHOICE_MODE_MULTIPLE : ListView.CHOICE_MODE_SINGLE);
|
||||
deviceAdapter = new DeviceListAdapter(this, CastConfig.MULTI_RECEIVER_ENABLED);
|
||||
listView.setAdapter(deviceAdapter);
|
||||
Button selectAll = findViewById(R.id.btn_select_all_nearby);
|
||||
if (selectAll != null) {
|
||||
selectAll.setVisibility(CastConfig.MULTI_RECEIVER_ENABLED ? View.VISIBLE : View.GONE);
|
||||
selectAll.setOnClickListener(v -> {
|
||||
deviceAdapter.selectAllVisible();
|
||||
java.util.List<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) -> {
|
||||
deviceAdapter.setSelectedPosition(position);
|
||||
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: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
|
||||
android:id="@+id/panel_preview"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -87,6 +87,21 @@
|
||||
android:checked="true"
|
||||
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
|
||||
android:id="@+id/check_send_crash_logs"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
<string name="cast_in_progress_pin_locked">PIN не может быть изменен посреди трансляции</string>
|
||||
<string name="tray_cast_active">Трансляция активна — нажмите чтобы открыть</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_description">Всегда показывать иконку статуса в строке состояния</string>
|
||||
<string name="swipe_for_settings">Проведите по левому краю чтобы перейти к настройкам</string>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<string name="app_name">Android Cast</string>
|
||||
<string name="send_screen">Send screen (tablet / phone)</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="receiver_title">Waiting for cast</string>
|
||||
<string name="pin_hint">PIN</string>
|
||||
@@ -144,6 +144,21 @@
|
||||
<string name="settings_section_receiver">Receiver settings</string>
|
||||
<string name="label_username">Username</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="pin_anonymous_hint">Leave empty for anonymous cast</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="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_listening">Listening for casts — tap to open app</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="swipe_for_settings">Swipe from the left edge for settings</string>
|
||||
|
||||
@@ -15,13 +15,20 @@ public class CastTrayContentTest {
|
||||
CastTrayContent.Body body = CastTrayContent.build(
|
||||
"Tap to open",
|
||||
"Cast active",
|
||||
"Listening",
|
||||
"",
|
||||
"",
|
||||
"Waiting for Wi‑Fi",
|
||||
"adb connect %1$s",
|
||||
"curl http://%1$s:%2$d/adb.json",
|
||||
"",
|
||||
5039,
|
||||
"Polling BE (%1$s)",
|
||||
"Connected",
|
||||
"Session %1$s",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
"",
|
||||
RemoteAccessMode.DISABLED,
|
||||
null);
|
||||
@@ -29,22 +36,56 @@ public class CastTrayContentTest {
|
||||
assertEquals("Tap to open", body.bigText);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void listening_combinesReceiverStatusWithDevLines() {
|
||||
CastTrayContent.Body body = CastTrayContent.build(
|
||||
"Tap to open",
|
||||
"Cast active",
|
||||
"Listening for casts",
|
||||
"Ready — PIN 1234 · USB",
|
||||
"",
|
||||
"Waiting",
|
||||
"adb connect %1$s",
|
||||
"curl http://%1$s:%2$d/adb.json",
|
||||
"192.168.1.10",
|
||||
5039,
|
||||
"Poll %1$s",
|
||||
"Connected",
|
||||
"Session %1$s",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"192.168.1.10:5555",
|
||||
RemoteAccessMode.DISABLED,
|
||||
null);
|
||||
assertEquals("Ready — PIN 1234 · USB", body.summary);
|
||||
assertTrue(body.bigText.contains("adb connect 192.168.1.10:5555"));
|
||||
assertTrue(body.bigText.contains("curl http://192.168.1.10:5039/adb.json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void casting_overridesDevLines() {
|
||||
CastTrayContent.Body body = CastTrayContent.build(
|
||||
"Tap to open",
|
||||
"Cast active",
|
||||
"Listening",
|
||||
"",
|
||||
"Casting to TV",
|
||||
"Waiting",
|
||||
"adb %1$s",
|
||||
"curl http://%1$s:%2$d/adb.json",
|
||||
"192.168.1.1",
|
||||
5039,
|
||||
"Poll %1$s",
|
||||
"Connected",
|
||||
"Session %1$s",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"192.168.1.1:5555",
|
||||
RemoteAccessMode.RSSH,
|
||||
snapshot(RemoteAccessStatusStore.STATE_WAIT, ""));
|
||||
assertEquals("Cast active", body.summary);
|
||||
assertEquals("Casting to TV", body.summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,12 +95,19 @@ public class CastTrayContentTest {
|
||||
CastTrayContent.Body body = CastTrayContent.build(
|
||||
"Tap to open",
|
||||
"Cast active",
|
||||
"Listening",
|
||||
"",
|
||||
"",
|
||||
"Waiting for Wi‑Fi",
|
||||
"adb connect %1$s",
|
||||
"curl http://%1$s:%2$d/adb.json",
|
||||
"",
|
||||
5039,
|
||||
"Polling BE (%1$s)",
|
||||
"Connected",
|
||||
"Session %1$s",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"10.0.0.5:5555",
|
||||
RemoteAccessMode.RSSH,
|
||||
|
||||
@@ -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