mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:17:39 +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_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_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_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_SENDER = "sender_";
|
||||||
private static final String PREFIX_RECEIVER = "receiver_";
|
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();
|
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) {
|
public static CastSettings loadSenderDefaults(Context context) {
|
||||||
CastSettings s = new CastSettings();
|
CastSettings s = new CastSettings();
|
||||||
applyStoredCastSettings(context, PREFIX_SENDER, s);
|
applyStoredCastSettings(context, PREFIX_SENDER, s);
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ public final class CastConfig {
|
|||||||
public static final int DISCOVERY_PORT = 41234;
|
public static final int DISCOVERY_PORT = 41234;
|
||||||
/** One UDP browse pass duration (sender shows countdown on Refresh). */
|
/** One UDP browse pass duration (sender shows countdown on Refresh). */
|
||||||
public static final long DISCOVERY_SCAN_PASS_MS = 30_000;
|
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. */
|
/** Idle gap between automatic browse passes. */
|
||||||
public static final long DISCOVERY_PAUSE_MS = 30_000;
|
public static final long DISCOVERY_PAUSE_MS = 30_000;
|
||||||
/** How often the sender broadcasts a discover probe during a scan pass. */
|
/** How often the sender broadcasts a discover probe during a scan pass. */
|
||||||
public static final long DISCOVERY_PROBE_INTERVAL_MS = 1_000;
|
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 int CAST_PORT = 41235;
|
||||||
public static final String DISCOVERY_MAGIC = "ACAST1";
|
public static final String DISCOVERY_MAGIC = "ACAST1";
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public final class CastSettingsBinder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
|
public static void bindReceiver(AppCompatActivity activity, CastSettings settings) {
|
||||||
|
bindReceiverPip(activity);
|
||||||
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
bindTransport(activity, settings, R.id.spinner_receiver_transport, true);
|
||||||
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
bindVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||||
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
bindNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
||||||
@@ -129,6 +130,7 @@ public final class CastSettingsBinder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void saveReceiver(AppCompatActivity activity, CastSettings settings) {
|
public static void saveReceiver(AppCompatActivity activity, CastSettings settings) {
|
||||||
|
saveReceiverPip(activity);
|
||||||
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
readTransport(activity, settings, R.id.spinner_receiver_transport);
|
||||||
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
readVideoCodec(activity, settings, R.id.spinner_receiver_video_codec);
|
||||||
readNetworkAdoption(activity, settings, R.id.spinner_receiver_network);
|
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);
|
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) {
|
private static void bindReceiverDisplay(AppCompatActivity activity, CastSettings settings) {
|
||||||
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
|
Spinner spinner = activity.findViewById(R.id.spinner_receiver_resolution);
|
||||||
CastSettings.ReceiverDisplayResolution[] modes = CastSettings.ReceiverDisplayResolution.values();
|
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
|
@Override
|
||||||
public void onPause() {
|
public void onPause() {
|
||||||
saveAll();
|
saveAll();
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ public final class PermissionHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Permissions needed before receiver cast / PiP (notifications for foreground service). */
|
/** Permissions needed before receiver cast / PiP (notifications for foreground service). */
|
||||||
public static boolean hasReceiverPlaybackReady(Activity activity) {
|
public static boolean hasReceiverPlaybackReady(android.content.Context context) {
|
||||||
return missingForReceiverPlayback(activity).isEmpty();
|
return missingForReceiverPlayback(context).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void remindForReceiverPlayback(Activity activity) {
|
public static void remindForReceiverPlayback(Activity activity) {
|
||||||
@@ -148,13 +148,15 @@ public final class PermissionHelper {
|
|||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<PermissionItem> missingForReceiverPlayback(Activity activity) {
|
private static List<PermissionItem> missingForReceiverPlayback(android.content.Context context) {
|
||||||
List<PermissionItem> needed = new ArrayList<>();
|
List<PermissionItem> needed = new ArrayList<>();
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
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) {
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS,
|
String label = context instanceof Activity
|
||||||
activity.getString(R.string.permission_notifications_cast), true));
|
? ((Activity) context).getString(R.string.permission_notifications_cast)
|
||||||
|
: "Notifications (cast status)";
|
||||||
|
needed.add(new PermissionItem(Manifest.permission.POST_NOTIFICATIONS, label, true));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return needed;
|
return needed;
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public class DiscoveryManager {
|
|||||||
public final long protoVersion;
|
public final long protoVersion;
|
||||||
/** SHA-256 hex of receiver PIN, empty if anonymous. */
|
/** SHA-256 hex of receiver PIN, empty if anonymous. */
|
||||||
public final String pinAuth;
|
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) {
|
public DiscoveredDevice(String name, String host, int port, String transport, long lastSeenMs) {
|
||||||
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
|
this(name, host, port, transport, lastSeenMs, 0, 0, 0, 0L, "");
|
||||||
@@ -96,6 +98,8 @@ public class DiscoveryManager {
|
|||||||
private final Object lockGuard = new Object();
|
private final Object lockGuard = new Object();
|
||||||
private WifiManager.MulticastLock multicastLock;
|
private WifiManager.MulticastLock multicastLock;
|
||||||
private int multicastHolds;
|
private int multicastHolds;
|
||||||
|
private boolean burstScan;
|
||||||
|
private NsdCastDiscovery nsdDiscovery;
|
||||||
|
|
||||||
public DiscoveryManager(Context context) {
|
public DiscoveryManager(Context context) {
|
||||||
this.appContext = context.getApplicationContext();
|
this.appContext = context.getApplicationContext();
|
||||||
@@ -110,11 +114,18 @@ public class DiscoveryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void startBrowsing() {
|
public void startBrowsing() {
|
||||||
|
startBrowsing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param burst shorter UDP pass + NSD (user Refresh). */
|
||||||
|
public void startBrowsing(boolean burst) {
|
||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
burstScan = burst;
|
||||||
purgeSelfFromDeviceMap();
|
purgeSelfFromDeviceMap();
|
||||||
notifyScanStarted();
|
notifyScanStarted();
|
||||||
|
startNsdBrowse();
|
||||||
executor.execute(this::browseLoop);
|
executor.execute(this::browseLoop);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +135,8 @@ public class DiscoveryManager {
|
|||||||
*/
|
*/
|
||||||
public void stop() {
|
public void stop() {
|
||||||
running.set(false);
|
running.set(false);
|
||||||
|
stopNsdBrowse();
|
||||||
|
stopNsdRegister();
|
||||||
DatagramSocket toClose = socket;
|
DatagramSocket toClose = socket;
|
||||||
socket = null;
|
socket = null;
|
||||||
if (toClose != null) {
|
if (toClose != null) {
|
||||||
@@ -154,9 +167,25 @@ public class DiscoveryManager {
|
|||||||
if (!running.compareAndSet(false, true)) {
|
if (!running.compareAndSet(false, true)) {
|
||||||
return;
|
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));
|
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) {
|
public void startAnnouncing(String deviceName, int port, String transport, boolean listening) {
|
||||||
startAnnouncing(appContext, deviceName, port, transport, listening, "");
|
startAnnouncing(appContext, deviceName, port, transport, listening, "");
|
||||||
}
|
}
|
||||||
@@ -170,13 +199,16 @@ public class DiscoveryManager {
|
|||||||
sock.setBroadcast(true);
|
sock.setBroadcast(true);
|
||||||
sock.setSoTimeout(300);
|
sock.setSoTimeout(300);
|
||||||
byte[] buf = new byte[1024];
|
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();
|
long nextProbeMs = System.currentTimeMillis();
|
||||||
while (running.get() && System.currentTimeMillis() < deadline) {
|
while (running.get() && System.currentTimeMillis() < deadline) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
if (now >= nextProbeMs) {
|
if (now >= nextProbeMs) {
|
||||||
sendDiscoveryProbe(sock);
|
sendDiscoveryProbe(sock);
|
||||||
nextProbeMs = now + CastConfig.DISCOVERY_PROBE_INTERVAL_MS;
|
nextProbeMs = now + probeInterval;
|
||||||
}
|
}
|
||||||
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
DatagramPacket packet = new DatagramPacket(buf, buf.length);
|
||||||
try {
|
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() {
|
private void releaseMulticastLock() {
|
||||||
synchronized (lockGuard) {
|
synchronized (lockGuard) {
|
||||||
if (multicastLock == null || multicastHolds <= 0) {
|
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
|
@Override
|
||||||
public void onUserLeaveHint() {
|
public void onUserLeaveHint() {
|
||||||
super.onUserLeaveHint();
|
super.onUserLeaveHint();
|
||||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||||
|
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||||
tryEnterPictureInPicture();
|
tryEnterPictureInPicture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +404,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
}
|
}
|
||||||
videoWidth = width;
|
videoWidth = width;
|
||||||
videoHeight = height;
|
videoHeight = height;
|
||||||
if (castSessionActive && PermissionHelper.hasReceiverPlaybackReady(this)) {
|
if (castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||||
|
&& PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||||
PictureInPictureHelper.applyParams(this, videoWidth, videoHeight);
|
PictureInPictureHelper.applyParams(this, videoWidth, videoHeight);
|
||||||
}
|
}
|
||||||
applyAspectRatio();
|
applyAspectRatio();
|
||||||
@@ -509,7 +511,9 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
|
|
||||||
private void onCastSessionStarted() {
|
private void onCastSessionStarted() {
|
||||||
castSessionActive = true;
|
castSessionActive = true;
|
||||||
PermissionHelper.remindForReceiverPlayback(this);
|
if (AppPreferences.isReceiverPipEnabled(this)) {
|
||||||
|
PermissionHelper.remindForReceiverPlayback(this);
|
||||||
|
}
|
||||||
updatePipUi();
|
updatePipUi();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +526,8 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
if (pipButton == null) {
|
if (pipButton == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean show = castSessionActive && PictureInPictureHelper.isSupported()
|
boolean show = castSessionActive && AppPreferences.isReceiverPipEnabled(this)
|
||||||
|
&& PictureInPictureHelper.isSupported()
|
||||||
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
&& PermissionHelper.hasReceiverPlaybackReady(this);
|
||||||
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
pipButton.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
if (show) {
|
if (show) {
|
||||||
@@ -531,7 +536,7 @@ public class ReceiverPlaybackActivity extends DrawerHostActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void tryEnterPictureInPicture() {
|
private void tryEnterPictureInPicture() {
|
||||||
if (!castSessionActive) {
|
if (!castSessionActive || !AppPreferences.isReceiverPipEnabled(this)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
if (!PermissionHelper.hasReceiverPlaybackReady(this)) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.util.Set;
|
|||||||
|
|
||||||
/** Device list with single or multi selection. */
|
/** Device list with single or multi selection. */
|
||||||
public class DeviceListAdapter extends BaseAdapter {
|
public class DeviceListAdapter extends BaseAdapter {
|
||||||
|
private final Context context;
|
||||||
private final LayoutInflater inflater;
|
private final LayoutInflater inflater;
|
||||||
private final boolean multiSelect;
|
private final boolean multiSelect;
|
||||||
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
private final List<DiscoveryManager.DiscoveredDevice> devices = new ArrayList<>();
|
||||||
@@ -25,6 +26,7 @@ public class DeviceListAdapter extends BaseAdapter {
|
|||||||
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
|
private final Set<Integer> selectedPositions = new LinkedHashSet<>();
|
||||||
|
|
||||||
public DeviceListAdapter(Context context, boolean multiSelect) {
|
public DeviceListAdapter(Context context, boolean multiSelect) {
|
||||||
|
this.context = context;
|
||||||
inflater = LayoutInflater.from(context);
|
inflater = LayoutInflater.from(context);
|
||||||
this.multiSelect = multiSelect;
|
this.multiSelect = multiSelect;
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,9 @@ public class DeviceListAdapter extends BaseAdapter {
|
|||||||
}
|
}
|
||||||
TextView label = row.findViewById(R.id.device_label);
|
TextView label = row.findViewById(R.id.device_label);
|
||||||
DiscoveryManager.DiscoveredDevice d = devices.get(position);
|
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;
|
boolean selected = multiSelect ? selectedPositions.contains(position) : position == selectedPosition;
|
||||||
row.setActivated(selected);
|
row.setActivated(selected);
|
||||||
row.setSelected(selected);
|
row.setSelected(selected);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import com.foxx.androidcast.PermissionHelper;
|
|||||||
import com.foxx.androidcast.PlatformCastSupport;
|
import com.foxx.androidcast.PlatformCastSupport;
|
||||||
import com.foxx.androidcast.R;
|
import com.foxx.androidcast.R;
|
||||||
import com.foxx.androidcast.discovery.DiscoveryManager;
|
import com.foxx.androidcast.discovery.DiscoveryManager;
|
||||||
|
import com.foxx.androidcast.discovery.PinnedReceiversStore;
|
||||||
import com.foxx.androidcast.discovery.ReceiverCompatibility;
|
import com.foxx.androidcast.discovery.ReceiverCompatibility;
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial;
|
import com.google.android.material.switchmaterial.SwitchMaterial;
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
|
|
||||||
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
|
private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE;
|
||||||
private int discoverySecondsLeft;
|
private int discoverySecondsLeft;
|
||||||
|
private boolean lastScanBurst;
|
||||||
private final Runnable autoScanRunnable = () -> beginScanPass(false);
|
private final Runnable autoScanRunnable = () -> beginScanPass(false);
|
||||||
|
|
||||||
private String pendingPin;
|
private String pendingPin;
|
||||||
@@ -148,7 +150,9 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onScanStarted() {
|
public void onScanStarted() {
|
||||||
discoveryPhase = DiscoveryPhase.SCANNING;
|
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);
|
discoveryProgress.setVisibility(View.VISIBLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,12 +213,26 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
if (deviceAdapter == null) {
|
if (deviceAdapter == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
java.util.List<DiscoveryManager.DiscoveredDevice> compatible = new java.util.ArrayList<>();
|
java.util.LinkedHashMap<String, DiscoveryManager.DiscoveredDevice> merged =
|
||||||
for (DiscoveryManager.DiscoveredDevice d : lastDiscovered) {
|
new java.util.LinkedHashMap<>();
|
||||||
|
for (DiscoveryManager.DiscoveredDevice d : PinnedReceiversStore.load(this)) {
|
||||||
if (ReceiverCompatibility.isCompatible(d, castSettings)) {
|
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);
|
deviceAdapter.setDevices(compatible);
|
||||||
if (statusText == null) {
|
if (statusText == null) {
|
||||||
return;
|
return;
|
||||||
@@ -297,7 +315,8 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
discovery.clearDevices();
|
discovery.clearDevices();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
discovery.startBrowsing();
|
lastScanBurst = userInitiated;
|
||||||
|
discovery.startBrowsing(userInitiated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startCastFlow() {
|
private void startCastFlow() {
|
||||||
@@ -341,6 +360,7 @@ public class SenderActivity extends DrawerHostActivity {
|
|||||||
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, R.string.transport_mismatch, Toast.LENGTH_LONG).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
PinnedReceiversStore.recordSuccess(this, d);
|
||||||
pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
|
pendingTargets.add(new CastReceiverTarget(d.name, d.host, d.port, d.transport,
|
||||||
d.videoCodecs, d.audioCodecs, d.placeholderId));
|
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.StreamProtectionMode;
|
||||||
import com.foxx.androidcast.network.transport.TransportLossStats;
|
import com.foxx.androidcast.network.transport.TransportLossStats;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,8 @@ public final class SessionStatsRecorder {
|
|||||||
private int fpsSamples;
|
private int fpsSamples;
|
||||||
|
|
||||||
private final TransportLossStats lossTotals = new TransportLossStats();
|
private final TransportLossStats lossTotals = new TransportLossStats();
|
||||||
|
private final JSONArray samples = new JSONArray();
|
||||||
|
private long lastSampleMs;
|
||||||
|
|
||||||
public SessionStatsRecorder(String role, String transport) {
|
public SessionStatsRecorder(String role, String transport) {
|
||||||
this.role = role;
|
this.role = role;
|
||||||
@@ -82,6 +85,7 @@ public final class SessionStatsRecorder {
|
|||||||
if (s == null) {
|
if (s == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
appendTimeSample(s);
|
||||||
if (s.rttMs > 0) {
|
if (s.rttMs > 0) {
|
||||||
sumRttMs += s.rttMs;
|
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) {
|
public void mergeSettings(CastSettings settings) {
|
||||||
if (settings == null) {
|
if (settings == null) {
|
||||||
return;
|
return;
|
||||||
@@ -209,6 +246,9 @@ public final class SessionStatsRecorder {
|
|||||||
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames);
|
||||||
}
|
}
|
||||||
root.put("udp", udp);
|
root.put("udp", udp);
|
||||||
|
if (samples.length() > 0) {
|
||||||
|
root.put("samples", samples);
|
||||||
|
}
|
||||||
|
|
||||||
SessionStatsStore.saveSession(context, startedMs, root);
|
SessionStatsStore.saveSession(context, startedMs, root);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
|
|||||||
@@ -218,6 +218,19 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content" />
|
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
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|||||||
@@ -195,10 +195,13 @@
|
|||||||
<string name="codec_speex">Speex</string>
|
<string name="codec_speex">Speex</string>
|
||||||
<string name="codec_opus">Opus</string>
|
<string name="codec_opus">Opus</string>
|
||||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||||
<string name="pip_enter">Картинка в картинке</string>
|
<string name="pip_enter">Картинка в картинке (PiP)</string>
|
||||||
<string name="pip_unavailable">Режим «картинка в картинке» недоступен на этом устройстве</string>
|
<string name="pip_unavailable">Режим «картинка в картинке» (PiP) недоступен на этом устройстве</string>
|
||||||
<string name="pip_disabled_title">Картинка в картинке отключена</string>
|
<string name="pip_disabled_title">PiP отключено</string>
|
||||||
<string name="pip_disabled_message">Разрешите картинку в картинке для Android Cast в настройках системы.</string>
|
<string name="pip_disabled_message">Разрешите PiP для Android Cast в настройках системы.</string>
|
||||||
<string name="pip_permissions_hint">Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне.</string>
|
<string name="pip_permissions_hint">Уведомления нужны, чтобы приёмник оставался активным при трансляции в фоне.</string>
|
||||||
<string name="permission_notifications_cast">Уведомления (статус трансляции)</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>
|
</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_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="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="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>
|
</resources>
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "AndroidCast"
|
rootProject.name = "AndroidCast"
|
||||||
include ':app'
|
include ':app'
|
||||||
|
include ':session-studio'
|
||||||
|
project(':session-studio').projectDir = file('desktop/session-studio')
|
||||||
|
|||||||
Reference in New Issue
Block a user