mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
@@ -20,6 +20,8 @@ public final class AppPreferences {
|
||||
private static final String KEY_PLAY_INCOMING_AUDIO = "play_incoming_audio";
|
||||
private static final String KEY_DEV_RECEIVER_DEBUG_OVERLAY = "dev_receiver_debug_overlay";
|
||||
private static final String KEY_DEV_GRAB_SESSION_STATS = "dev_grab_session_stats";
|
||||
private static final String KEY_RECEIVER_PIP_USER_DEFINED = "receiver_pip_user_defined";
|
||||
private static final String KEY_RECEIVER_PIP_ENABLED = "receiver_pip_enabled";
|
||||
|
||||
private static final String PREFIX_SENDER = "sender_";
|
||||
private static final String PREFIX_RECEIVER = "receiver_";
|
||||
@@ -112,6 +114,38 @@ public final class AppPreferences {
|
||||
prefs(context).edit().putBoolean(KEY_DEV_GRAB_SESSION_STATS, grab).apply();
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver PiP: user toggle when set; otherwise derived from PiP support + notifications permission.
|
||||
*/
|
||||
public static boolean isReceiverPipEnabled(Context context) {
|
||||
if (prefs(context).getBoolean(KEY_RECEIVER_PIP_USER_DEFINED, false)) {
|
||||
return prefs(context).getBoolean(KEY_RECEIVER_PIP_ENABLED, false);
|
||||
}
|
||||
return defaultReceiverPipEnabled(context);
|
||||
}
|
||||
|
||||
/** Value shown in settings before the user changes the toggle. */
|
||||
public static boolean getReceiverPipEffective(Context context) {
|
||||
return isReceiverPipEnabled(context);
|
||||
}
|
||||
|
||||
public static boolean isReceiverPipUserDefined(Context context) {
|
||||
return prefs(context).getBoolean(KEY_RECEIVER_PIP_USER_DEFINED, false);
|
||||
}
|
||||
|
||||
public static void setReceiverPipUserChoice(Context context, boolean enabled) {
|
||||
prefs(context).edit()
|
||||
.putBoolean(KEY_RECEIVER_PIP_USER_DEFINED, true)
|
||||
.putBoolean(KEY_RECEIVER_PIP_ENABLED, enabled)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private static boolean defaultReceiverPipEnabled(Context context) {
|
||||
return PictureInPictureHelper.isSupported()
|
||||
&& PictureInPictureHelper.isAllowedForApp(context)
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(context);
|
||||
}
|
||||
|
||||
public static CastSettings loadSenderDefaults(Context context) {
|
||||
CastSettings s = new CastSettings();
|
||||
applyStoredCastSettings(context, PREFIX_SENDER, s);
|
||||
|
||||
@@ -5,10 +5,16 @@ public final class CastConfig {
|
||||
public static final int DISCOVERY_PORT = 41234;
|
||||
/** One UDP browse pass duration (sender shows countdown on Refresh). */
|
||||
public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
|
||||
/** Fast scan when user taps Refresh (UDP burst + NSD). */
|
||||
public static final long DISCOVERY_BURST_MS = 5_000;
|
||||
/** Idle gap between automatic browse passes. */
|
||||
public static final long DISCOVERY_PAUSE_MS = 30_000;
|
||||
/** How often the sender broadcasts a discover probe during a scan pass. */
|
||||
public static final long DISCOVERY_PROBE_INTERVAL_MS = 1_000;
|
||||
/** Probe interval during burst scan. */
|
||||
public static final long DISCOVERY_BURST_PROBE_MS = 250;
|
||||
/** NSD service type for LAN discovery (in addition to UDP). */
|
||||
public static final String NSD_SERVICE_TYPE = "_androidcast._udp.";
|
||||
public static final int CAST_PORT = 41235;
|
||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ public final class CastSettingsBinder {
|
||||
}
|
||||
|
||||
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
|
||||
bindReceiverPip(activity);
|
||||
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
||||
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||
@@ -129,6 +130,7 @@ public final class CastSettingsBinder {
|
||||
}
|
||||
|
||||
public static void saveReceiver(AppCompatActivity activity, CastSettings settings) {
|
||||
saveReceiverPip(activity);
|
||||
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
||||
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||
@@ -312,6 +314,37 @@ public final class CastSettingsBinder {
|
||||
readEnumSpinner(activity.findViewById(R.id.spinner_sender_resolution), settings::setResolution);
|
||||
}
|
||||
|
||||
public static void bindReceiverPip(AppCompatActivity activity) {
|
||||
CheckBox pip = activity.findViewById(R.id.check_receiver_pip);
|
||||
if (pip == null) {
|
||||
return;
|
||||
}
|
||||
boolean supported = PictureInPictureHelper.isSupported();
|
||||
pip.setEnabled(supported);
|
||||
pip.setOnCheckedChangeListener(null);
|
||||
pip.setChecked(AppPreferences.getReceiverPipEffective(activity));
|
||||
pip.setOnCheckedChangeListener((btn, checked) ->
|
||||
AppPreferences.setReceiverPipUserChoice(activity, checked));
|
||||
if (!supported) {
|
||||
pip.setChecked(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-apply permission-derived default when the user has not chosen explicitly. */
|
||||
public static void refreshReceiverPipFromPermissions(AppCompatActivity activity) {
|
||||
if (AppPreferences.isReceiverPipUserDefined(activity)) {
|
||||
return;
|
||||
}
|
||||
bindReceiverPip(activity);
|
||||
}
|
||||
|
||||
private static void saveReceiverPip(AppCompatActivity activity) {
|
||||
CheckBox pip = activity.findViewById(R.id.check_receiver_pip);
|
||||
if (pip != null && pip.isEnabled()) {
|
||||
AppPreferences.setReceiverPipUserChoice(activity, pip.isChecked());
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
|
||||
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
|
||||
CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();
|
||||
|
||||
@@ -119,6 +119,12 @@ public class CastSettingsFragment extends Fragment {
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
CastSettingsBinder.refreshReceiverPipFromPermissions((AppCompatActivity) requireActivity());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
saveAll();
|
||||
|
||||
@@ -123,8 +123,8 @@ public final class PermissionHelper {
|
||||
}
|
||||
|
||||
/** Permissions needed before receiver cast / PiP (notifications for foreground service). */
|
||||
public static boolean hasReceiverPlaybackReady(Activity activity) {
|
||||
return missingForReceiverPlayback(activity).isEmpty();
|
||||
public static boolean hasReceiverPlaybackReady(android.content.Context context) {
|
||||
return missingForReceiverPlayback(context).isEmpty();
|
||||
}
|
||||
|
||||
public static void remindForReceiverPlayback(Activity activity) {
|
||||
@@ -148,13 +148,15 @@ public final class PermissionHelper {
|
||||
.show();
|
||||
}
|
||||
|
||||
private static List<PermissionItem> missingForReceiverPlayback(Activity activity) {
|
||||
private static List<PermissionItem> missingForReceiverPlayback(android.content.Context context) {
|
||||
List<PermissionItem> needed = new ArrayList<>();
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS)
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS,
|
||||
activity.getString(R.string.permission_notifications_cast), true));
|
||||
String label = context instanceof Activity
|
||||
? ((Activity) context).getString(R.string.permission_notifications_cast)
|
||||
: "Notifications (cast status)";
|
||||
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS, label, true));
|
||||
}
|
||||
}
|
||||
return needed;
|
||||
|
||||
@@ -56,6 +56,8 @@ public class DiscoveryManager {
|
||||
public final long protoVersion;
|
||||
/** SHA-256 hex of receiver PIN, empty if anonymous. */
|
||||
public final String pinAuth;
|
||||
/** Shown from {@link PinnedReceiversStore} at top of sender list. */
|
||||
public boolean pinnedRecent;
|
||||
|
||||
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
|
||||
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
|
||||
@@ -96,6 +98,8 @@ public class DiscoveryManager {
|
||||
private final Object lockGuard = new Object();
|
||||
private WifiManager.MulticastLock multicastLock;
|
||||
private int multicastHolds;
|
||||
private boolean burstScan;
|
||||
private NsdCastDiscovery nsdDiscovery;
|
||||
|
||||
public DiscoveryManager(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
@@ -110,11 +114,18 @@ public class DiscoveryManager {
|
||||
}
|
||||
|
||||
public void startBrowsing() {
|
||||
startBrowsing(false);
|
||||
}
|
||||
|
||||
/** @param burst shorter UDP pass + NSD (user Refresh). */
|
||||
public void startBrowsing(boolean burst) {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
burstScan = burst;
|
||||
purgeSelfFromDeviceMap();
|
||||
notifyScanStarted();
|
||||
startNsdBrowse();
|
||||
executor.execute(this::browseLoop);
|
||||
}
|
||||
|
||||
@@ -124,6 +135,8 @@ public class DiscoveryManager {
|
||||
*/
|
||||
public void stop() {
|
||||
running.set(false);
|
||||
stopNsdBrowse();
|
||||
stopNsdRegister();
|
||||
DatagramSocket toClose = socket;
|
||||
socket = null;
|
||||
if (toClose != null) {
|
||||
@@ -154,9 +167,25 @@ public class DiscoveryManager {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
String pinHash = com.foxx.androidcast.PinAuth.isProtected(pin)
|
||||
? com.foxx.androidcast.PinAuth.hash(pin) : "";
|
||||
startNsdRegister(deviceName, port, transport, pinHash);
|
||||
executor.execute(() -> announceLoop(context, deviceName, port, transport, listening, pin));
|
||||
}
|
||||
|
||||
/** Merge device from NSD or pinned list into the browse map. */
|
||||
public void upsertExternal(DiscoveredDevice device) {
|
||||
if (device == null || device.host == null || device.host.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (DiscoverySelfFilter.isSelf(appContext, device.host, null)) {
|
||||
return;
|
||||
}
|
||||
String key = device.host + ":" + device.port;
|
||||
devices.put(key, device);
|
||||
notifyListener();
|
||||
}
|
||||
|
||||
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
|
||||
startAnnouncing(appContext, deviceName, port, transport, listening, "");
|
||||
}
|
||||
@@ -170,13 +199,16 @@ public class DiscoveryManager {
|
||||
sock.setBroadcast(true);
|
||||
sock.setSoTimeout(300);
|
||||
byte[] buf = new byte[1024];
|
||||
long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS;
|
||||
long passMs = burstScan ? CastConfig.DISCOVERY_BURST_MS : CastConfig.DISCOVERY_SCAN_PASS_MS;
|
||||
long probeInterval = burstScan ? CastConfig.DISCOVERY_BURST_PROBE_MS
|
||||
: CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
|
||||
long deadline = System.currentTimeMillis() + passMs;
|
||||
long nextProbeMs = System.currentTimeMillis();
|
||||
while (running.get() && System.currentTimeMillis() < deadline) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextProbeMs) {
|
||||
sendDiscoveryProbe(sock);
|
||||
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
|
||||
nextProbeMs = now + probeInterval;
|
||||
}
|
||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||
try {
|
||||
@@ -451,6 +483,32 @@ public class DiscoveryManager {
|
||||
}
|
||||
}
|
||||
|
||||
private void startNsdBrowse() {
|
||||
if (nsdDiscovery == null) {
|
||||
nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
|
||||
}
|
||||
nsdDiscovery.startDiscovering();
|
||||
}
|
||||
|
||||
private void stopNsdBrowse() {
|
||||
if (nsdDiscovery != null) {
|
||||
nsdDiscovery.stopDiscovering();
|
||||
}
|
||||
}
|
||||
|
||||
private void startNsdRegister(String deviceName, int port, String transport, String pinAuth) {
|
||||
if (nsdDiscovery == null) {
|
||||
nsdDiscovery = new NsdCastDiscovery(appContext, this::upsertExternal);
|
||||
}
|
||||
nsdDiscovery.register(deviceName, port, transport, pinAuth);
|
||||
}
|
||||
|
||||
public void stopNsdRegister() {
|
||||
if (nsdDiscovery != null) {
|
||||
nsdDiscovery.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseMulticastLock() {
|
||||
synchronized (lockGuard) {
|
||||
if (multicastLock == null || multicastHolds <= 0) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.foxx.androidcast.discovery;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.nsd.NsdManager;
|
||||
import android.net.nsd.NsdServiceInfo;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.foxx.androidcast.CastConfig;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Android NSD (mDNS) discovery/advertise alongside UDP broadcast.
|
||||
* TXT: port, transport, pin_auth (optional).
|
||||
*/
|
||||
public final class NsdCastDiscovery {
|
||||
private static final String TAG = "NsdCastDiscovery";
|
||||
|
||||
public interface Host {
|
||||
void onNsdDeviceFound(DiscoveryManager.DiscoveredDevice device);
|
||||
}
|
||||
|
||||
private final Context appContext;
|
||||
private final Host host;
|
||||
private NsdManager nsdManager;
|
||||
private NsdManager.DiscoveryListener discoveryListener;
|
||||
private NsdManager.RegistrationListener registrationListener;
|
||||
private final AtomicBoolean discovering = new AtomicBoolean(false);
|
||||
|
||||
public NsdCastDiscovery(Context context, Host host) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
public void startDiscovering() {
|
||||
if (!discovering.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
nsdManager = (NsdManager) appContext.getSystemService(Context.NSD_SERVICE);
|
||||
if (nsdManager == null) {
|
||||
discovering.set(false);
|
||||
return;
|
||||
}
|
||||
discoveryListener = new NsdManager.DiscoveryListener() {
|
||||
@Override
|
||||
public void onDiscoveryStarted(String serviceType) {
|
||||
Log.d(TAG, "NSD discovery started");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceFound(NsdServiceInfo service) {
|
||||
if (service == null || service.getServiceType() == null) {
|
||||
return;
|
||||
}
|
||||
if (!service.getServiceType().contains("_androidcast")) {
|
||||
return;
|
||||
}
|
||||
nsdManager.resolveService(service, new NsdManager.ResolveListener() {
|
||||
@Override
|
||||
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
|
||||
Log.d(TAG, "NSD resolve failed: " + errorCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceResolved(NsdServiceInfo info) {
|
||||
publishResolved(info);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceLost(NsdServiceInfo service) {}
|
||||
|
||||
@Override
|
||||
public void onDiscoveryStopped(String serviceType) {
|
||||
discovering.set(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
|
||||
Log.w(TAG, "NSD start failed: " + errorCode);
|
||||
discovering.set(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
|
||||
discovering.set(false);
|
||||
}
|
||||
};
|
||||
try {
|
||||
nsdManager.discoverServices(CastConfig.NSD_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD,
|
||||
discoveryListener);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "NSD discover: " + e.getMessage());
|
||||
discovering.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void stopDiscovering() {
|
||||
if (!discovering.get() || nsdManager == null || discoveryListener == null) {
|
||||
discovering.set(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nsdManager.stopServiceDiscovery(discoveryListener);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
discoveryListener = null;
|
||||
discovering.set(false);
|
||||
}
|
||||
|
||||
public void register(String serviceName, int port, String transport, String pinAuth) {
|
||||
nsdManager = (NsdManager) appContext.getSystemService(Context.NSD_SERVICE);
|
||||
if (nsdManager == null) {
|
||||
return;
|
||||
}
|
||||
NsdServiceInfo info = new NsdServiceInfo();
|
||||
info.setServiceName(sanitizeServiceName(serviceName));
|
||||
info.setServiceType(CastConfig.NSD_SERVICE_TYPE);
|
||||
info.setPort(port);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
info.setAttribute("transport", transport != null ? transport : CastConfig.DEFAULT_TRANSPORT);
|
||||
if (pinAuth != null && !pinAuth.isEmpty()) {
|
||||
info.setAttribute("pin_auth", pinAuth);
|
||||
}
|
||||
}
|
||||
registrationListener = new NsdManager.RegistrationListener() {
|
||||
@Override
|
||||
public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
|
||||
Log.w(TAG, "NSD register failed: " + errorCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {}
|
||||
|
||||
@Override
|
||||
public void onServiceRegistered(NsdServiceInfo serviceInfo) {
|
||||
Log.i(TAG, "NSD registered " + serviceInfo.getServiceName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceUnregistered(NsdServiceInfo serviceInfo) {}
|
||||
};
|
||||
try {
|
||||
nsdManager.registerService(info, NsdManager.PROTOCOL_DNS_SD, registrationListener);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "NSD register: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
if (nsdManager == null || registrationListener == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
nsdManager.unregisterService(registrationListener);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
registrationListener = null;
|
||||
}
|
||||
|
||||
private void publishResolved(NsdServiceInfo info) {
|
||||
if (info == null || info.getHost() == null) {
|
||||
return;
|
||||
}
|
||||
String hostAddress = info.getHost().getHostAddress();
|
||||
int port = info.getPort();
|
||||
if (port <= 0) {
|
||||
port = CastConfig.CAST_PORT;
|
||||
}
|
||||
String transport = CastConfig.DEFAULT_TRANSPORT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
byte[] t = info.getAttributes().get("transport");
|
||||
if (t != null) {
|
||||
transport = new String(t).trim();
|
||||
}
|
||||
}
|
||||
String name = info.getServiceName() != null ? info.getServiceName().replace('_', ' ') : hostAddress;
|
||||
DiscoveryManager.DiscoveredDevice device = new DiscoveryManager.DiscoveredDevice(
|
||||
name, hostAddress, port, transport, System.currentTimeMillis());
|
||||
host.onNsdDeviceFound(device);
|
||||
}
|
||||
|
||||
private static String sanitizeServiceName(String name) {
|
||||
String s = name != null ? name : "AndroidCast";
|
||||
s = s.replaceAll("[^a-zA-Z0-9-]", "");
|
||||
if (s.length() < 1) {
|
||||
s = "AndroidCast";
|
||||
}
|
||||
if (s.length() > 63) {
|
||||
s = s.substring(0, 63);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.foxx.androidcast.discovery;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Last successful cast targets shown at the top of the sender device list. */
|
||||
public final class PinnedReceiversStore {
|
||||
private static final String PREFS = "androidcast_pinned_receivers";
|
||||
private static final String KEY_ENTRIES = "entries";
|
||||
private static final int MAX_PINNED = 5;
|
||||
|
||||
private PinnedReceiversStore() {}
|
||||
|
||||
public static void recordSuccess(Context context, DiscoveryManager.DiscoveredDevice device) {
|
||||
if (context == null || device == null) {
|
||||
return;
|
||||
}
|
||||
List<DiscoveryManager.DiscoveredDevice> list = load(context);
|
||||
String key = device.host + ":" + device.port;
|
||||
list.removeIf(d -> (d.host + ":" + d.port).equals(key));
|
||||
DiscoveryManager.DiscoveredDevice copy = new DiscoveryManager.DiscoveredDevice(
|
||||
device.name, device.host, device.port, device.transport, System.currentTimeMillis(),
|
||||
device.videoCodecs, device.audioCodecs, device.placeholderId, device.protoVersion,
|
||||
device.pinAuth);
|
||||
copy.pinnedRecent = true;
|
||||
list.add(0, copy);
|
||||
while (list.size() > MAX_PINNED) {
|
||||
list.remove(list.size() - 1);
|
||||
}
|
||||
save(context, list);
|
||||
}
|
||||
|
||||
public static List<DiscoveryManager.DiscoveredDevice> load(Context context) {
|
||||
List<DiscoveryManager.DiscoveredDevice> out = new ArrayList<>();
|
||||
SharedPreferences p = context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||
String raw = p.getString(KEY_ENTRIES, "[]");
|
||||
try {
|
||||
JSONArray arr = new JSONArray(raw);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject o = arr.getJSONObject(i);
|
||||
DiscoveryManager.DiscoveredDevice d = new DiscoveryManager.DiscoveredDevice(
|
||||
o.optString("name", "?"),
|
||||
o.optString("host", ""),
|
||||
o.optInt("port", 0),
|
||||
o.optString("transport", "udp"),
|
||||
o.optLong("last_seen_ms", 0L),
|
||||
o.optInt("video_codecs", 0),
|
||||
o.optInt("audio_codecs", 0),
|
||||
o.optInt("placeholder_id", 0),
|
||||
o.optLong("proto_version", 0L),
|
||||
o.optString("pin_auth", ""));
|
||||
d.pinnedRecent = true;
|
||||
if (!d.host.isEmpty() && d.port > 0) {
|
||||
out.add(d);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void save(Context context, List<DiscoveryManager.DiscoveredDevice> list) {
|
||||
JSONArray arr = new JSONArray();
|
||||
for (DiscoveryManager.DiscoveredDevice d : list) {
|
||||
try {
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("name", d.name);
|
||||
o.put("host", d.host);
|
||||
o.put("port", d.port);
|
||||
o.put("transport", d.transport);
|
||||
o.put("last_seen_ms", d.lastSeenMs);
|
||||
o.put("video_codecs", d.videoCodecs);
|
||||
o.put("audio_codecs", d.audioCodecs);
|
||||
o.put("placeholder_id", d.placeholderId);
|
||||
o.put("proto_version", d.protoVersion);
|
||||
o.put("pin_auth", d.pinAuth);
|
||||
arr.put(o);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_ENTRIES, arr.toString()).apply();
|
||||
}
|
||||
}
|
||||
@@ -247,7 +247,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
@Override
|
||||
public void onUserLeaveHint() {
|
||||
super.onUserLeaveHint();
|
||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
tryEnterPictureInPicture();
|
||||
}
|
||||
}
|
||||
@@ -403,7 +404,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
}
|
||||
videoWidth = width;
|
||||
videoHeight = height;
|
||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
PictureInPictureHelper.applyParams(this, videoWidth, videoHeight);
|
||||
}
|
||||
applyAspectRatio();
|
||||
@@ -509,7 +511,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
|
||||
private void onCastSessionStarted() {
|
||||
castSessionActive = true;
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
if (AppPreferences.isReceiverPipEnabled(this)) {
|
||||
PermissionHelper.remindForReceiverPlayback(this);
|
||||
}
|
||||
updatePipUi();
|
||||
}
|
||||
|
||||
@@ -522,7 +526,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
if (pipButton == null) {
|
||||
return;
|
||||
}
|
||||
boolean show = castSessionActive && PictureInPictureHelper.isSupported()
|
||||
boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||
&& PictureInPictureHelper.isSupported()
|
||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||
if (show) {
|
||||
@@ -531,7 +536,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
||||
}
|
||||
|
||||
private void tryEnterPictureInPicture() {
|
||||
if (!castSessionActive) {
|
||||
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
|
||||
return;
|
||||
}
|
||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.Set;
|
||||
|
||||
/** Device list with single or multi selection. */
|
||||
public class DeviceListAdapter extends BaseAdapter {
|
||||
private final Context context;
|
||||
private final LayoutInflater inflater;
|
||||
private final boolean multiSelect;
|
||||
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
||||
@@ -25,6 +26,7 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
|
||||
|
||||
public DeviceListAdapter(Context context, boolean multiSelect) {
|
||||
this.context = context;
|
||||
inflater = LayoutInflater.from(context);
|
||||
this.multiSelect = multiSelect;
|
||||
}
|
||||
@@ -109,7 +111,9 @@ public class DeviceListAdapter extends BaseAdapter {
|
||||
}
|
||||
TextView label = row.findViewById(R.id.device_label);
|
||||
DiscoveryManager.DiscoveredDevice d = devices.get(position);
|
||||
label.setText(d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
|
||||
String prefix = d.pinnedRecent
|
||||
? context.getString(R.string.discovery_pinned_label) + " · " : "";
|
||||
label.setText(prefix + d.name + " — " + d.host + " [" + d.transport.toUpperCase() + "]");
|
||||
boolean selected = multiSelect ? selectedPositions.contains(position) : position == selectedPosition;
|
||||
row.setActivated(selected);
|
||||
row.setSelected(selected);
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.foxx.androidcast.PermissionHelper;
|
||||
import com.foxx.androidcast.PlatformCastSupport;
|
||||
import com.foxx.androidcast.R;
|
||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||
import com.foxx.androidcast.discovery.PinnedReceiversStore;
|
||||
import com.foxx.androidcast.discovery.ReceiverCompatibility;
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||
|
||||
@@ -57,6 +58,7 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
|
||||
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
|
||||
private int discoverySecondsLeft;
|
||||
private boolean lastScanBurst;
|
||||
private final Runnable autoScanRunnable = () -> beginScanPass(false);
|
||||
|
||||
private String pendingPin;
|
||||
@@ -148,7 +150,9 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
@Override
|
||||
public void onScanStarted() {
|
||||
discoveryPhase = DiscoveryPhase.SCANNING;
|
||||
discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000);
|
||||
long passMs = lastScanBurst
|
||||
? CastConfig.DISCOVERY_BURST_MS : CastConfig.DISCOVERY_SCAN_PASS_MS;
|
||||
discoverySecondsLeft = Math.max(1, (int) (passMs / 1000));
|
||||
discoveryProgress.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
@@ -209,12 +213,26 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
if (deviceAdapter == null) {
|
||||
return;
|
||||
}
|
||||
java.util.List<DiscoveryManager.DiscoveredDevice> compatible = new java.util.ArrayList<>();
|
||||
for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) {
|
||||
java.util.LinkedHashMap<String, DiscoveryManager.DiscoveredDevice> merged =
|
||||
new java.util.LinkedHashMap<>();
|
||||
for (DiscoveryManager.DiscoveredDevice d : PinnedReceiversStore.load(this)) {
|
||||
if (ReceiverCompatibility.isCompatible(d, castSettings)) {
|
||||
compatible.add(d);
|
||||
merged.put(d.host + ":" + d.port, d);
|
||||
}
|
||||
}
|
||||
for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) {
|
||||
if (!ReceiverCompatibility.isCompatible(d, castSettings)) {
|
||||
continue;
|
||||
}
|
||||
String key = d.host + ":" + d.port;
|
||||
DiscoveryManager.DiscoveredDevice prev = merged.get(key);
|
||||
if (prev != null && prev.pinnedRecent) {
|
||||
d.pinnedRecent = true;
|
||||
}
|
||||
merged.put(key, d);
|
||||
}
|
||||
java.util.List<DiscoveryManager.DiscoveredDevice> compatible =
|
||||
new java.util.ArrayList<>(merged.values());
|
||||
deviceAdapter.setDevices(compatible);
|
||||
if (statusText == null) {
|
||||
return;
|
||||
@@ -297,7 +315,8 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
discovery.clearDevices();
|
||||
}
|
||||
}
|
||||
discovery.startBrowsing();
|
||||
lastScanBurst = userInitiated;
|
||||
discovery.startBrowsing(userInitiated);
|
||||
}
|
||||
|
||||
private void startCastFlow() {
|
||||
@@ -341,6 +360,7 @@ public class SenderActivity extends DrawerHostActivity {
|
||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
PinnedReceiversStore.recordSuccess(this, d);
|
||||
pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
|
||||
d.videoCodecs, d.audioCodecs, d.placeholderId));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.transport.StreamProtectionMode;
|
||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
/**
|
||||
@@ -36,6 +37,8 @@ public final class SessionStatsRecorder {
|
||||
private int fpsSamples;
|
||||
|
||||
private final TransportLossStats lossTotals = new TransportLossStats();
|
||||
private final JSONArray samples = new JSONArray();
|
||||
private long lastSampleMs;
|
||||
|
||||
public SessionStatsRecorder(String role, String transport) {
|
||||
this.role = role;
|
||||
@@ -82,6 +85,7 @@ public final class SessionStatsRecorder {
|
||||
if (s == null) {
|
||||
return;
|
||||
}
|
||||
appendTimeSample(s);
|
||||
if (s.rttMs > 0) {
|
||||
sumRttMs += s.rttMs;
|
||||
}
|
||||
@@ -130,6 +134,39 @@ public final class SessionStatsRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
private void appendTimeSample(NetworkStatsSnapshot s) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastSampleMs < 2000L) {
|
||||
return;
|
||||
}
|
||||
lastSampleMs = now;
|
||||
try {
|
||||
JSONObject pt = new JSONObject();
|
||||
pt.put("t_ms", now - startedMs);
|
||||
if (s.rttMs > 0) {
|
||||
pt.put("rtt_ms", s.rttMs);
|
||||
}
|
||||
if (s.lossRatio > 0) {
|
||||
pt.put("loss_ratio", s.lossRatio);
|
||||
}
|
||||
int videoKbps = s.sendBitrateKbps > 0 ? s.sendBitrateKbps : s.recvBitrateKbps;
|
||||
if (videoKbps > 0) {
|
||||
pt.put("video_bitrate_kbps", videoKbps);
|
||||
}
|
||||
if (s.encodeFps > 0) {
|
||||
pt.put("encode_fps", s.encodeFps);
|
||||
}
|
||||
if (s.targetBitrateKbps > 0) {
|
||||
pt.put("target_bitrate_kbps", s.targetBitrateKbps);
|
||||
}
|
||||
if (s.congestionLevel > 0) {
|
||||
pt.put("congestion_level", s.congestionLevel);
|
||||
}
|
||||
samples.put(pt);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void mergeSettings(CastSettings settings) {
|
||||
if (settings == null) {
|
||||
return;
|
||||
@@ -209,6 +246,9 @@ public final class SessionStatsRecorder {
|
||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||
}
|
||||
root.put("udp", udp);
|
||||
if (samples.length() > 0) {
|
||||
root.put("samples", samples);
|
||||
}
|
||||
|
||||
SessionStatsStore.saveSession(context, startedMs, root);
|
||||
} catch (Exception ignored) {
|
||||
|
||||
@@ -218,6 +218,19 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/check_receiver_pip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/label_receiver_pip" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/receiver_pip_hint"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -195,10 +195,13 @@
|
||||
<string name="codec_speex">Speex</string>
|
||||
<string name="codec_opus">Opus</string>
|
||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||
<string name="pip_enter">Картинка в картинке</string>
|
||||
<string name="pip_unavailable">Режим «картинка в картинке» недоступен на этом устройстве</string>
|
||||
<string name="pip_disabled_title">Картинка в картинке отключена</string>
|
||||
<string name="pip_disabled_message">Разрешите картинку в картинке для Android Cast в настройках системы.</string>
|
||||
<string name="pip_enter">Картинка в картинке (PiP)</string>
|
||||
<string name="pip_unavailable">Режим «картинка в картинке» (PiP) недоступен на этом устройстве</string>
|
||||
<string name="pip_disabled_title">PiP отключено</string>
|
||||
<string name="pip_disabled_message">Разрешите PiP для Android Cast в настройках системы.</string>
|
||||
<string name="pip_permissions_hint">Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне.</string>
|
||||
<string name="permission_notifications_cast">Уведомления (статус трансляции)</string>
|
||||
<string name="label_receiver_pip">PiP (приёмник)</string>
|
||||
<string name="receiver_pip_hint">При включении приёмник может свернуться в плавающее окно во время трансляции (нужны уведомления). Выключено, если PiP недоступен.</string>
|
||||
<string name="discovery_pinned_label">Недавние</string>
|
||||
</resources>
|
||||
|
||||
@@ -201,4 +201,7 @@
|
||||
<string name="pip_disabled_message">Allow picture-in-picture for Android Cast in system settings, then try again.</string>
|
||||
<string name="pip_permissions_hint">Notifications keep the receiver visible while casting in the background.</string>
|
||||
<string name="permission_notifications_cast">Notifications (cast status)</string>
|
||||
<string name="label_receiver_pip">Enable picture-in-picture (receiver)</string>
|
||||
<string name="receiver_pip_hint">When on, the receiver can shrink to a floating window during cast (after notifications are allowed). Off by default if PiP is unavailable.</string>
|
||||
<string name="discovery_pinned_label">Recent</string>
|
||||
</resources>
|
||||
|
||||
@@ -18,3 +18,5 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "AndroidCast"
|
||||
include ':app'
|
||||
include ':session-studio'
|
||||
project(':session-studio').projectDir = file('desktop/session-studio')
|
||||
|
||||
Reference in New Issue
Block a user