From 0a6a4269f4b03d31930dc4146df2e2dd31ddc387 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sat, 16 May 2026 18:50:36 +0200 Subject: [PATCH] snap --- app/build.gradle | 7 + app/src/main/AndroidManifest.xml | 2 + .../java/com/foxx/androidcast/CastConfig.java | 26 +- .../com/foxx/androidcast/CastResolution.java | 5 + .../com/foxx/androidcast/CastSettings.java | 11 +- .../com/foxx/androidcast/MainActivity.java | 3 + .../foxx/androidcast/PlatformCastSupport.java | 7 + .../java/com/foxx/androidcast/SettingsUi.java | 85 ++-- .../diagnostics/CastDiagnosticsFormatter.java | 108 +++++ .../diagnostics/CastDiagnosticsLabels.java | 4 + .../discovery/DiscoveryManager.java | 114 +++++- .../androidcast/media/CodecNegotiator.java | 4 + .../androidcast/media/EncodedStreamProbe.java | 32 ++ .../foxx/androidcast/media/H264Bitstream.java | 5 +- .../com/foxx/androidcast/media/RgbToNv21.java | 52 +++ .../foxx/androidcast/media/RgbToYuv420.java | 115 ++++++ .../androidcast/network/CastPacketFramer.java | 12 +- .../androidcast/network/CastProtocol.java | 48 ++- .../androidcast/network/CastSessionGate.java | 37 ++ .../androidcast/network/CastTransport.java | 10 + .../network/CastTransportFactory.java | 22 +- .../androidcast/network/TcpCastTransport.java | 85 +++- .../androidcast/network/UdpCastTransport.java | 121 +++++- .../network/control/NetworkControlAdvice.java | 2 +- .../network/control/NetworkStatsSnapshot.java | 42 ++ .../PassThroughNetworkControlPlane.java | 2 +- .../network/quic/CronetEngineProvider.java | 50 +++ .../network/quic/QuicCronetCastTransport.java | 138 +++++++ .../transport/StreamProtectionEngine.java | 95 +++++ .../transport/StreamProtectionMode.java | 26 ++ .../network/transport/TransportLossStats.java | 97 +++++ .../transport/TransportLossStatsBridge.java | 42 ++ .../transport/TransportStatsProvider.java | 6 + .../network/webrtc/WebRtcCastTransport.java | 62 +++ .../network/webrtc/WebRtcPeerAdapter.java | 37 ++ .../webrtc/WebRtcTransportFactory.java | 19 + .../androidcast/receiver/AudioDecoder.java | 81 +++- .../receiver/ReceiverCastService.java | 150 ++++++- .../receiver/ReceiverPlaybackActivity.java | 100 +++-- .../androidcast/receiver/ReceiverSession.java | 29 +- .../androidcast/receiver/VideoDecoder.java | 30 +- .../foxx/androidcast/sender/AudioCapture.java | 105 +++-- .../foxx/androidcast/sender/AudioEncoder.java | 12 +- .../foxx/androidcast/sender/CastSendPump.java | 68 +++- .../sender/MultiCastCoordinator.java | 13 +- .../androidcast/sender/ScreenCastService.java | 381 +++++++++++++++--- .../androidcast/sender/SenderActivity.java | 136 +++++-- .../foxx/androidcast/sender/VideoEncoder.java | 110 ++++- .../calibration/CalibrationCastDriver.java | 199 +++++++++ .../calibration/CalibrationEglRenderer.java | 236 +++++++++++ .../calibration/TvCalibrationGenerator.java | 159 ++++++++ .../sender/camera/CameraCastCapture.java | 156 +++++++ .../stats/SessionStatsRecorder.java | 204 ++++++++++ .../androidcast/stats/SessionStatsStore.java | 73 ++++ .../ui/AspectRatioFrameLayout.java | 19 +- .../ui/PlaybackTextureTransform.java | 41 ++ .../calibration_chart_reference.png | Bin 0 -> 175302 bytes app/src/main/res/layout/activity_playback.xml | 2 +- app/src/main/res/values/strings.xml | 17 +- .../foxx/androidcast/CastResolutionTest.java | 17 + .../media/EncodedStreamProbeTest.java | 16 + .../media/H264BitstreamHevcTest.java | 16 + .../foxx/androidcast/media/RgbToNv21Test.java | 34 ++ .../androidcast/media/RgbToYuv420Test.java | 25 ++ .../network/CastProtoHeaderTest.java | 34 ++ .../network/CastSessionGateTest.java | 49 +++ .../stats/SessionStatsStoreTest.java | 42 ++ .../ui/PlaybackTextureTransformTest.java | 34 ++ 68 files changed, 3816 insertions(+), 305 deletions(-) create mode 100644 app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java create mode 100644 app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java create mode 100644 app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java create mode 100644 app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java create mode 100644 app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java create mode 100644 app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java create mode 100644 app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java create mode 100644 app/src/main/res/drawable-nodpi/calibration_chart_reference.png create mode 100644 app/src/test/java/com/foxx/androidcast/CastResolutionTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java create mode 100644 app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java create mode 100644 app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java create mode 100644 app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java diff --git a/app/build.gradle b/app/build.gradle index 08f0fa6..cf6b57a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,10 +29,17 @@ android { buildFeatures { aidl true } + + testOptions { + unitTests.includeAndroidResources = true + } } dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'com.google.android.gms:play-services-cronet:18.1.0' + + testImplementation 'junit:junit:4.13.2' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0d18ca3..e42917a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + = Build.VERSION_CODES.Q; + } + /** Applies default audio flag: on for Android 10+, off otherwise. */ public static void applyDefaultAudioSetting(CastSettings settings) { settings.setAudioEnabled(isAudioCaptureSupported()); diff --git a/app/src/main/java/com/foxx/androidcast/SettingsUi.java b/app/src/main/java/com/foxx/androidcast/SettingsUi.java index 04e050c..cc8232a 100644 --- a/app/src/main/java/com/foxx/androidcast/SettingsUi.java +++ b/app/src/main/java/com/foxx/androidcast/SettingsUi.java @@ -50,10 +50,36 @@ public final class SettingsUi { Spinner resolution = activity.findViewById(R.id.spinner_resolution); bindSpinner(activity, transport, new String[]{ + activity.getString(R.string.transport_udp), activity.getString(R.string.transport_tcp), - activity.getString(R.string.transport_udp) - }, position -> settings.setTransport( - position == 1 ? CastConfig.TRANSPORT_UDP : CastConfig.TRANSPORT_TCP)); + activity.getString(R.string.transport_quic), + activity.getString(R.string.transport_webrtc) + }, position -> { + switch (position) { + case 1: + settings.setTransport(CastConfig.TRANSPORT_TCP); + break; + case 2: + settings.setTransport(CastConfig.TRANSPORT_QUIC); + break; + case 3: + settings.setTransport(CastConfig.TRANSPORT_WEBRTC); + break; + case 0: + default: + settings.setTransport(CastConfig.TRANSPORT_UDP); + break; + } + }); + int transportIndex = 0; + if (CastConfig.TRANSPORT_TCP.equals(settings.getTransport())) { + transportIndex = 1; + } else if (CastConfig.TRANSPORT_QUIC.equals(settings.getTransport())) { + transportIndex = 2; + } else if (CastConfig.TRANSPORT_WEBRTC.equals(settings.getTransport())) { + transportIndex = 3; + } + transport.setSelection(transportIndex); List codecs = CodecCatalog.supportedChoices(); List codecLabels = new ArrayList<>(); @@ -106,29 +132,42 @@ public final class SettingsUi { if (captureMode == null) { return; } + java.util.List modes = new java.util.ArrayList<>(); + java.util.List modeValues = new java.util.ArrayList<>(); + modes.add(activity.getString(R.string.capture_full_screen)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); if (PlatformCastSupport.isCaptureUserChoiceSupported()) { - bindSpinner(activity, captureMode, new String[]{ - activity.getString(R.string.capture_full_screen), - activity.getString(R.string.capture_user_choice) - }, position -> settings.setCaptureMode( - position == 1 ? CastSettings.CaptureMode.USER_CHOICE : CastSettings.CaptureMode.FULL_SCREEN)); - captureMode.setSelection(settings.getCaptureMode() == CastSettings.CaptureMode.USER_CHOICE ? 1 : 0); - captureMode.setEnabled(true); - if (label != null) { - label.setText(R.string.label_capture_mode); - } + modes.add(activity.getString(R.string.capture_user_choice)); + modeValues.add(CastSettings.CaptureMode.USER_CHOICE); } else { - String unavailable = activity.getString(R.string.capture_user_choice_unavailable, - Build.VERSION.SDK_INT); - bindSpinner(activity, captureMode, new String[]{ - activity.getString(R.string.capture_full_screen), - unavailable - }, position -> settings.setCaptureMode(CastSettings.CaptureMode.FULL_SCREEN)); - captureMode.setSelection(0); - captureMode.setEnabled(false); - if (label != null) { - label.setText(activity.getString(R.string.label_capture_mode_sender_only)); + modes.add(activity.getString(R.string.capture_user_choice_unavailable, Build.VERSION.SDK_INT)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); + } + modes.add(activity.getString(R.string.capture_calibration_test)); + modeValues.add(CastSettings.CaptureMode.CALIBRATION_TEST); + if (PlatformCastSupport.isCameraCaptureModeSupported()) { + modes.add(activity.getString(R.string.capture_camera)); + modeValues.add(CastSettings.CaptureMode.CAMERA); + } else { + modes.add(activity.getString(R.string.capture_camera_unavailable)); + modeValues.add(CastSettings.CaptureMode.FULL_SCREEN); + } + bindSpinner(activity, captureMode, modes.toArray(new String[0]), position -> { + if (position >= 0 && position < modeValues.size()) { + settings.setCaptureMode(modeValues.get(position)); } + }); + int sel = 0; + for (int i = 0; i < modeValues.size(); i++) { + if (modeValues.get(i) == settings.getCaptureMode()) { + sel = i; + break; + } + } + captureMode.setSelection(sel); + captureMode.setEnabled(true); + if (label != null) { + label.setText(R.string.label_capture_mode); } } diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java index 9929806..e353561 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsFormatter.java @@ -135,6 +135,10 @@ public final class CastDiagnosticsFormatter { html.append("Congestion: ").append(colored(String.valueOf(congestionLevel), congestionLevel > 0)); html.append("
"); + + appendUdpLossSection(html, peer, lastLocal); + appendProtectionSection(html, peer, lastLocal); + html.append("Ctrl: ").append(escape(ctrl)); html.append("
"); html.append("Mode: ").append(escape(bitrateMode)); @@ -261,6 +265,110 @@ public final class CastDiagnosticsFormatter { return html.toString(); } + private static void appendUdpLossSection(StringBuilder html, NetworkStatsSnapshot peer, + NetworkStatsSnapshot local) { + long rx = pick(peer, local, true, false); + long tx = pick(peer, local, true, true); + long lost = val(peer, local, p -> p.udpReassemblyLost, l -> l.udpReassemblyLost); + long invalid = val(peer, local, p -> p.udpDatagramsInvalid, l -> l.udpDatagramsInvalid); + long dup = val(peer, local, p -> p.udpFragmentsDuplicate, l -> l.udpFragmentsDuplicate); + long wireDrop = val(peer, local, p -> p.udpWireDropped, l -> l.udpWireDropped); + long skipKey = val(peer, local, p -> p.recvVideoSkippedKey, l -> l.recvVideoSkippedKey); + long vDecErr = val(peer, local, p -> p.recvVideoDecodeErrors, l -> l.recvVideoDecodeErrors); + long aDecErr = val(peer, local, p -> p.recvAudioDecodeErrors, l -> l.recvAudioDecodeErrors); + long aDrop = val(peer, local, p -> p.recvAudioQueueDrops, l -> l.recvAudioQueueDrops); + long dropAudio = val(peer, local, p -> (long) p.droppedAudioFrames, l -> (long) l.droppedAudioFrames); + + if (rx == 0 && tx == 0 && lost == 0 && invalid == 0) { + return; + } + html.append("UDP: "); + html.append("rx ").append(rx).append(" / tx ").append(tx).append(" dg"); + if (lost > 0) { + html.append(" ").append(colored("reasm-lost " + lost, true)); + } + if (invalid > 0) { + html.append(" ").append(colored("bad " + invalid, true)); + } + if (dup > 0) { + html.append(" dup ").append(dup); + } + if (wireDrop > 0) { + html.append(" ").append(colored("sess-drop " + wireDrop, true)); + } + html.append("
"); + if (skipKey > 0 || vDecErr > 0 || aDecErr > 0 || aDrop > 0 || dropAudio > 0) { + html.append("App drops: "); + if (skipKey > 0) { + html.append(colored("vid-wait-key " + skipKey, skipKey > 50)); + } + if (vDecErr > 0) { + html.append(' ').append(colored("vid-dec " + vDecErr, true)); + } + if (aDecErr > 0) { + html.append(' ').append(colored("aud-dec " + aDecErr, true)); + } + if (aDrop > 0) { + html.append(' ').append(colored("aud-q " + aDrop, true)); + } + if (dropAudio > 0) { + html.append(' ').append(colored("snd-aud " + dropAudio, true)); + } + html.append("
"); + } + } + + private static void appendProtectionSection(StringBuilder html, NetworkStatsSnapshot peer, + NetworkStatsSnapshot local) { + String mode = str(peer, local, p -> p.protectionMode, l -> l.protectionMode, "NONE"); + html.append("Protect: "); + html.append(escape(mode)); + html.append(" · FEC stub (off)"); + html.append(" · NACK stub (off)"); + long nack = val(peer, local, p -> p.nackStubRequests, l -> l.nackStubRequests); + if (nack > 0) { + html.append(" nack=").append(nack); + } + html.append("
"); + } + + private static long pick(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + boolean preferLocal, boolean tx) { + if (preferLocal && local != null) { + return tx ? local.udpDatagramsTx : local.udpDatagramsRx; + } + if (peer != null) { + return tx ? peer.udpDatagramsTx : peer.udpDatagramsRx; + } + return local != null ? (tx ? local.udpDatagramsTx : local.udpDatagramsRx) : 0; + } + + private static long val(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + java.util.function.Function peerFn, + java.util.function.Function localFn) { + long v = 0; + if (local != null) { + v = Math.max(v, localFn.apply(local)); + } + if (peer != null) { + v = Math.max(v, peerFn.apply(peer)); + } + return v; + } + + private static String str(NetworkStatsSnapshot peer, NetworkStatsSnapshot local, + java.util.function.Function peerFn, + java.util.function.Function localFn, + String def) { + if (local != null && localFn.apply(local) != null && !localFn.apply(local).isEmpty()) { + return localFn.apply(local); + } + if (peer != null && peerFn.apply(peer) != null && !peerFn.apply(peer).isEmpty()) { + return peerFn.apply(peer); + } + return def; + } + private static List buildHints(float inFps, float renderFps, int bwInKbps, int rttMs, long videoPackets) { List hints = new ArrayList<>(); diff --git a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java index b01c36f..3df4388 100644 --- a/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java +++ b/app/src/main/java/com/foxx/androidcast/diagnostics/CastDiagnosticsLabels.java @@ -117,6 +117,10 @@ public final class CastDiagnosticsLabels { switch (mode) { case USER_CHOICE: return "Choose app or screen (Android 14+)"; + case CALIBRATION_TEST: + return "Calibration test pattern"; + case CAMERA: + return "Camera (Camera2, planned)"; case FULL_SCREEN: default: return "Entire screen"; diff --git a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java index 044494c..99aa02f 100644 --- a/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java +++ b/app/src/main/java/com/foxx/androidcast/discovery/DiscoveryManager.java @@ -92,6 +92,10 @@ public class DiscoveryManager { this.listener = listener; } + public boolean isBrowsing() { + return running.get(); + } + public void startBrowsing() { if (!running.compareAndSet(false, true)) { return; @@ -148,7 +152,13 @@ public class DiscoveryManager { sock.setSoTimeout(300); byte[] buf = new byte[1024]; long deadline = System.currentTimeMillis() + CastConfig.DISCOVERY_SCAN_PASS_MS; + 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; + } DatagramPacket packet = new DatagramPacket(buf, buf.length); try { sock.receive(packet); @@ -200,26 +210,25 @@ public class DiscoveryManager { try (DatagramSocket sock = new DatagramSocket()) { socket = sock; sock.setBroadcast(true); + sock.setSoTimeout(400); while (running.get()) { - JSONObject json = new JSONObject(); - json.put("magic", CastConfig.DISCOVERY_MAGIC); - json.put("name", deviceName); - json.put("port", port); - json.put("transport", transport); - json.put("listening", listening); - json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); - byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); - CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); - byte[] wire; - try { - wire = CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes); - } catch (Exception e) { - wire = jsonBytes; + sendAnnounce(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + long listenUntil = System.currentTimeMillis() + 2_000; + while (running.get() && System.currentTimeMillis() < listenUntil) { + try { + byte[] buf = new byte[1024]; + DatagramPacket packet = new DatagramPacket(buf, buf.length); + sock.receive(packet); + if (isDiscoverProbe(packet)) { + sendAnnounce(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + sendAnnounceUnicast(sock, context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask, packet.getAddress()); + } + } catch (java.net.SocketTimeoutException ignored) { + } } - InetAddress broadcast = InetAddress.getByName("255.255.255.255"); - DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); - sock.send(packet); - Thread.sleep(2000); } } catch (Exception e) { if (running.get()) { @@ -232,6 +241,73 @@ public class DiscoveryManager { } } + private static void sendDiscoveryProbe(DatagramSocket sock) { + try { + JSONObject json = new JSONObject(); + json.put("magic", CastConfig.DISCOVERY_MAGIC); + json.put("discover", true); + byte[] bytes = json.toString().getBytes(StandardCharsets.UTF_8); + InetAddress broadcast = InetAddress.getByName("255.255.255.255"); + DatagramPacket packet = new DatagramPacket(bytes, bytes.length, broadcast, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } catch (Exception e) { + Log.w(TAG, "Discovery probe send failed: " + e.getMessage()); + } + } + + private static boolean isDiscoverProbe(DatagramPacket packet) { + try { + String text = new String(packet.getData(), packet.getOffset(), packet.getLength(), + StandardCharsets.UTF_8); + JSONObject json = new JSONObject(text); + return CastConfig.DISCOVERY_MAGIC.equals(json.optString("magic")) + && json.optBoolean("discover", false); + } catch (Exception e) { + return false; + } + } + + private static void sendAnnounce(DatagramSocket sock, Context context, String deviceName, int port, + String transport, boolean listening, int placeholderId, int videoMask, int audioMask) + throws Exception { + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + InetAddress broadcast = InetAddress.getByName("255.255.255.255"); + DatagramPacket packet = new DatagramPacket(wire, wire.length, broadcast, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } + + private static void sendAnnounceUnicast(DatagramSocket sock, Context context, String deviceName, int port, + String transport, boolean listening, int placeholderId, int videoMask, int audioMask, + InetAddress target) { + try { + byte[] wire = buildAnnounceWire(context, deviceName, port, transport, listening, + placeholderId, videoMask, audioMask); + DatagramPacket packet = new DatagramPacket(wire, wire.length, target, CastConfig.DISCOVERY_PORT); + sock.send(packet); + } catch (Exception e) { + Log.w(TAG, "Unicast announce failed: " + e.getMessage()); + } + } + + private static byte[] buildAnnounceWire(Context context, String deviceName, int port, String transport, + boolean listening, int placeholderId, int videoMask, int audioMask) throws Exception { + JSONObject json = new JSONObject(); + json.put("magic", CastConfig.DISCOVERY_MAGIC); + json.put("name", deviceName); + json.put("port", port); + json.put("transport", transport); + json.put("listening", listening); + json.put("device_uuid", DeviceInfo.getDeviceUuid(context)); + byte[] jsonBytes = json.toString().getBytes(StandardCharsets.UTF_8); + CastProtoHeader hdr = CastProtoHeader.forDiscovery(placeholderId, videoMask, audioMask); + try { + return CastPacketFramer.frame(hdr, CastProtocol.MSG_HELLO, jsonBytes); + } catch (Exception e) { + return jsonBytes; + } + } + private void handlePacket(DatagramPacket packet) { try { byte[] raw = packet.getData(); @@ -274,7 +350,7 @@ public class DiscoveryManager { } String name = json.optString("name", "Unknown"); int port = json.optInt("port", CastConfig.CAST_PORT); - String transport = json.optString("transport", CastConfig.TRANSPORT_TCP); + String transport = json.optString("transport", CastConfig.DEFAULT_TRANSPORT); if (!json.optBoolean("listening", true)) { return; } diff --git a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java index 5509b56..21942a3 100644 --- a/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java +++ b/app/src/main/java/com/foxx/androidcast/media/CodecNegotiator.java @@ -72,4 +72,8 @@ public final class CodecNegotiator { public static boolean isH264(String mime) { return MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime); } + + public static boolean isHevc(String mime) { + return MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime); + } } diff --git a/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java b/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java new file mode 100644 index 0000000..c4d7a54 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/EncodedStreamProbe.java @@ -0,0 +1,32 @@ +package com.foxx.androidcast.media; + +import android.media.MediaFormat; + +/** Inspects encoded bitstream headers for diagnostics. */ +public final class EncodedStreamProbe { + private EncodedStreamProbe() {} + + public static String describeKeyframe(String mime, byte[] annexB) { + if (annexB == null || annexB.length < 5) { + return "empty"; + } + int offset = 0; + if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1) { + offset = 4; + } else if (annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 1) { + offset = 3; + } + if (offset >= annexB.length) { + return "no start code"; + } + if (MediaFormat.MIMETYPE_VIDEO_HEVC.equals(mime)) { + int nalType = (annexB[offset] >> 1) & 0x3F; + return "HEVC NAL type " + nalType + " (" + annexB.length + " B)"; + } + if (MediaFormat.MIMETYPE_VIDEO_AVC.equals(mime)) { + int nalType = annexB[offset] & 0x1F; + return "AVC NAL type " + nalType + " (" + annexB.length + " B)"; + } + return "encoded " + annexB.length + " B"; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java index c828793..cdd16f3 100644 --- a/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java +++ b/app/src/main/java/com/foxx/androidcast/media/H264Bitstream.java @@ -2,7 +2,10 @@ package com.foxx.androidcast.media; import java.io.ByteArrayOutputStream; -/** Converts H.264 AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. */ +/** + * Converts H.264 / HEVC AVCC (length-prefixed) NAL units to Annex-B start codes for decoders. + * MediaCodec encoders on Android typically emit AVCC; many decoders expect Annex-B in input buffers. + */ public final class H264Bitstream { private static final byte[] START_CODE = {0, 0, 0, 1}; diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java b/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java new file mode 100644 index 0000000..659ddcf --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/RgbToNv21.java @@ -0,0 +1,52 @@ +package com.foxx.androidcast.media; + +import android.graphics.Bitmap; + +/** ARGB bitmap to NV21 for YUV420 encoder input. */ +public final class RgbToNv21 { + private RgbToNv21() {} + + public static byte[] fromBitmap(Bitmap bitmap) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int[] argb = new int[width * height]; + bitmap.getPixels(argb, 0, width, 0, 0, width, height); + return fromArgb(argb, width, height); + } + + public static byte[] fromArgb(int[] argb, int width, int height) { + int frameSize = width * height; + int ySize = frameSize; + int uvSize = frameSize / 2; + byte[] nv21 = new byte[ySize + uvSize]; + int yIndex = 0; + int uvIndex = ySize; + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + int y = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16; + int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128; + int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128; + nv21[yIndex++] = (byte) clamp(y); + if ((j & 1) == 0 && (i & 1) == 0) { + nv21[uvIndex++] = (byte) clamp(v); + nv21[uvIndex++] = (byte) clamp(u); + } + } + } + return nv21; + } + + private static int clamp(int v) { + if (v < 0) { + return 0; + } + if (v > 255) { + return 255; + } + return v; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java new file mode 100644 index 0000000..4fffee9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/media/RgbToYuv420.java @@ -0,0 +1,115 @@ +package com.foxx.androidcast.media; + +import android.graphics.Bitmap; + +/** + * ARGB to YUV420 planar (I420) or semi-planar (NV12) for MediaCodec buffer input. + */ +public final class RgbToYuv420 { + public enum Layout { + I420, + NV12 + } + + private RgbToYuv420() {} + + public static byte[] fromBitmap(Bitmap bitmap, Layout layout) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int[] argb = new int[width * height]; + bitmap.getPixels(argb, 0, width, 0, 0, width, height); + return fromArgb(argb, width, height, layout); + } + + public static byte[] fromArgb(int[] argb, int width, int height, Layout layout) { + if (layout == Layout.NV12) { + return toNv12(argb, width, height); + } + return toI420(argb, width, height); + } + + public static int bufferSize(int width, int height) { + return width * height + (width * height) / 2; + } + + private static byte[] toI420(int[] argb, int width, int height) { + int frameSize = width * height; + int chromaW = (width + 1) / 2; + int chromaH = (height + 1) / 2; + int chromaLen = chromaW * chromaH; + byte[] out = new byte[frameSize + chromaLen * 2]; + int uOffset = frameSize; + int vOffset = frameSize + chromaLen; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + int y = yFromRgb(r, g, b); + out[j * width + i] = (byte) y; + if ((j & 1) == 0 && (i & 1) == 0) { + int u = uFromRgb(r, g, b); + int v = vFromRgb(r, g, b); + int cx = i >> 1; + int cy = j >> 1; + int cIndex = cy * chromaW + cx; + out[uOffset + cIndex] = (byte) u; + out[vOffset + cIndex] = (byte) v; + } + } + } + return out; + } + + private static byte[] toNv12(int[] argb, int width, int height) { + int frameSize = width * height; + int chromaW = (width + 1) / 2; + int chromaH = (height + 1) / 2; + byte[] out = new byte[frameSize + chromaW * chromaH * 2]; + int uvOffset = frameSize; + + for (int j = 0; j < height; j++) { + for (int i = 0; i < width; i++) { + int color = argb[j * width + i]; + int r = (color >> 16) & 0xff; + int g = (color >> 8) & 0xff; + int b = color & 0xff; + out[j * width + i] = (byte) yFromRgb(r, g, b); + if ((j & 1) == 0 && (i & 1) == 0) { + int u = uFromRgb(r, g, b); + int v = vFromRgb(r, g, b); + int cx = i >> 1; + int cy = j >> 1; + int uvIndex = uvOffset + (cy * chromaW + cx) * 2; + out[uvIndex] = (byte) u; + out[uvIndex + 1] = (byte) v; + } + } + } + return out; + } + + private static int yFromRgb(int r, int g, int b) { + return clamp(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16); + } + + private static int uFromRgb(int r, int g, int b) { + return clamp(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128); + } + + private static int vFromRgb(int r, int g, int b) { + return clamp(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128); + } + + private static int clamp(int v) { + if (v < 0) { + return 0; + } + if (v > 255) { + return 255; + } + return v; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java index 9fd50bc..e375237 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastPacketFramer.java @@ -24,14 +24,22 @@ public final class CastPacketFramer { return out; } - public static CastProtocol.Message unframe(byte[] wire) throws IOException { - if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) { + public static CastProtoHeader readHeader(byte[] wire) throws IOException { + if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1) { throw new IOException("Frame too short"); } CastProtoHeader hdr = CastProtoHeader.decode(wire, 0, wire.length); if (!hdr.verifyChecksum()) { throw new IOException("Header checksum mismatch"); } + return hdr; + } + + public static CastProtocol.Message unframe(byte[] wire) throws IOException { + if (wire == null || wire.length < CastProtoHeader.HEADER_SIZE_V1 + 5) { + throw new IOException("Frame too short"); + } + CastProtoHeader hdr = readHeader(wire); int innerOff = hdr.headerSize; if (wire.length < innerOff + 5) { throw new IOException("Missing message body"); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java index a685620..d9a6b52 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastProtocol.java @@ -255,7 +255,7 @@ public final class CastProtocol { return local.getTransport().equals(remote.getTransport()); } - private static final byte NETWORK_STATS_VERSION = 3; + private static final byte NETWORK_STATS_VERSION = 4; public static byte[] networkStatsPayload(NetworkStatsSnapshot s) throws IOException { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); @@ -283,6 +283,28 @@ public final class CastProtocol { dos.writeFloat(s.audioCapturePeakDb); dos.writeFloat(s.audioCaptureRmsDb); } + if (NETWORK_STATS_VERSION >= 4) { + dos.writeLong(s.udpDatagramsRx); + dos.writeLong(s.udpDatagramsTx); + dos.writeLong(s.udpDatagramsInvalid); + dos.writeLong(s.udpFragmentsRx); + dos.writeLong(s.udpFragmentsDuplicate); + dos.writeLong(s.udpReassemblyOk); + dos.writeLong(s.udpReassemblyLost); + dos.writeLong(s.udpWireDropped); + dos.writeLong(s.udpMalformed); + dos.writeInt(s.droppedAudioFrames); + dos.writeLong(s.recvVideoPackets); + dos.writeLong(s.recvAudioPackets); + dos.writeLong(s.recvVideoSkippedKey); + dos.writeLong(s.recvVideoDecodeErrors); + dos.writeLong(s.recvAudioDecodeErrors); + dos.writeLong(s.recvAudioQueueDrops); + dos.writeLong(s.recvDamagedVideoFrames); + dos.writeUTF(s.protectionMode != null ? s.protectionMode : "NONE"); + dos.writeLong(s.fecStubPackets); + dos.writeLong(s.nackStubRequests); + } dos.flush(); return bos.toByteArray(); } @@ -315,6 +337,28 @@ public final class CastProtocol { s.audioCapturePeakDb = dis.readFloat(); s.audioCaptureRmsDb = dis.readFloat(); } + if (ver >= 4 && dis.available() > 0) { + s.udpDatagramsRx = dis.readLong(); + s.udpDatagramsTx = dis.readLong(); + s.udpDatagramsInvalid = dis.readLong(); + s.udpFragmentsRx = dis.readLong(); + s.udpFragmentsDuplicate = dis.readLong(); + s.udpReassemblyOk = dis.readLong(); + s.udpReassemblyLost = dis.readLong(); + s.udpWireDropped = dis.readLong(); + s.udpMalformed = dis.readLong(); + s.droppedAudioFrames = dis.readInt(); + s.recvVideoPackets = dis.readLong(); + s.recvAudioPackets = dis.readLong(); + s.recvVideoSkippedKey = dis.readLong(); + s.recvVideoDecodeErrors = dis.readLong(); + s.recvAudioDecodeErrors = dis.readLong(); + s.recvAudioQueueDrops = dis.readLong(); + s.recvDamagedVideoFrames = dis.readLong(); + s.protectionMode = dis.readUTF(); + s.fecStubPackets = dis.readLong(); + s.nackStubRequests = dis.readLong(); + } } } return s; @@ -324,7 +368,7 @@ public final class CastProtocol { java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); DataOutputStream dos = new DataOutputStream(bos); dos.writeInt(advice.suggestedBitrateKbps); - dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.TRANSPORT_TCP); + dos.writeUTF(advice.suggestedTransport != null ? advice.suggestedTransport : CastConfig.DEFAULT_TRANSPORT); dos.writeByte(advice.switchTransport ? 1 : 0); dos.writeInt(advice.congestionLevel); dos.flush(); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java b/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java new file mode 100644 index 0000000..853e565 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/CastSessionGate.java @@ -0,0 +1,37 @@ +package com.foxx.androidcast.network; + +/** + * Binds to the first non-zero session id on a connection and drops stale packets from other casts. + */ +public final class CastSessionGate { + private static final int UNBOUND = Integer.MIN_VALUE; + + private int boundSessionId = UNBOUND; + + public boolean accept(CastProtoHeader header) { + if (header == null) { + return true; + } + int sid = header.sessionId; + if (sid == 0) { + return true; + } + if (boundSessionId == UNBOUND) { + boundSessionId = sid; + return true; + } + return boundSessionId == sid; + } + + public void reset() { + boundSessionId = UNBOUND; + } + + public boolean isBound() { + return boundSessionId != UNBOUND; + } + + public int getBoundSessionId() { + return boundSessionId == UNBOUND ? 0 : boundSessionId; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java index c59354b..a4b4f90 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransport.java @@ -1,5 +1,7 @@ package com.foxx.androidcast.network; +import com.foxx.androidcast.network.transport.TransportLossStats; + import java.io.Closeable; import java.io.IOException; @@ -7,6 +9,9 @@ import java.io.IOException; public interface CastTransport extends Closeable { /** Optional proto header context; ignored when null. */ default void setFramingContext(CastFramingContext context) {} + + /** Drops inbound wire packets whose session id does not match the active cast. */ + default void setInboundSessionGate(CastSessionGate gate) {} /** Server: bind and wait for first peer datagram / connection. */ void listen(int port) throws IOException; @@ -28,6 +33,11 @@ public interface CastTransport extends Closeable { String getModeLabel(); + /** Loss/damage counters when supported (UDP); empty stats otherwise. */ + default TransportLossStats getLossStats() { + return new TransportLossStats(); + } + /** Server: drop the active client socket but keep the listen socket bound. */ default void releaseConnection() { close(); diff --git a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java index cec3ff8..6bc1cc1 100644 --- a/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java +++ b/app/src/main/java/com/foxx/androidcast/network/CastTransportFactory.java @@ -1,15 +1,35 @@ package com.foxx.androidcast.network; +import android.content.Context; + import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.quic.QuicCronetCastTransport; +import com.foxx.androidcast.network.webrtc.WebRtcTransportFactory; import java.io.IOException; public final class CastTransportFactory { + private static Context appContext; + private CastTransportFactory() {} + public static void init(Context context) { + appContext = context.getApplicationContext(); + } + public static CastTransport create(CastSettings settings) throws IOException { - if (CastConfig.TRANSPORT_UDP.equals(settings.getTransport())) { + String mode = settings.getTransport(); + if (CastConfig.TRANSPORT_QUIC.equals(mode)) { + if (appContext == null) { + throw new IOException("CastTransportFactory.init(context) required for QUIC"); + } + return new QuicCronetCastTransport(appContext); + } + if (WebRtcTransportFactory.isTransportId(mode)) { + return WebRtcTransportFactory.create(); + } + if (CastConfig.TRANSPORT_UDP.equals(mode)) { return new UdpCastTransport(); } return new TcpCastTransport(); diff --git a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java index 0d6383a..2f13863 100644 --- a/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/TcpCastTransport.java @@ -5,6 +5,7 @@ import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.io.InputStream; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.ServerSocket; @@ -18,6 +19,7 @@ public class TcpCastTransport implements CastTransport { private final Object sendLock = new Object(); private CastFramingContext framingContext; + private CastSessionGate inboundSessionGate; private final Object receiveLock = new Object(); private ServerSocket serverSocket; @@ -83,6 +85,11 @@ public class TcpCastTransport implements CastTransport { this.framingContext = context; } + @Override + public void setInboundSessionGate(CastSessionGate gate) { + this.inboundSessionGate = gate; + } + @Override public void send(byte type, byte[] payload) throws IOException { synchronized (sendLock) { @@ -133,7 +140,83 @@ public class TcpCastTransport implements CastTransport { } private CastProtocol.Message readFramedMessage() throws IOException { - return CastPacketFramer.readFramedMessage(bufferedIn); + if (inboundSessionGate == null) { + return CastPacketFramer.readFramedMessage(bufferedIn); + } + byte[] magicBuf = readFully(bufferedIn, 4); + if (magicBuf == null) { + return null; + } + int magic = (magicBuf[0] & 0xff) | ((magicBuf[1] & 0xff) << 8) + | ((magicBuf[2] & 0xff) << 16) | ((magicBuf[3] & 0xff) << 24); + if (magic != CastProtoHeader.MAGIC) { + throw new IOException("Bad magic on wire: 0x" + Integer.toHexString(magic)); + } + byte[] restHdr = readFully(bufferedIn, CastProtoHeader.HEADER_SIZE_V1 - 4); + if (restHdr == null) { + return null; + } + byte[] headerBytes = new byte[CastProtoHeader.HEADER_SIZE_V1]; + System.arraycopy(magicBuf, 0, headerBytes, 0, 4); + System.arraycopy(restHdr, 0, headerBytes, 4, restHdr.length); + CastProtoHeader hdr = CastProtoHeader.decode(headerBytes, 0, headerBytes.length); + if (!hdr.verifyChecksum()) { + throw new IOException("Header checksum mismatch"); + } + if (!inboundSessionGate.accept(hdr)) { + skipMessageBody(in); + return null; + } + byte type = (byte) in.read(); + if (type < 0) { + return null; + } + byte[] lenBuf = readFully(bufferedIn, 4); + if (lenBuf == null) { + return null; + } + int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16) + | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff); + if (len < 0 || len > 4 * 1024 * 1024) { + throw new IOException("Invalid payload length: " + len); + } + byte[] payload = len == 0 ? new byte[0] : readFully(bufferedIn, len); + if (payload == null && len > 0) { + return null; + } + return new CastProtocol.Message(type, payload); + } + + private static void skipMessageBody(DataInputStream in) throws IOException { + byte type = (byte) in.read(); + if (type < 0) { + return; + } + byte[] lenBuf = readFully(in, 4); + if (lenBuf == null) { + return; + } + int len = ((lenBuf[0] & 0xff) << 24) | ((lenBuf[1] & 0xff) << 16) + | ((lenBuf[2] & 0xff) << 8) | (lenBuf[3] & 0xff); + if (len > 0 && len <= 4 * 1024 * 1024) { + in.skipBytes(len); + } + } + + private static byte[] readFully(InputStream in, int n) throws IOException { + byte[] buf = new byte[n]; + int off = 0; + while (off < n) { + int r = in.read(buf, off, n - off); + if (r < 0) { + if (off == 0) { + return null; + } + throw new java.io.EOFException("Short read"); + } + off += r; + } + return buf; } private boolean waitForHeader(int timeoutMs) throws IOException { diff --git a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java index 4e36078..ddc1594 100644 --- a/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java +++ b/app/src/main/java/com/foxx/androidcast/network/UdpCastTransport.java @@ -1,6 +1,9 @@ package com.foxx.androidcast.network; import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.transport.StreamProtectionEngine; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportStatsProvider; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -14,7 +17,7 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** Best-effort message transport over UDP with fragmentation. */ -public class UdpCastTransport implements CastTransport { +public class UdpCastTransport implements CastTransport, TransportStatsProvider { private static final byte[] MAGIC = {'A', 'C', 'U', 'D'}; /** magic(4) + type(1) + msgId(4) + fragIdx(2) + fragCnt(2) + totalLen(4) */ private static final int HEADER = 17; @@ -26,7 +29,11 @@ public class UdpCastTransport implements CastTransport { private volatile boolean listening; private final Map pending = new ConcurrentHashMap<>(); + private final TransportLossStats lossStats = new TransportLossStats(); + private StreamProtectionEngine protectionEngine = StreamProtectionEngine.noop(); private CastFramingContext framingContext; + private CastSessionGate inboundSessionGate; + private static final long REASSEMBLY_TIMEOUT_MS = 3_000; public boolean isListening() { return listening && socket != null && !socket.isClosed(); @@ -71,6 +78,24 @@ public class UdpCastTransport implements CastTransport { this.framingContext = context; } + @Override + public void setInboundSessionGate(CastSessionGate gate) { + this.inboundSessionGate = gate; + } + + /** Select protection stub (default none). FEC/NACK are not active yet. */ + public void setProtectionEngine(StreamProtectionEngine engine) { + protectionEngine = engine != null ? engine : StreamProtectionEngine.noop(); + synchronized (lossStats) { + lossStats.protectionMode = protectionEngine.mode(); + } + } + + @Override + public TransportLossStats getLossStats() { + return lossStats.copy(); + } + @Override public void send(byte type, byte[] payload) throws IOException { if (peer == null) { @@ -85,6 +110,7 @@ public class UdpCastTransport implements CastTransport { } else { body = payload == null ? new byte[0] : payload; } + body = protectionEngine.onOutboundPayload(type, body); if (body.length <= MAX_RELIABLE_BYTES) { sendOnce(wireType, body); sendOnce(wireType, body); @@ -103,6 +129,9 @@ public class UdpCastTransport implements CastTransport { byte[] packet = buildPacket(type, messageId, i, fragments, body, offset, len, body.length); DatagramPacket dp = new DatagramPacket(packet, packet.length, peer); socket.send(dp); + synchronized (lossStats) { + lossStats.datagramsSent++; + } } } @@ -113,12 +142,16 @@ public class UdpCastTransport implements CastTransport { } long deadline = System.currentTimeMillis() + timeoutMs; while (System.currentTimeMillis() < deadline) { + expireStaleReassembly(); int remaining = (int) Math.max(1, deadline - System.currentTimeMillis()); socket.setSoTimeout(remaining); try { byte[] buf = new byte[CastConfig.UDP_CHUNK_SIZE + 64]; DatagramPacket dp = new DatagramPacket(buf, buf.length); socket.receive(dp); + synchronized (lossStats) { + lossStats.datagramsReceived++; + } if (peer == null) { peer = new InetSocketAddress(dp.getAddress(), dp.getPort()); } @@ -132,15 +165,38 @@ public class UdpCastTransport implements CastTransport { return null; } + private void expireStaleReassembly() { + long now = System.currentTimeMillis(); + for (Map.Entry e : pending.entrySet()) { + Reassembly r = e.getValue(); + if (r != null && now - r.startedMs > REASSEMBLY_TIMEOUT_MS) { + pending.remove(e.getKey()); + synchronized (lossStats) { + lossStats.reassemblyIncomplete++; + } + protectionEngine.onNackStub(e.getKey()); + } + } + } + private CastProtocol.Message parsePacket(byte[] data, int length) throws IOException { if (length < HEADER) { + synchronized (lossStats) { + lossStats.datagramsInvalid++; + } return null; } for (int i = 0; i < 4; i++) { if (data[i] != MAGIC[i]) { + synchronized (lossStats) { + lossStats.datagramsInvalid++; + } return null; } } + synchronized (lossStats) { + lossStats.fragmentsReceived++; + } byte type = data[4]; int messageId = readInt(data, 5); int fragIndex = readShort(data, 9); @@ -152,32 +208,68 @@ public class UdpCastTransport implements CastTransport { CastProtocol.Message raw; if (fragCount <= 1) { + byte[] body; if (totalLen != payloadLen && totalLen > 0 && totalLen <= payloadLen) { - byte[] trimmed = new byte[totalLen]; - System.arraycopy(chunk, 0, trimmed, 0, totalLen); - raw = new CastProtocol.Message(type, trimmed); + body = new byte[totalLen]; + System.arraycopy(chunk, 0, body, 0, totalLen); } else { - raw = new CastProtocol.Message(type, chunk); + body = chunk; } + body = protectionEngine.onInboundPayload(type, body); + raw = new CastProtocol.Message(type, body); return unwrapWire(raw); } - Reassembly r = pending.get(messageId); - if (r == null || r.fragCount != fragCount || r.totalLen != totalLen) { + Reassembly existing = pending.get(messageId); + if (existing != null && (existing.fragCount != fragCount || existing.totalLen != totalLen)) { + pending.remove(messageId); + synchronized (lossStats) { + lossStats.reassemblySuperseded++; + } + existing = null; + } + Reassembly r = existing; + if (r == null) { r = new Reassembly(fragCount, totalLen); pending.put(messageId, r); } - r.put(fragIndex, chunk, payloadLen); + if (!r.put(fragIndex, chunk, payloadLen)) { + synchronized (lossStats) { + lossStats.fragmentsDuplicate++; + } + return null; + } if (!r.complete()) { return null; } pending.remove(messageId); - return unwrapWire(new CastProtocol.Message(type, r.assemble())); + synchronized (lossStats) { + lossStats.reassemblyCompleted++; + } + byte[] assembled = r.assemble(); + assembled = protectionEngine.onInboundPayload(type, assembled); + return unwrapWire(new CastProtocol.Message(type, assembled)); } - private static CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException { + private CastProtocol.Message unwrapWire(CastProtocol.Message raw) throws IOException { if (raw.type == CastProtocol.MSG_WIRE_PACKET) { - return CastPacketFramer.unframe(raw.payload); + if (inboundSessionGate != null) { + CastProtoHeader hdr = CastPacketFramer.readHeader(raw.payload); + if (!inboundSessionGate.accept(hdr)) { + synchronized (lossStats) { + lossStats.wirePacketsDropped++; + } + return null; + } + } + try { + return CastPacketFramer.unframe(raw.payload); + } catch (IOException e) { + synchronized (lossStats) { + lossStats.malformedPackets++; + } + throw e; + } } return raw; } @@ -242,6 +334,7 @@ public class UdpCastTransport implements CastTransport { private static final class Reassembly { final int fragCount; final int totalLen; + final long startedMs; final byte[][] parts; final int[] sizes; int received; @@ -249,19 +342,21 @@ public class UdpCastTransport implements CastTransport { Reassembly(int fragCount, int totalLen) { this.fragCount = fragCount; this.totalLen = totalLen; + this.startedMs = System.currentTimeMillis(); this.parts = new byte[fragCount][]; this.sizes = new int[fragCount]; } - void put(int index, byte[] data, int len) { + boolean put(int index, byte[] data, int len) { if (index < 0 || index >= fragCount || parts[index] != null) { - return; + return false; } byte[] copy = new byte[len]; System.arraycopy(data, 0, copy, 0, len); parts[index] = copy; sizes[index] = len; received++; + return true; } boolean complete() { diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java index 3a6b9ee..f199bd2 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkControlAdvice.java @@ -7,7 +7,7 @@ public class NetworkControlAdvice { public boolean sendStats; public boolean sendControl; public int suggestedBitrateKbps; - public String suggestedTransport = CastConfig.TRANSPORT_TCP; + public String suggestedTransport = CastConfig.DEFAULT_TRANSPORT; public boolean switchTransport; public int congestionLevel; diff --git a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java index 74cdb70..465a8fd 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/NetworkStatsSnapshot.java @@ -27,6 +27,28 @@ public class NetworkStatsSnapshot { public float audioCapturePeakDb; public float audioCaptureRmsDb; + /** v4: UDP + app-layer loss (see {@link com.foxx.androidcast.network.transport.TransportLossStats}). */ + public long udpDatagramsRx; + public long udpDatagramsTx; + public long udpDatagramsInvalid; + public long udpFragmentsRx; + public long udpFragmentsDuplicate; + public long udpReassemblyOk; + public long udpReassemblyLost; + public long udpWireDropped; + public long udpMalformed; + public int droppedAudioFrames; + public long recvVideoPackets; + public long recvAudioPackets; + public long recvVideoSkippedKey; + public long recvVideoDecodeErrors; + public long recvAudioDecodeErrors; + public long recvAudioQueueDrops; + public long recvDamagedVideoFrames; + public String protectionMode = "NONE"; + public long fecStubPackets; + public long nackStubRequests; + public NetworkStatsSnapshot copy() { NetworkStatsSnapshot c = new NetworkStatsSnapshot(); c.sequence = sequence; @@ -49,6 +71,26 @@ public class NetworkStatsSnapshot { c.audioCaptureAudibleBlocks = audioCaptureAudibleBlocks; c.audioCapturePeakDb = audioCapturePeakDb; c.audioCaptureRmsDb = audioCaptureRmsDb; + c.udpDatagramsRx = udpDatagramsRx; + c.udpDatagramsTx = udpDatagramsTx; + c.udpDatagramsInvalid = udpDatagramsInvalid; + c.udpFragmentsRx = udpFragmentsRx; + c.udpFragmentsDuplicate = udpFragmentsDuplicate; + c.udpReassemblyOk = udpReassemblyOk; + c.udpReassemblyLost = udpReassemblyLost; + c.udpWireDropped = udpWireDropped; + c.udpMalformed = udpMalformed; + c.droppedAudioFrames = droppedAudioFrames; + c.recvVideoPackets = recvVideoPackets; + c.recvAudioPackets = recvAudioPackets; + c.recvVideoSkippedKey = recvVideoSkippedKey; + c.recvVideoDecodeErrors = recvVideoDecodeErrors; + c.recvAudioDecodeErrors = recvAudioDecodeErrors; + c.recvAudioQueueDrops = recvAudioQueueDrops; + c.recvDamagedVideoFrames = recvDamagedVideoFrames; + c.protectionMode = protectionMode; + c.fecStubPackets = fecStubPackets; + c.nackStubRequests = nackStubRequests; return c; } } diff --git a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java index 8a24de6..052fb67 100644 --- a/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java +++ b/app/src/main/java/com/foxx/androidcast/network/control/PassThroughNetworkControlPlane.java @@ -33,7 +33,7 @@ public class PassThroughNetworkControlPlane implements NetworkControlPlane { outbound.sendStats = nowMs - lastStatsMs >= STATS_INTERVAL_MS; outbound.sendControl = false; outbound.suggestedBitrateKbps = 0; - outbound.suggestedTransport = CastConfig.TRANSPORT_TCP; + outbound.suggestedTransport = CastConfig.DEFAULT_TRANSPORT; outbound.switchTransport = false; outbound.congestionLevel = 0; if (outbound.sendStats) { diff --git a/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java b/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java new file mode 100644 index 0000000..03014e0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/quic/CronetEngineProvider.java @@ -0,0 +1,50 @@ +package com.foxx.androidcast.network.quic; + +import android.content.Context; + +import com.google.android.gms.net.CronetProviderInstaller; +import com.google.android.gms.tasks.Tasks; + +import org.chromium.net.CronetEngine; +import org.chromium.net.ExperimentalCronetEngine; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** Shared Cronet engine with QUIC / HTTP3 enabled for experimental cast transport. */ +public final class CronetEngineProvider { + private static final AtomicReference ENGINE = new AtomicReference<>(); + + private CronetEngineProvider() {} + + public static CronetEngine getEngine(Context context) throws Exception { + CronetEngine existing = ENGINE.get(); + if (existing != null) { + return existing; + } + synchronized (CronetEngineProvider.class) { + existing = ENGINE.get(); + if (existing != null) { + return existing; + } + Context app = context.getApplicationContext(); + Tasks.await(CronetProviderInstaller.installProvider(app), 30, TimeUnit.SECONDS); + ExperimentalCronetEngine.Builder builder = + new ExperimentalCronetEngine.Builder(app) + .enableQuic(true) + .enableHttp2(true) + .setStoragePath(app.getCacheDir().getAbsolutePath()) + .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 256 * 1024); + CronetEngine built = builder.build(); + ENGINE.set(built); + return built; + } + } + + public static void shutdown() { + CronetEngine e = ENGINE.getAndSet(null); + if (e != null) { + e.shutdown(); + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java new file mode 100644 index 0000000..f706009 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/quic/QuicCronetCastTransport.java @@ -0,0 +1,138 @@ +package com.foxx.androidcast.network.quic; + +import android.content.Context; +import android.util.Log; + +import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.CastFramingContext; +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastTransport; +import com.foxx.androidcast.network.TcpCastTransport; +import com.foxx.androidcast.network.UdpCastTransport; + +import org.chromium.net.CronetEngine; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Experimental QUIC cast transport (Cronet stack). + *

+ * Cronet on Android is a QUIC/HTTP3 client only. This transport uses: + *

    + *
  • {@link CronetEngine} with QUIC enabled (shared via {@link CronetEngineProvider})
  • + *
  • Framed cast messages on {@link CastConfig#QUIC_PORT} over UDP (same as legacy ACUD framing)
  • + *
  • TCP fallback on the same port if UDP bind fails
  • + *
+ * A future revision can move the data plane to Cronet {@code BidirectionalStream} once an HTTP/3 + * terminator is available on the receiver. + */ +public final class QuicCronetCastTransport implements CastTransport { + private static final String TAG = "QuicCronetTransport"; + + private final Context appContext; + private final UdpCastTransport udp = new UdpCastTransport(); + private final TcpCastTransport tcpFallback = new TcpCastTransport(); + private final AtomicBoolean usingTcp = new AtomicBoolean(false); + private CronetEngine engine; + + public QuicCronetCastTransport(Context context) { + this.appContext = context.getApplicationContext(); + } + + private CastTransport active() { + return usingTcp.get() ? tcpFallback : udp; + } + + @Override + public void setFramingContext(CastFramingContext context) { + udp.setFramingContext(context); + tcpFallback.setFramingContext(context); + } + + @Override + public void setInboundSessionGate(com.foxx.androidcast.network.CastSessionGate gate) { + udp.setInboundSessionGate(gate); + tcpFallback.setInboundSessionGate(gate); + } + + private void ensureCronet() { + if (engine != null) { + return; + } + try { + engine = CronetEngineProvider.getEngine(appContext); + Log.i(TAG, "Cronet engine ready (QUIC/HTTP3 enabled for experimental transport)"); + } catch (Exception e) { + Log.w(TAG, "Cronet engine unavailable: " + e.getMessage()); + } + } + + @Override + public void prepareListen(int port) throws IOException { + ensureCronet(); + int quicPort = port > 0 ? port : CastConfig.QUIC_PORT; + try { + udp.prepareListen(quicPort); + usingTcp.set(false); + Log.i(TAG, "QUIC listen UDP :" + quicPort); + } catch (IOException e) { + Log.w(TAG, "QUIC UDP listen failed, TCP fallback :" + quicPort, e); + tcpFallback.prepareListen(quicPort); + usingTcp.set(true); + } + } + + @Override + public void acceptClient() throws IOException { + active().acceptClient(); + } + + @Override + public void listen(int port) throws IOException { + prepareListen(port); + acceptClient(); + } + + @Override + public void connect(String host, int port) throws IOException { + ensureCronet(); + int quicPort = port > 0 ? port : CastConfig.QUIC_PORT; + try { + udp.connect(host, quicPort); + usingTcp.set(false); + Log.i(TAG, "QUIC connect UDP " + host + ":" + quicPort); + } catch (IOException e) { + Log.w(TAG, "QUIC UDP connect failed, TCP fallback", e); + tcpFallback.connect(host, quicPort); + usingTcp.set(true); + } + } + + @Override + public void send(byte type, byte[] payload) throws IOException { + active().send(type, payload); + } + + @Override + public CastProtocol.Message receive(int timeoutMs) throws IOException { + return active().receive(timeoutMs); + } + + @Override + public String getModeLabel() { + String base = usingTcp.get() ? "QUIC(exp/TCP)" : "QUIC(exp/UDP)"; + return engine != null ? base + "+Cronet" : base; + } + + @Override + public void releaseConnection() { + active().releaseConnection(); + } + + @Override + public void close() { + udp.close(); + tcpFallback.close(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java new file mode 100644 index 0000000..dffb504 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionEngine.java @@ -0,0 +1,95 @@ +package com.foxx.androidcast.network.transport; + +/** + * Hook for future FEC / NACK over UDP. Current builds use {@link #noop()} only. + */ +public interface StreamProtectionEngine { + StreamProtectionMode mode(); + + /** Called before UDP payload is fragmented (outbound). */ + byte[] onOutboundPayload(byte type, byte[] payload); + + /** Called when a reassembled UDP message is ready (inbound). */ + byte[] onInboundPayload(byte type, byte[] payload); + + /** Future: process FEC repair block (stub). */ + default void onFecBlockStub(byte[] block) { + } + + /** Future: sender handles NACK for messageId (stub). */ + default void onNackStub(int messageId) { + } + + static StreamProtectionEngine noop() { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.NONE; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + }; + } + + /** Placeholder for upcoming FEC — counts invocations only. */ + static StreamProtectionEngine fecStub(TransportLossStats stats) { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.FEC_STUB; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + if (stats != null) { + synchronized (stats) { + stats.fecPacketsStub++; + } + } + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + }; + } + + /** Placeholder for upcoming NACK — no retransmit yet. */ + static StreamProtectionEngine nackStub(TransportLossStats stats) { + return new StreamProtectionEngine() { + @Override + public StreamProtectionMode mode() { + return StreamProtectionMode.NACK_STUB; + } + + @Override + public byte[] onOutboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public byte[] onInboundPayload(byte type, byte[] payload) { + return payload; + } + + @Override + public void onNackStub(int messageId) { + if (stats != null) { + synchronized (stats) { + stats.nackRequestsStub++; + } + } + } + }; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java new file mode 100644 index 0000000..142561c --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/StreamProtectionMode.java @@ -0,0 +1,26 @@ +package com.foxx.androidcast.network.transport; + +/** Planned LAN cast reliability features (stubs until implemented). */ +public enum StreamProtectionMode { + NONE, + /** Forward-error correction over UDP (not implemented). */ + FEC_STUB, + /** Negative acknowledgement / retransmit requests (not implemented). */ + NACK_STUB; + + public boolean isStub() { + return this == FEC_STUB || this == NACK_STUB; + } + + public String overlayLabel() { + switch (this) { + case FEC_STUB: + return "FEC (stub)"; + case NACK_STUB: + return "NACK (stub)"; + case NONE: + default: + return "none"; + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java new file mode 100644 index 0000000..ddfa402 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStats.java @@ -0,0 +1,97 @@ +package com.foxx.androidcast.network.transport; + +/** + * Per-transport loss / damage counters (UDP datagrams, reassembly, app-layer drops). + * Thread-safe increments; copy for stats exchange. + */ +public final class TransportLossStats { + public long datagramsSent; + public long datagramsReceived; + public long datagramsInvalid; + public long fragmentsReceived; + public long fragmentsDuplicate; + public long reassemblyCompleted; + public long reassemblyIncomplete; + public long reassemblySuperseded; + public long wirePacketsDropped; + public long malformedPackets; + + public int sendDroppedVideoP; + public int sendDroppedAudio; + public int sendQueueDepth; + + public long recvVideoPackets; + public long recvAudioPackets; + public long recvVideoSkippedAwaitingKey; + public long recvVideoDecodeErrors; + public long recvAudioDecodeErrors; + public long recvAudioQueueDrops; + public long recvDamagedVideoFrames; + + public StreamProtectionMode protectionMode = StreamProtectionMode.NONE; + public long fecPacketsStub; + public long nackRequestsStub; + public long nackRetransmitsStub; + + public synchronized void addFrom(TransportLossStats other) { + if (other == null) { + return; + } + datagramsSent += other.datagramsSent; + datagramsReceived += other.datagramsReceived; + datagramsInvalid += other.datagramsInvalid; + fragmentsReceived += other.fragmentsReceived; + fragmentsDuplicate += other.fragmentsDuplicate; + reassemblyCompleted += other.reassemblyCompleted; + reassemblyIncomplete += other.reassemblyIncomplete; + reassemblySuperseded += other.reassemblySuperseded; + wirePacketsDropped += other.wirePacketsDropped; + malformedPackets += other.malformedPackets; + sendDroppedVideoP += other.sendDroppedVideoP; + sendDroppedAudio += other.sendDroppedAudio; + recvVideoPackets += other.recvVideoPackets; + recvAudioPackets += other.recvAudioPackets; + recvVideoSkippedAwaitingKey += other.recvVideoSkippedAwaitingKey; + recvVideoDecodeErrors += other.recvVideoDecodeErrors; + recvAudioDecodeErrors += other.recvAudioDecodeErrors; + recvAudioQueueDrops += other.recvAudioQueueDrops; + recvDamagedVideoFrames += other.recvDamagedVideoFrames; + fecPacketsStub += other.fecPacketsStub; + nackRequestsStub += other.nackRequestsStub; + nackRetransmitsStub += other.nackRetransmitsStub; + } + + public synchronized TransportLossStats copy() { + TransportLossStats c = new TransportLossStats(); + c.datagramsSent = datagramsSent; + c.datagramsReceived = datagramsReceived; + c.datagramsInvalid = datagramsInvalid; + c.fragmentsReceived = fragmentsReceived; + c.fragmentsDuplicate = fragmentsDuplicate; + c.reassemblyCompleted = reassemblyCompleted; + c.reassemblyIncomplete = reassemblyIncomplete; + c.reassemblySuperseded = reassemblySuperseded; + c.wirePacketsDropped = wirePacketsDropped; + c.malformedPackets = malformedPackets; + c.sendDroppedVideoP = sendDroppedVideoP; + c.sendDroppedAudio = sendDroppedAudio; + c.sendQueueDepth = sendQueueDepth; + c.recvVideoPackets = recvVideoPackets; + c.recvAudioPackets = recvAudioPackets; + c.recvVideoSkippedAwaitingKey = recvVideoSkippedAwaitingKey; + c.recvVideoDecodeErrors = recvVideoDecodeErrors; + c.recvAudioDecodeErrors = recvAudioDecodeErrors; + c.recvAudioQueueDrops = recvAudioQueueDrops; + c.recvDamagedVideoFrames = recvDamagedVideoFrames; + c.protectionMode = protectionMode; + c.fecPacketsStub = fecPacketsStub; + c.nackRequestsStub = nackRequestsStub; + c.nackRetransmitsStub = nackRetransmitsStub; + return c; + } + + /** Estimated UDP loss: incomplete reassembly + invalid, not double-counting fragments. */ + public synchronized long estimatedUdpLossEvents() { + return reassemblyIncomplete + reassemblySuperseded + datagramsInvalid + malformedPackets; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java new file mode 100644 index 0000000..ee90f8b --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportLossStatsBridge.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.network.transport; + +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; + +/** Copies {@link TransportLossStats} into {@link NetworkStatsSnapshot} for overlay / heartbeat. */ +public final class TransportLossStatsBridge { + private TransportLossStatsBridge() {} + + public static void applyTo(NetworkStatsSnapshot s, TransportLossStats loss) { + if (s == null || loss == null) { + return; + } + synchronized (loss) { + s.udpDatagramsRx = loss.datagramsReceived; + s.udpDatagramsTx = loss.datagramsSent; + s.udpDatagramsInvalid = loss.datagramsInvalid; + s.udpFragmentsRx = loss.fragmentsReceived; + s.udpFragmentsDuplicate = loss.fragmentsDuplicate; + s.udpReassemblyOk = loss.reassemblyCompleted; + s.udpReassemblyLost = loss.reassemblyIncomplete + loss.reassemblySuperseded; + s.udpWireDropped = loss.wirePacketsDropped; + s.udpMalformed = loss.malformedPackets; + s.droppedPFrames = Math.max(s.droppedPFrames, loss.sendDroppedVideoP); + s.droppedAudioFrames = loss.sendDroppedAudio; + s.encodeQueueDepth = Math.max(s.encodeQueueDepth, loss.sendQueueDepth); + s.recvVideoPackets = loss.recvVideoPackets; + s.recvAudioPackets = loss.recvAudioPackets; + s.recvVideoSkippedKey = loss.recvVideoSkippedAwaitingKey; + s.recvVideoDecodeErrors = loss.recvVideoDecodeErrors; + s.recvAudioDecodeErrors = loss.recvAudioDecodeErrors; + s.recvAudioQueueDrops = loss.recvAudioQueueDrops; + s.recvDamagedVideoFrames = loss.recvDamagedVideoFrames; + s.protectionMode = loss.protectionMode.name(); + s.fecStubPackets = loss.fecPacketsStub; + s.nackStubRequests = loss.nackRequestsStub; + long lossEvents = loss.estimatedUdpLossEvents(); + if (loss.datagramsReceived > 0 && lossEvents > 0) { + s.lossRatio = Math.min(1f, lossEvents / (float) loss.datagramsReceived); + } + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java new file mode 100644 index 0000000..002e0d9 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/transport/TransportStatsProvider.java @@ -0,0 +1,6 @@ +package com.foxx.androidcast.network.transport; + +/** Transports that expose {@link TransportLossStats}. */ +public interface TransportStatsProvider { + TransportLossStats getLossStats(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java new file mode 100644 index 0000000..79cf591 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcCastTransport.java @@ -0,0 +1,62 @@ +package com.foxx.androidcast.network.webrtc; + +import com.foxx.androidcast.network.CastProtocol; +import com.foxx.androidcast.network.CastTransport; + +import java.io.IOException; + +/** + * Future WebRTC transport (libwebrtc or pion-backed). Not wired yet — use {@link #createStub()}. + */ +public interface WebRtcCastTransport extends CastTransport { + + /** Signaling / ICE configuration for upcoming integration. */ + interface SignalingConfig { + String getSignalingUrl(); + + String[] getIceServers(); + } + + /** Attach when integrating real PeerConnection / DataChannel. */ + void attachPeer(WebRtcPeerAdapter peer); + + /** + * Placeholder until libwebrtc or pion transport is connected. + */ + static WebRtcCastTransport createStub() { + return new WebRtcCastTransportStub(); + } + + final class WebRtcCastTransportStub implements WebRtcCastTransport { + @Override + public void attachPeer(WebRtcPeerAdapter peer) {} + + @Override + public void listen(int port) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public void connect(String host, int port) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public void send(byte type, byte[] payload) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public CastProtocol.Message receive(int timeoutMs) throws IOException { + throw new UnsupportedOperationException("WebRTC transport is not implemented yet"); + } + + @Override + public String getModeLabel() { + return "WebRTC (stub)"; + } + + @Override + public void close() {} + } +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java new file mode 100644 index 0000000..fdacb5e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcPeerAdapter.java @@ -0,0 +1,37 @@ +package com.foxx.androidcast.network.webrtc; + +/** + * Abstraction over future {@code org.webrtc.PeerConnection} or pion peer APIs. + * Implementations will map cast frames to SCTP DataChannel or encoded tracks. + */ +public interface WebRtcPeerAdapter { + + interface Observer { + void onIceCandidate(String sdpMid, int sdpMLineIndex, String candidate); + + void onConnectionStateChanged(WebRtcConnectionState state); + + void onDataChannelMessage(byte[] payload); + + void onEncodedVideoFrame(long ptsUs, boolean keyFrame, byte[] data); + } + + enum WebRtcConnectionState { + NEW, CONNECTING, CONNECTED, DISCONNECTED, FAILED, CLOSED + } + + void setObserver(Observer observer); + + void createOffer(); + + void createAnswer(String remoteDescription); + + void setRemoteDescription(String sdp, boolean isOffer); + + void addIceCandidate(String sdpMid, int sdpMLineIndex, String candidate); + + /** Send opaque cast protocol bytes on a labeled DataChannel. */ + void sendDataChannel(byte[] payload); + + void close(); +} diff --git a/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java new file mode 100644 index 0000000..8aec38f --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/network/webrtc/WebRtcTransportFactory.java @@ -0,0 +1,19 @@ +package com.foxx.androidcast.network.webrtc; + +import com.foxx.androidcast.CastConfig; +import com.foxx.androidcast.network.CastTransport; + +/** + * Factory for WebRTC-backed cast transport (stub today; swap implementation for libwebrtc / pion). + */ +public final class WebRtcTransportFactory { + private WebRtcTransportFactory() {} + + public static boolean isTransportId(String transport) { + return CastConfig.TRANSPORT_WEBRTC.equals(transport); + } + + public static CastTransport create() { + return WebRtcCastTransport.createStub(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java index 9abe17d..79fb2f5 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/AudioDecoder.java @@ -7,6 +7,7 @@ import android.media.AudioManager; import android.media.AudioTrack; import android.media.MediaCodec; import android.media.MediaFormat; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; import android.util.Log; @@ -36,6 +37,7 @@ public class AudioDecoder { public long pcmSilentBlocks; public long pcmAudibleBlocks; public long decodeErrors; + public long queueDrops; public float lastPeak; public float lastRms; } @@ -43,7 +45,8 @@ public class AudioDecoder { private final Context appContext; private final HandlerThread workerThread = new HandlerThread("AudioDecoderCtrl"); private Handler worker; - private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(128); + private final LinkedBlockingQueue inputQueue = new LinkedBlockingQueue<>(256); + private long playbackEpochUs = -1; private final Stats stats = new Stats(); private MediaCodec codec; @@ -130,6 +133,7 @@ public class AudioDecoder { private void startDrainThread() { drainThread = new Thread(this::drainLoop, "AudioDecoderDrain"); + drainThread.setPriority(Thread.MAX_PRIORITY - 2); drainThread.start(); } @@ -138,7 +142,7 @@ public class AudioDecoder { ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; int minBuf = AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); - int bufSize = Math.max(minBuf * 8, 65536); + int bufSize = Math.max(minBuf * 16, 131072); track = new AudioTrack.Builder() .setAudioAttributes(new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) @@ -164,8 +168,11 @@ public class AudioDecoder { } stats.framesQueued++; if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { + stats.queueDrops++; inputQueue.poll(); - inputQueue.offer(new PendingFrame(ptsUs, data.clone())); + if (!inputQueue.offer(new PendingFrame(ptsUs, data.clone()))) { + stats.queueDrops++; + } } } @@ -241,7 +248,8 @@ public class AudioDecoder { } else { stats.pcmAudibleBlocks++; } - int written = writePcm(pcm); + pacePlayback(info.presentationTimeUs); + int written = writePcmBlocking(pcm); if (written > 0) { stats.pcmBytesWritten += written; if (renderCallback != null) { @@ -253,16 +261,42 @@ public class AudioDecoder { } } - private int writePcm(byte[] pcm) { + private void pacePlayback(long ptsUs) { + long nowUs = System.nanoTime() / 1000L; + if (playbackEpochUs < 0) { + playbackEpochUs = nowUs - ptsUs; + return; + } + long dueUs = playbackEpochUs + ptsUs; + long aheadUs = dueUs - nowUs; + if (aheadUs > 120_000L) { + try { + Thread.sleep(aheadUs / 1000L, (int) ((aheadUs % 1000L) * 1000L)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private int writePcmBlocking(byte[] pcm) { int total = 0; int offset = 0; - while (offset < pcm.length) { + int idle = 0; + while (offset < pcm.length && idle < 80) { int w = track.write(pcm, offset, pcm.length - offset); - if (w <= 0) { + if (w > 0) { + total += w; + offset += w; + idle = 0; + continue; + } + idle++; + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); break; } - total += w; - offset += w; } return total; } @@ -275,7 +309,11 @@ public class AudioDecoder { } } - public void release() { + /** Stops playback immediately and drops queued AAC (sender ended cast). */ + public void flushAndStop() { + draining = false; + configured = false; + inputQueue.clear(); final Object lock = new Object(); worker.post(() -> { releaseOnWorker(); @@ -285,21 +323,26 @@ public class AudioDecoder { }); synchronized (lock) { try { - lock.wait(2000); + lock.wait(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } + public void release() { + flushAndStop(); + } + private void releaseOnWorker() { draining = false; configured = false; + playbackEpochUs = -1; inputQueue.clear(); if (drainThread != null) { drainThread.interrupt(); try { - drainThread.join(1500); + drainThread.join(400); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } @@ -315,12 +358,26 @@ public class AudioDecoder { } if (track != null) { try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + track.pause(); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + track.flush(); + } track.stop(); } catch (Exception ignored) { } track.release(); track = null; } + abandonAudioFocus(); + } + + private void abandonAudioFocus() { + AudioManager am = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + if (am != null) { + am.abandonAudioFocus(null); + } } public void releaseAndQuit() { diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java index 6b93eea..5fd1ac7 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverCastService.java @@ -22,7 +22,11 @@ import com.foxx.androidcast.R; import com.foxx.androidcast.CastConfig; import com.foxx.androidcast.diagnostics.CastDiagnosticsFormatter; import com.foxx.androidcast.discovery.DiscoveryManager; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportLossStatsBridge; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.stats.SessionStatsRecorder; +import com.foxx.androidcast.stats.SessionStatsStore; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; import java.util.Arrays; @@ -80,7 +84,10 @@ public class ReceiverCastService extends Service { private CastSettings remoteCastSettings; private NetworkStatsSnapshot lastLocalStats; private boolean streamIdle; + private volatile boolean castEnded; private long lastVideoFrameMs; + private final TransportLossStats transportLoss = new TransportLossStats(); + private SessionStatsRecorder sessionStatsRecorder; private final Runnable idleWatchdog = new Runnable() { @Override @@ -136,6 +143,7 @@ public class ReceiverCastService extends Service { public void onCreate() { super.onCreate(); CastNotifications.createChannels(this); + SessionStatsStore.pruneOnAppStart(this); mainHandler.post(idleWatchdog); } @@ -175,7 +183,11 @@ public class ReceiverCastService extends Service { private void restartListeningLocked(String pin) { stopSessionOnly(); + castEnded = false; resetStreamState(); + pendingWidth = 0; + pendingHeight = 0; + playbackLaunched = false; phase = Phase.LISTENING; acquireWakeLock(); startForeground(CastNotifications.ID_RECEIVER, @@ -220,10 +232,10 @@ public class ReceiverCastService extends Service { @Override public void onVideoFrame(long ptsUs, boolean keyFrame, byte[] data) { - if (reconfiguring) { - return; - } if (awaitingKeyframe && !keyFrame) { + synchronized (transportLoss) { + transportLoss.recvVideoSkippedAwaitingKey++; + } return; } if (keyFrame) { @@ -237,7 +249,10 @@ public class ReceiverCastService extends Service { onStreamResumedAfterIdle(); } streamMetrics.onVideoPacket(keyFrame, frameBytes); - if (videoDecoder != null && !reconfiguring) { + synchronized (transportLoss) { + transportLoss.recvVideoPackets++; + } + if (videoDecoder != null) { videoDecoder.queueFrame(ptsUs, keyFrame, data); } }); @@ -271,6 +286,9 @@ public class ReceiverCastService extends Service { @Override public void onAudioFrame(long ptsUs, byte[] data) { + if (castEnded) { + return; + } final int frameBytes = data != null ? data.length : 0; final boolean accept; synchronized (ReceiverCastService.this) { @@ -279,7 +297,17 @@ public class ReceiverCastService extends Service { if (accept && audioDecoder != null) { audioDecoder.queueFrame(ptsUs, data); } - mainHandler.post(() -> streamMetrics.onAudioPacket(frameBytes)); + mainHandler.post(() -> { + streamMetrics.onAudioPacket(frameBytes); + synchronized (transportLoss) { + transportLoss.recvAudioPackets++; + } + }); + } + + @Override + public void onCastEnded() { + mainHandler.postAtFrontOfQueue(ReceiverCastService.this::stopAudioPlaybackImmediate); } @Override @@ -304,6 +332,13 @@ public class ReceiverCastService extends Service { private void onSenderConnected(String senderName, CastSettings remoteSettings, String videoMime) { phase = Phase.CONNECTED; + castEnded = false; + if (sessionStatsRecorder == null) { + sessionStatsRecorder = new SessionStatsRecorder("receiver", settings.getTransport()); + sessionStatsRecorder.setClientCount(1); + } + sessionStatsRecorder.mergeSettings(remoteSettings); + sessionStatsRecorder.setCodecs(videoMime, "AAC"); negotiatedVideoMime = videoMime; remoteCastSettings = remoteSettings; streamIdle = false; @@ -332,25 +367,32 @@ public class ReceiverCastService extends Service { }, "AudioConsent").start(); } + private void stopAudioPlaybackImmediate() { + castEnded = true; + if (audioDecoder != null) { + audioDecoder.flushAndStop(); + audioDecoder = new AudioDecoder(this); + audioDecoder.setRenderCallback((pcmBytes, levels) -> streamMetrics.onAudioRendered( + pcmBytes, levels.peak, levels.rms, levels.silent)); + } + Log.i(TAG, "Audio playback stopped"); + } + private void onSenderDisconnected() { + stopAudioPlaybackImmediate(); phase = Phase.LISTENING; audioAccepted = null; remoteCastSettings = null; streamIdle = false; lastVideoFrameMs = 0; negotiatedVideoMime = null; - enterAwaitingMode(R.string.playback_stream_ended, true); - updateStatus(getString(R.string.waiting_sender)); - closePlaybackUi(); - openReceiverSetupUi(); - Log.i(TAG, "Sender disconnected"); - } - - private void openReceiverSetupUi() { - Intent intent = new Intent(this, ReceiverActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP - | Intent.FLAG_ACTIVITY_SINGLE_TOP); - startActivity(intent); + pendingWidth = 0; + pendingHeight = 0; + hasPendingConfig = false; + resetDecoderState(); + enterAwaitingMode(R.string.playback_listening, true); + updateStatus(getString(R.string.receiver_ready, activePin, settings.getTransport().toUpperCase())); + Log.i(TAG, "Sender disconnected — listening again"); } private void checkStreamIdle() { @@ -360,6 +402,9 @@ public class ReceiverCastService extends Service { long idleMs = System.currentTimeMillis() - lastVideoFrameMs; if (idleMs >= CastConfig.STREAM_IDLE_TIMEOUT_MS) { Log.i(TAG, "Stream idle " + idleMs + "ms — awaiting"); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.noteIdleTimeout(); + } enterAwaitingMode(R.string.playback_awaiting_stream, false); } } @@ -459,6 +504,9 @@ public class ReceiverCastService extends Service { decoderActive = false; notifyPlaybackDimensions(pendingWidth, pendingHeight); scheduleDecoderConfigureRetries(); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.setResolution(pendingWidth, pendingHeight); + } Log.i(TAG, "Stream config " + pendingWidth + "x" + pendingHeight); } @@ -504,9 +552,11 @@ public class ReceiverCastService extends Service { return; } discovery = new DiscoveryManager(this); - discovery.startAnnouncing(android.os.Build.MODEL, com.foxx.androidcast.CastConfig.CAST_PORT, + com.foxx.androidcast.network.CastTransportFactory.init(this); + int announcePort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); + discovery.startAnnouncing(android.os.Build.MODEL, announcePort, settings.getTransport(), true); - Log.i(TAG, "Discovery announcing on port " + com.foxx.androidcast.CastConfig.CAST_PORT); + Log.i(TAG, "Discovery announcing on port " + announcePort); } private void stopSessionOnly() { @@ -545,6 +595,7 @@ public class ReceiverCastService extends Service { if (!hasPendingConfig || attachedSurface == null || !attachedSurface.isValid()) { Log.i(TAG, "Decoder waiting: config=" + hasPendingConfig + " surface=" + (attachedSurface != null && attachedSurface.isValid())); + reconfiguring = false; return; } if (decoderActive && decoderWidth == pendingWidth && decoderHeight == pendingHeight) { @@ -569,16 +620,24 @@ public class ReceiverCastService extends Service { public void onFrameRendered() { ReceiverCastService.this.onFrameRendered(); } + + @Override + public void onOutputSizeChanged(int width, int height) { + ReceiverCastService.this.onDecoderOutputSize(width, height); + } }); decoderActive = true; reconfiguring = false; decoderWidth = pendingWidth; decoderHeight = pendingHeight; - updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); + updateStatus(getString(R.string.playback_awaiting_stream)); broadcastStreamStarted(pendingWidth, pendingHeight); Log.i(TAG, "Decoder started " + pendingWidth + "x" + pendingHeight); } catch (Exception e) { decoderActive = false; + synchronized (transportLoss) { + transportLoss.recvVideoDecodeErrors++; + } Log.e(TAG, "Video configure failed", e); updateStatus("Video: " + e.getMessage()); } @@ -589,6 +648,9 @@ public class ReceiverCastService extends Service { streamMetrics.onRenderedFrame(); Log.i(TAG, "First frame on screen"); streamIdle = false; + if (pendingWidth > 0 && pendingHeight > 0) { + updateStatus(getString(R.string.playing_resolution, pendingWidth, pendingHeight)); + } broadcastVideoRendering(); }); } @@ -597,8 +659,27 @@ public class ReceiverCastService extends Service { mainHandler.post(() -> streamMetrics.onRenderedFrame()); } + private void onDecoderOutputSize(int width, int height) { + mainHandler.post(() -> { + if (width <= 0 || height <= 0) { + return; + } + pendingWidth = width; + pendingHeight = height; + decoderWidth = width; + decoderHeight = height; + notifyPlaybackDimensions(width, height); + Log.i(TAG, "Decoder output size " + width + "x" + height); + }); + } + private void enrichReceiverStats(NetworkStatsSnapshot s, long nowMs) { s.encodeFps = streamMetrics.getDecodeFps(); + TransportLossStats snap = buildReceiverLossSnapshot(); + TransportLossStatsBridge.applyTo(s, snap); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.sampleNetwork(s, streamMetrics.getLastPeer()); + } NetworkStatsSnapshot peer = streamMetrics.getLastPeer(); CastSettings tune = remoteCastSettings != null ? remoteCastSettings : settings; com.foxx.androidcast.CastTuningEngine.EffectiveParams eff = @@ -634,6 +715,34 @@ public class ReceiverCastService extends Service { PlaybackViewState.getZoomPercent()); } + private TransportLossStats buildReceiverLossSnapshot() { + TransportLossStats snap = session != null + ? session.getTransportLossStats() : new TransportLossStats(); + synchronized (transportLoss) { + snap.recvVideoSkippedAwaitingKey = transportLoss.recvVideoSkippedAwaitingKey; + snap.recvVideoPackets = transportLoss.recvVideoPackets; + snap.recvAudioPackets = transportLoss.recvAudioPackets; + snap.recvVideoDecodeErrors = transportLoss.recvVideoDecodeErrors; + } + if (audioDecoder != null) { + AudioDecoder.Stats ast = audioDecoder.getStats(); + snap.recvAudioDecodeErrors = ast.decodeErrors; + snap.recvAudioQueueDrops = ast.queueDrops; + } + return snap; + } + + private void finishSessionStatsAsync() { + final SessionStatsRecorder recorder = sessionStatsRecorder; + sessionStatsRecorder = null; + if (recorder == null) { + return; + } + recorder.setResolution(pendingWidth, pendingHeight); + recorder.mergeLoss(buildReceiverLossSnapshot()); + new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start(); + } + private void attachSurface(Surface surface) { if (surface == null || !surface.isValid()) { Log.w(TAG, "attachSurface: invalid surface"); @@ -732,6 +841,7 @@ public class ReceiverCastService extends Service { } private void releaseAll() { + finishSessionStatsAsync(); phase = Phase.IDLE; resetStreamState(); playbackLaunched = false; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java index ffbc216..ab92e8b 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverPlaybackActivity.java @@ -9,8 +9,9 @@ import android.os.Handler; import android.os.IBinder; import android.os.Looper; import android.os.RemoteException; -import android.view.SurfaceHolder; -import android.view.SurfaceView; +import android.graphics.SurfaceTexture; +import android.view.Surface; +import android.view.TextureView; import android.view.View; import android.view.WindowManager; import android.animation.AnimatorSet; @@ -27,6 +28,7 @@ import com.foxx.androidcast.ICastReceiverService; import com.foxx.androidcast.ICastStatusCallback; import com.foxx.androidcast.R; import com.foxx.androidcast.ui.AspectRatioFrameLayout; +import com.foxx.androidcast.ui.PlaybackTextureTransform; import com.foxx.androidcast.ui.ZoomPanContainer; import androidx.core.text.HtmlCompat; @@ -35,7 +37,7 @@ import androidx.core.text.HtmlCompat; * Fullscreen display: dancing-robot overlay while listening / waiting; * video surface shown only after the first decoded frame. */ -public class ReceiverPlaybackActivity extends AppCompatActivity implements SurfaceHolder.Callback { +public class ReceiverPlaybackActivity extends AppCompatActivity { public static final String EXTRA_WIDTH = "video_width"; public static final String EXTRA_HEIGHT = "video_height"; public static final String EXTRA_FINISH = "finish_playback"; @@ -43,13 +45,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa public static final String EXTRA_AWAITING_MESSAGE = "awaiting_message"; private AspectRatioFrameLayout aspectContainer; - private SurfaceView surfaceView; + private TextureView textureView; private View awaitingOverlay; private TextView diagnosticsText; private ImageView robotView; private TextView awaitingMessage; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private SurfaceHolder surfaceHolder; + private SurfaceTexture surfaceTexture; private int videoWidth; private int videoHeight; private boolean streamRendering; @@ -78,6 +80,11 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa runOnUiThread(() -> { streamRendering = true; hideAwaitingOverlay(); + ZoomPanContainer zoom = findViewById(R.id.zoom_container); + if (zoom != null) { + zoom.resetTransform(); + } + applyTextureFitCenter(); updateDiagnosticsVisibility(); }); } @@ -138,15 +145,13 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa ZoomPanContainer zoomContainer = findViewById(R.id.zoom_container); aspectContainer = findViewById(R.id.aspect_container); - surfaceView = findViewById(R.id.surface_playback); + textureView = findViewById(R.id.surface_playback); zoomContainer.setOnZoomChangedListener(PlaybackViewState::setZoomPercent); awaitingOverlay = findViewById(R.id.overlay_awaiting); diagnosticsText = findViewById(R.id.text_diagnostics); robotView = findViewById(R.id.image_robot); awaitingMessage = findViewById(R.id.text_awaiting_message); - surfaceHolder = surfaceView.getHolder(); - surfaceHolder.addCallback(this); - surfaceView.setZOrderMediaOverlay(false); + textureView.setSurfaceTextureListener(surfaceListener); applyAspectRatio(); if (getIntent().getBooleanExtra(EXTRA_SHOW_AWAITING, true)) { @@ -319,12 +324,24 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa videoWidth = width; videoHeight = height; applyAspectRatio(); - if (surfaceHolder != null && surfaceHolder.getSurface() != null && surfaceHolder.getSurface().isValid()) { - surfaceHolder.setFixedSize(videoWidth, videoHeight); + applyTextureFitCenter(); + if (surfaceTexture != null) { + surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight); attachSurfaceIfReady(); } } + private void applyTextureFitCenter() { + if (textureView == null || videoWidth <= 0 || videoHeight <= 0) { + return; + } + if (textureView.getWidth() == 0 || textureView.getHeight() == 0) { + textureView.post(this::applyTextureFitCenter); + return; + } + PlaybackTextureTransform.applyFitCenter(textureView, videoWidth, videoHeight); + } + private void applyFullscreen() { getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); getWindow().getDecorView().setSystemUiVisibility( @@ -340,42 +357,49 @@ public class ReceiverPlaybackActivity extends AppCompatActivity implements Surfa } private void applyAspectRatio() { - if (videoWidth > 0 && videoHeight > 0) { - aspectContainer.setAspectRatio(videoWidth, videoHeight); - } + // Full-screen TextureView; letterboxing via transform only (avoids double-scaling). + aspectContainer.setAspectRatio(0, 0); } - @Override - public void surfaceCreated(SurfaceHolder holder) { - if (videoWidth > 0 && videoHeight > 0) { - holder.setFixedSize(videoWidth, videoHeight); - } - surfaceSentToService = false; - attachSurfaceIfReady(); - } + private final TextureView.SurfaceTextureListener surfaceListener = + new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture texture, int width, int height) { + surfaceTexture = texture; + if (videoWidth > 0 && videoHeight > 0) { + texture.setDefaultBufferSize(videoWidth, videoHeight); + } + surfaceSentToService = false; + attachSurfaceIfReady(); + } - @Override - public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { - if (videoWidth > 0 && videoHeight > 0) { - holder.setFixedSize(videoWidth, videoHeight); - } - } + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture texture, int width, int height) { + if (videoWidth > 0 && videoHeight > 0) { + texture.setDefaultBufferSize(videoWidth, videoHeight); + } + applyTextureFitCenter(); + } - @Override - public void surfaceDestroyed(SurfaceHolder holder) { - surfaceSentToService = false; - detachSurface(); - } + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture texture) { + surfaceSentToService = false; + detachSurface(); + surfaceTexture = null; + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture texture) { + } + }; private void attachSurfaceIfReady() { - if (surfaceSentToService || !bound || receiverService == null || surfaceHolder.getSurface() == null) { - return; - } - if (!surfaceHolder.getSurface().isValid()) { + if (surfaceSentToService || !bound || receiverService == null || surfaceTexture == null) { return; } try { - receiverService.attachVideoSurface(surfaceHolder.getSurface()); + receiverService.attachVideoSurface(new Surface(surfaceTexture)); surfaceSentToService = true; } catch (RemoteException ignored) { } diff --git a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java index 837a313..40acb61 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/ReceiverSession.java @@ -11,8 +11,10 @@ import com.foxx.androidcast.network.CastCodecFlags; import com.foxx.androidcast.network.CastFramingContext; import com.foxx.androidcast.network.CastProtocol; import com.foxx.androidcast.network.CastSession; +import com.foxx.androidcast.network.CastSessionGate; import com.foxx.androidcast.network.CastTransport; import com.foxx.androidcast.network.CastTransportFactory; +import com.foxx.androidcast.network.transport.TransportLossStats; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; @@ -41,6 +43,9 @@ public class ReceiverSession { void onDisconnected(); + /** Sender sent {@link CastProtocol#MSG_GOODBYE} — stop audio/video playback immediately. */ + void onCastEnded(); + /** TCP/UDP listen socket is bound; safe to advertise on the LAN. */ void onListening(); @@ -58,6 +63,7 @@ public class ReceiverSession { private CastSession session; private CastTransport listenTransport; + private final CastSessionGate inboundSessionGate = new CastSessionGate(); private NetworkFeedbackManager networkFeedback; private PassThroughNetworkControlPlane.StatsEnricher statsEnricher; @@ -94,6 +100,14 @@ public class ReceiverSession { listenTransport.close(); listenTransport = null; } + inboundSessionGate.reset(); + } + + public TransportLossStats getTransportLossStats() { + if (listenTransport != null) { + return listenTransport.getLossStats(); + } + return new TransportLossStats(); } public void sendAudioConsent(boolean accept) { @@ -111,17 +125,21 @@ public class ReceiverSession { while (running.get()) { try { if (listenTransport == null) { + com.foxx.androidcast.network.CastTransportFactory.init(appContext); listenTransport = CastTransportFactory.create(localSettings); + inboundSessionGate.reset(); + listenTransport.setInboundSessionGate(inboundSessionGate); listenTransport.setFramingContext(new CastFramingContext( 0, DeviceInfo.getPlaceholderId(appContext), CastCodecFlags.localDecoderVideoMask(), CastCodecFlags.localAudioMask())); session = new CastSession(listenTransport); - session.prepareListen(CastConfig.CAST_PORT); + int listenPort = CastConfig.portForTransport(localSettings.getTransport()); + session.prepareListen(listenPort); listener.onListening(); postStatus("Listening (" + listenTransport.getModeLabel() + ") port " - + CastConfig.CAST_PORT); + + listenPort); } else if (session == null) { session = new CastSession(listenTransport); } @@ -158,7 +176,13 @@ public class ReceiverSession { } finally { if (session != null) { session.releaseConnection(); + session = null; } + if (listenTransport != null) { + listenTransport.close(); + listenTransport = null; + } + inboundSessionGate.reset(); networkFeedback = null; listener.onDisconnected(); } @@ -214,6 +238,7 @@ public class ReceiverSession { break; case CastProtocol.MSG_GOODBYE: postStatus("Sender stopped casting"); + listener.onCastEnded(); return; default: break; diff --git a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java index 857f825..dd11685 100644 --- a/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java +++ b/app/src/main/java/com/foxx/androidcast/receiver/VideoDecoder.java @@ -22,6 +22,9 @@ public class VideoDecoder { /** Called for each frame after the first. */ default void onFrameRendered() {} + + /** Decoder output size (may differ from stream config crop). */ + default void onOutputSizeChanged(int width, int height) {} } private final Object lock = new Object(); @@ -66,7 +69,7 @@ public class VideoDecoder { if (released || data == null || data.length == 0) { return; } - byte[] annexB = CodecNegotiator.isH264(mime) ? H264Bitstream.toAnnexB(data) : data; + byte[] annexB = needsAnnexB(mime) ? H264Bitstream.toAnnexB(data) : data; if (!configured || codec == null) { pending.add(new PendingFrame(ptsUs, keyFrame, annexB)); return; @@ -143,7 +146,9 @@ public class VideoDecoder { break; } if (outIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - Log.i(TAG, "Output format: " + c.getOutputFormat()); + MediaFormat fmt = c.getOutputFormat(); + Log.i(TAG, "Output format: " + fmt); + notifyOutputSizeLocked(fmt); continue; } if (outIndex < 0) { @@ -161,6 +166,23 @@ public class VideoDecoder { } } + private void notifyOutputSizeLocked(MediaFormat format) { + if (listener == null || format == null) { + return; + } + int w = format.getInteger(MediaFormat.KEY_WIDTH); + int h = format.getInteger(MediaFormat.KEY_HEIGHT); + if (format.containsKey("crop-right") && format.containsKey("crop-left")) { + w = format.getInteger("crop-right") + 1 - format.getInteger("crop-left"); + } + if (format.containsKey("crop-bottom") && format.containsKey("crop-top")) { + h = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top"); + } + if (w > 0 && h > 0) { + listener.onOutputSizeChanged(w, h); + } + } + private void notifyFirstFrameLocked() { if (listener == null) { return; @@ -175,6 +197,10 @@ public class VideoDecoder { } } + private static boolean needsAnnexB(String mime) { + return CodecNegotiator.isH264(mime) || CodecNegotiator.isHevc(mime); + } + private static final class PendingFrame { final long ptsUs; final boolean keyFrame; diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java index 6c07075..9cabc34 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioCapture.java @@ -50,6 +50,10 @@ public class AudioCapture { Log.w(TAG, "Audio capture requires API 29+"); return; } + if (projection == null) { + startMicOnly(callback); + return; + } if (!startPlayback(projection)) { Log.e(TAG, "Playback capture unavailable"); return; @@ -72,6 +76,59 @@ public class AudioCapture { thread.start(); } + private void startMicOnly(Callback callback) { + micRecord = canUseMic() ? buildMicRecord() : null; + if (micRecord == null || micRecord.getState() != AudioRecord.STATE_INITIALIZED) { + micRecord = null; + Log.w(TAG, "Mic-only capture unavailable"); + return; + } + running = true; + micRecord.startRecording(); + captureMode = "mic"; + if (callback != null) { + callback.onCaptureModeChanged(captureMode); + } + Log.i(TAG, "Mic-only capture for calibration VU"); + thread = new Thread(() -> micOnlyLoop(callback), "AudioCaptureMic"); + thread.start(); + } + + private void micOnlyLoop(Callback callback) { + int frameBytes = pcmFrameBytes(); + byte[] micBuf = new byte[frameBytes]; + byte[] copy = new byte[frameBytes]; + long frameIndex = 0; + while (running) { + if (!readFully(micRecord, micBuf, frameBytes)) { + continue; + } + System.arraycopy(micBuf, 0, copy, 0, frameBytes); + long ptsUs = frameIndex * 20_000L; + frameIndex++; + if (callback != null) { + callback.onPcm(copy, frameBytes, ptsUs, + com.foxx.androidcast.media.PcmLevelAnalyzer.analyze(copy, frameBytes)); + } + } + } + + private static int pcmFrameBytes() { + return CastConfig.AUDIO_SAMPLE_RATE * CastConfig.AUDIO_CHANNEL_COUNT * 2 / 50; + } + + private static boolean readFully(AudioRecord record, byte[] buf, int length) { + int off = 0; + while (off < length && record != null) { + int n = record.read(buf, off, length - off); + if (n <= 0) { + return false; + } + off += n; + } + return true; + } + private boolean startPlayback(MediaProjection projection) { try { playbackRecord = buildPlaybackRecord(projection); @@ -138,40 +195,32 @@ public class AudioCapture { } private void mixLoop(Callback callback) { - byte[] playBuf = new byte[4096]; - byte[] micBuf = new byte[4096]; - long startNs = System.nanoTime(); + int frameBytes = pcmFrameBytes(); + byte[] playBuf = new byte[frameBytes]; + byte[] micBuf = new byte[frameBytes]; + byte[] mixOut = new byte[frameBytes]; + long frameIndex = 0; while (running) { - int pRead = playbackRecord.read(playBuf, 0, playBuf.length); - int mRead = micRecord != null ? micRecord.read(micBuf, 0, micBuf.length) : 0; - if (pRead < 0 && mRead < 0) { - Log.w(TAG, "Audio read error"); + if (!readFully(playbackRecord, playBuf, frameBytes)) { continue; } - byte[] out; - int outLen; - if (pRead > 0) { - out = playBuf; - outLen = pRead; - String mode = hasAudibleSamples(playBuf, pRead) ? "playback" : "playback-quiet"; - if (!mode.equals(captureMode)) { - captureMode = mode; - notifyMode(callback, captureMode); + int mRead = micRecord != null ? micRecord.read(micBuf, 0, frameBytes) : 0; + System.arraycopy(playBuf, 0, mixOut, 0, frameBytes); + if (mRead > 0 && !hasAudibleSamples(playBuf, frameBytes)) { + for (int i = 0; i < frameBytes; i++) { + mixOut[i] = (byte) Math.max(mixOut[i] & 0xff, micBuf[i] & 0xff); } - } else if (mRead > 0 && micRecord != null) { - out = micBuf; - outLen = mRead; - if (!"mic".equals(captureMode)) { - captureMode = "mic"; - notifyMode(callback, captureMode); - } - } else { - continue; } - long ptsUs = (System.nanoTime() - startNs) / 1000; + String mode = hasAudibleSamples(playBuf, frameBytes) ? "playback" : "playback-quiet"; + if (!mode.equals(captureMode)) { + captureMode = mode; + notifyMode(callback, captureMode); + } + long ptsUs = frameIndex * 20_000L; + frameIndex++; if (callback != null) { - PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(out, outLen); - callback.onPcm(out, outLen, ptsUs, levels); + PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(mixOut, frameBytes); + callback.onPcm(mixOut, frameBytes, ptsUs, levels); } } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java index dc13ed9..017a356 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/AudioEncoder.java @@ -85,6 +85,8 @@ public class AudioEncoder { public void stop() { running = false; + MediaCodec c = codec; + codec = null; if (drainThread != null) { try { drainThread.join(1000); @@ -92,13 +94,15 @@ public class AudioEncoder { Thread.currentThread().interrupt(); } } - if (codec != null) { + if (c != null) { try { - codec.stop(); + c.stop(); + } catch (Exception ignored) { + } + try { + c.release(); } catch (Exception ignored) { } - codec.release(); - codec = null; } } diff --git a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java index 8c4ee0d..a7da65c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java +++ b/app/src/main/java/com/foxx/androidcast/sender/CastSendPump.java @@ -11,25 +11,32 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -/** Background TCP/UDP send queue so encoding is not blocked by network I/O. */ +/** Background send queue: audio has its own lane so video backlog cannot starve it. */ public final class CastSendPump { private static final String TAG = "CastSendPump"; - private static final int MAX_QUEUE = 48; + private static final int MAX_VIDEO_QUEUE = 40; + private static final int MAX_AUDIO_QUEUE = 96; - private final LinkedBlockingQueue queue = new LinkedBlockingQueue<>(MAX_QUEUE); + private final LinkedBlockingQueue videoQueue = new LinkedBlockingQueue<>(MAX_VIDEO_QUEUE); + private final LinkedBlockingQueue audioQueue = new LinkedBlockingQueue<>(MAX_AUDIO_QUEUE); private final AtomicBoolean running = new AtomicBoolean(false); private Thread thread; private CastSession session; private Runnable onSendFailed; private PassThroughNetworkControlPlane statsPlane; private volatile int droppedPFrames; + private volatile int droppedAudioFrames; public int getDroppedPFrames() { return droppedPFrames; } + public int getDroppedAudioFrames() { + return droppedAudioFrames; + } + public int getQueueDepth() { - return queue.size(); + return videoQueue.size() + audioQueue.size(); } public void start(CastSession session, Runnable onSendFailed) { @@ -44,21 +51,23 @@ public final class CastSendPump { return; } thread = new Thread(this::run, "CastSendPump"); + thread.setPriority(Thread.NORM_PRIORITY + 1); thread.start(); } public void stop() { running.set(false); + videoQueue.clear(); + audioQueue.clear(); if (thread != null) { thread.interrupt(); try { - thread.join(1500); + thread.join(500); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } thread = null; } - queue.clear(); session = null; } @@ -67,17 +76,12 @@ public final class CastSendPump { return; } Outgoing item = Outgoing.video(ptsUs, keyFrame, annexBReadyPayload); - if (!queue.offer(item)) { + if (!videoQueue.offer(item)) { if (!keyFrame) { - Outgoing dropped = queue.poll(); - if (dropped != null && !dropped.keyFrame) { - droppedPFrames++; - } + dropOneVideoPFrame(); } - if (!queue.offer(item)) { - if (!keyFrame) { - droppedPFrames++; - } + if (!videoQueue.offer(item) && !keyFrame) { + droppedPFrames++; } } } @@ -87,15 +91,43 @@ public final class CastSendPump { return; } Outgoing item = Outgoing.raw(type, payload); - while (running.get() && !queue.offer(item)) { - queue.poll(); + boolean audio = type == CastProtocol.MSG_AUDIO_FRAME || type == CastProtocol.MSG_AUDIO_CONFIG; + if (audio) { + if (!audioQueue.offer(item)) { + Outgoing dropped = audioQueue.poll(); + if (dropped != null) { + droppedAudioFrames++; + } + audioQueue.offer(item); + } + return; + } + while (running.get() && !videoQueue.offer(item)) { + dropOneVideoPFrame(); + } + } + + private void dropOneVideoPFrame() { + for (Outgoing o : videoQueue) { + if (o.type == CastProtocol.MSG_VIDEO_FRAME && !o.keyFrame) { + videoQueue.remove(o); + droppedPFrames++; + return; + } + } + Outgoing head = videoQueue.poll(); + if (head != null && head.type == CastProtocol.MSG_VIDEO_FRAME && !head.keyFrame) { + droppedPFrames++; } } private void run() { while (running.get()) { try { - Outgoing item = queue.poll(200, TimeUnit.MILLISECONDS); + Outgoing item = audioQueue.poll(); + if (item == null) { + item = videoQueue.poll(50, TimeUnit.MILLISECONDS); + } if (item == null || session == null) { continue; } diff --git a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java index b28b997..1384a33 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java +++ b/app/src/main/java/com/foxx/androidcast/sender/MultiCastCoordinator.java @@ -149,18 +149,27 @@ public final class MultiCastCoordinator { return any; } - public void closeAll() { + public void sendGoodbyeAll() { for (ActivePeer p : peers) { try { p.session.send(CastProtocol.MSG_GOODBYE, new byte[0]); } catch (IOException ignored) { } - p.session.close(); + } + } + + public void closeAll() { + for (ActivePeer p : peers) { + try { + p.session.close(); + } catch (Exception ignored) { + } } peers.clear(); } private CastSession createSession(CastSettings settings) throws IOException { + CastTransportFactory.init(appContext); CastSession session = new CastSession(CastTransportFactory.create(settings)); session.getTransport().setFramingContext(framingContext); return session; diff --git a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java index 8275113..c7d849c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java +++ b/app/src/main/java/com/foxx/androidcast/sender/ScreenCastService.java @@ -36,6 +36,10 @@ import com.foxx.androidcast.network.CastTransportFactory; import com.foxx.androidcast.network.control.NetworkFeedbackManager; import com.foxx.androidcast.network.control.NetworkStatsSnapshot; import com.foxx.androidcast.network.control.PassThroughNetworkControlPlane; +import com.foxx.androidcast.network.transport.TransportLossStats; +import com.foxx.androidcast.network.transport.TransportLossStatsBridge; +import com.foxx.androidcast.stats.SessionStatsRecorder; +import com.foxx.androidcast.stats.SessionStatsStore; import java.io.IOException; import java.util.ArrayList; @@ -46,7 +50,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; /** Foreground sender: screen + audio capture → encode → TCP/UDP (survives UI exit). */ -public class ScreenCastService extends Service { +public class ScreenCastService extends Service implements + com.foxx.androidcast.sender.calibration.CalibrationCastDriver.FallbackHost { private static final String TAG = "ScreenCastService"; private static final int CONNECT_RETRIES = 20; private static final long CONNECT_RETRY_MS = 1000; @@ -58,12 +63,14 @@ public class ScreenCastService extends Service { public static final String EXTRA_PIN = "pin"; public static final String EXTRA_DEVICE_NAME = "device_name"; public static final String EXTRA_TARGETS = "cast_targets"; + public static final String EXTRA_CALIBRATION = "calibration_mode"; public static final String ACTION_STOP = "com.foxx.androidcast.STOP_SEND"; private final RemoteCallbackList callbacks = new RemoteCallbackList<>(); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final AtomicBoolean stopping = new AtomicBoolean(false); private final AtomicBoolean casting = new AtomicBoolean(false); + private final AtomicBoolean resourcesReleased = new AtomicBoolean(false); private MediaProjection projection; private VirtualDisplay virtualDisplay; @@ -92,6 +99,11 @@ public class ScreenCastService extends Service { private int captureDensity; private DisplayManager displayManager; private DisplayManager.DisplayListener displayListener; + private com.foxx.androidcast.sender.calibration.CalibrationCastDriver calibrationDriver; + private volatile boolean calibrationMode; + private volatile boolean cameraMode; + private com.foxx.androidcast.sender.camera.CameraCastCapture cameraCapture; + private SessionStatsRecorder sessionStatsRecorder; private final ICastSenderService.Stub binder = new ICastSenderService.Stub() { @Override @@ -128,7 +140,9 @@ public class ScreenCastService extends Service { @Override public void onCreate() { super.onCreate(); + SessionStatsStore.pruneOnAppStart(this); CastNotifications.createChannels(this); + com.foxx.androidcast.network.CastTransportFactory.init(this); } @Override @@ -149,6 +163,8 @@ public class ScreenCastService extends Service { } private void runCast(Intent intent) { + resourcesReleased.set(false); + stopping.set(false); if (intent == null) { failAndStop(getString(R.string.error_no_intent)); return; @@ -161,7 +177,11 @@ public class ScreenCastService extends Service { String deviceName = intent.getStringExtra(EXTRA_DEVICE_NAME); CastSettings settings = IntentExtras.getCastSettings(intent); - if (data == null) { + boolean calibration = settings != null + && settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; + boolean camera = settings != null + && settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + if (!calibration && !camera && data == null) { failAndStop(getString(R.string.error_projection_data)); return; } @@ -169,6 +189,16 @@ public class ScreenCastService extends Service { failAndStop(getString(R.string.error_missing_params)); return; } + cameraMode = settings.getCaptureMode() == CastSettings.CaptureMode.CAMERA; + calibrationMode = settings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST; + if (cameraMode && !com.foxx.androidcast.PlatformCastSupport.isCameraCaptureModeSupported()) { + failAndStop(getString(R.string.capture_camera_not_implemented)); + return; + } + int castPort = com.foxx.androidcast.CastConfig.portForTransport(settings.getTransport()); + if (port <= 0 || port == com.foxx.androidcast.CastConfig.CAST_PORT) { + port = castPort; + } acquireWakeLock(); try { @@ -210,7 +240,17 @@ public class ScreenCastService extends Service { casting.set(true); CastActiveState.setSenderCasting(true); activeSettings = settings; - startCaptureOnMainThread(resultCode, new Intent(data), settings); + sessionStatsRecorder = new SessionStatsRecorder("sender", settings.getTransport()); + sessionStatsRecorder.mergeSettings(settings); + sessionStatsRecorder.setClientCount(peers.size()); + sessionStatsRecorder.setCodecs(settings.getNegotiatedVideoMime(), "AAC"); + if (calibration) { + startCalibrationCaptureOnMainThread(settings); + } else if (camera) { + startCameraCaptureOnMainThread(settings); + } else { + startCaptureOnMainThread(resultCode, new Intent(data), settings); + } scheduleTuningTick(); while (!stopping.get()) { try { @@ -285,6 +325,83 @@ public class ScreenCastService extends Service { throw new IOException(getString(R.string.error_connect_failed, host, port), last); } + private void startCalibrationCaptureOnMainThread(CastSettings settings) throws IOException { + if (Looper.myLooper() != mainHandler.getLooper()) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + mainHandler.post(() -> { + try { + startCalibrationCaptureOnMainThread(settings); + } catch (Exception e) { + error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e)); + } finally { + latch.countDown(); + } + }); + try { + if (!latch.await(15, TimeUnit.SECONDS)) { + throw new IOException("Calibration setup timeout"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + if (error.get() != null) { + throw error.get(); + } + return; + } + CastResolution.Size size = CastResolution.calibrationTestSize(); + setupVideoPipeline(size, settings, true); + startAudioPipeline(settings, true); + updateStatus(getString(R.string.casting_calibration, captureWidth, captureHeight)); + } + + private void startCameraCaptureOnMainThread(CastSettings settings) throws IOException { + if (Looper.myLooper() != mainHandler.getLooper()) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + mainHandler.post(() -> { + try { + startCameraCaptureOnMainThread(settings); + } catch (Exception e) { + error.set(e instanceof IOException ? (IOException) e : new IOException(e.getMessage(), e)); + } finally { + latch.countDown(); + } + }); + try { + if (!latch.await(20, TimeUnit.SECONDS)) { + throw new IOException("Camera setup timeout"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted", e); + } + if (error.get() != null) { + throw error.get(); + } + return; + } + DisplayMetrics metrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + wm.getDefaultDisplay().getRealMetrics(metrics); + CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); + setupVideoPipeline(size, settings, false); + Surface surface = videoEncoder != null ? videoEncoder.getInputSurface() : null; + if (surface == null) { + throw new IOException("Encoder surface unavailable for camera"); + } + cameraCapture = new com.foxx.androidcast.sender.camera.CameraCastCapture(this); + try { + cameraCapture.start(surface, captureWidth, captureHeight); + } catch (android.hardware.camera2.CameraAccessException e) { + throw new IOException("Camera open failed: " + e.getMessage(), e); + } + startAudioPipeline(settings, true); + updateStatus(getString(R.string.casting_camera, captureWidth, captureHeight)); + } + private void startCaptureOnMainThread(int resultCode, Intent data, CastSettings settings) throws IOException { if (Looper.myLooper() != mainHandler.getLooper()) { CountDownLatch latch = new CountDownLatch(1); @@ -335,16 +452,17 @@ public class ScreenCastService extends Service { WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); wm.getDefaultDisplay().getRealMetrics(metrics); CastResolution.Size size = CastResolution.compute(metrics, settings, getPeerStats()); - setupVideoPipeline(size, settings); + setupVideoPipeline(size, settings, false); registerDisplayListener(); - startAudioPipeline(settings); + startAudioPipeline(settings, false); String mode = session.getTransport().getModeLabel(); updateStatus(getString(R.string.casting_active, mode, captureWidth, captureHeight)); } - private void setupVideoPipeline(CastResolution.Size size, CastSettings settings) throws IOException { + private void setupVideoPipeline(CastResolution.Size size, CastSettings settings, boolean calibration) + throws IOException { if (settings.isPassthrough()) { throw new IOException("Passthrough video is not implemented yet (debug only)"); } @@ -376,45 +494,29 @@ public class ScreenCastService extends Service { } videoEncoder = new VideoEncoder(); - VideoEncoder.Callback videoCb = new VideoEncoder.Callback() { - @Override - public void onConfig(int w, int h, byte[] csd0, byte[] csd1) { - if (stopping.get()) { - return; - } - sendSafe(CastProtocol.MSG_STREAM_CONFIG, - () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1)); - } + VideoEncoder.Callback videoCb = buildVideoEncoderCallback(mime); - @Override - public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { - if ((sendPump == null && fanoutPump == null) || frameData == null) { - return; - } - boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames - && !keyFrame - && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES; - if (tiny) { - return; - } - lastSignificantFrameMs = System.currentTimeMillis(); - senderMetrics.onEncodedFrame(keyFrame); - enqueueVideoFrame(ptsUs, keyFrame, frameData); - } - }; + if (calibration) { + startCalibrationVideoEncoder(mime, effectiveParams, videoCb, true); + return; + } Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, effectiveParams, videoCb); encoderKeepalive = new EncoderKeepalive(); encoderKeepalive.start(videoEncoder); - virtualDisplay = projection.createVirtualDisplay( - "AndroidCast", - captureWidth, - captureHeight, - captureDensity, - DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, - surface, - null, - null); + if (projection != null) { + virtualDisplay = projection.createVirtualDisplay( + "AndroidCast", + captureWidth, + captureHeight, + captureDensity, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, + surface, + null, + null); + } else { + throw new IOException("MediaProjection required for screen capture"); + } videoEncoder.requestKeyframe(); Log.i(TAG, "Video pipeline " + captureWidth + "x" + captureHeight); } @@ -450,7 +552,8 @@ public class ScreenCastService extends Service { } private void maybeReconfigureForRotation() { - if (!casting.get() || projection == null || activeSettings == null || stopping.get()) { + if (calibrationMode || cameraMode || !casting.get() || projection == null + || activeSettings == null || stopping.get()) { return; } DisplayMetrics metrics = new DisplayMetrics(); @@ -461,7 +564,7 @@ public class ScreenCastService extends Service { return; } try { - setupVideoPipeline(size, activeSettings); + setupVideoPipeline(size, activeSettings, false); updateStatus(getString(R.string.casting_rotated, captureWidth, captureHeight)); } catch (IOException e) { Log.e(TAG, "Rotation reconfigure failed", e); @@ -484,6 +587,11 @@ public class ScreenCastService extends Service { s.encodeQueueDepth = sendPump.getQueueDepth(); s.droppedPFrames = sendPump.getDroppedPFrames(); } + TransportLossStats snap = buildSenderLossSnapshot(); + TransportLossStatsBridge.applyTo(s, snap); + if (sessionStatsRecorder != null) { + sessionStatsRecorder.sampleNetwork(s, getPeerStats()); + } s.audioActive = audioEncoder != null && audioFramesSent > 0; s.audioFramesSent = audioFramesSent; s.audioCaptureVuPercent = senderAudioMetrics.getVuPercent(); @@ -543,7 +651,7 @@ public class ScreenCastService extends Service { } private void maybeAdaptCaptureSize(NetworkStatsSnapshot peer) { - if (activeSettings == null + if (calibrationMode || cameraMode || activeSettings == null || activeSettings.getResolution() != CastSettings.Resolution.ADAPTIVE) { return; } @@ -557,19 +665,19 @@ public class ScreenCastService extends Service { return; } try { - setupVideoPipeline(size, activeSettings); + setupVideoPipeline(size, activeSettings, false); Log.i(TAG, "Adaptive capture " + captureWidth + "x" + captureHeight); } catch (IOException e) { Log.w(TAG, "Adaptive resize failed", e); } } - private void startAudioPipeline(CastSettings settings) { + private void startAudioPipeline(CastSettings settings, boolean calibrationOnlyMic) { if (!settings.isAudioEnabled() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { Log.i(TAG, "Audio capture disabled for this session"); return; } - if (projection == null) { + if (projection == null && !calibrationOnlyMic) { Log.w(TAG, "Audio skipped — no MediaProjection"); return; } @@ -609,11 +717,15 @@ public class ScreenCastService extends Service { senderAudioMetrics.reset(); audioCaptureMode = "playback"; audioCapture = new AudioCapture(this); - audioCapture.start(projection, new AudioCapture.Callback() { + MediaProjection audioProjection = calibrationOnlyMic ? null : projection; + audioCapture.start(audioProjection, new AudioCapture.Callback() { @Override public void onPcm(byte[] pcm, int length, long ptsUs, com.foxx.androidcast.media.PcmLevelAnalyzer.Levels levels) { senderAudioMetrics.onPcm(levels); + if (calibrationDriver != null) { + calibrationDriver.setVuFromPcm(pcm, length); + } if (audioEncoder != null) { audioEncoder.queuePcm(pcm, length, ptsUs); } @@ -634,7 +746,8 @@ public class ScreenCastService extends Service { private void handleAudioConsent(boolean accept) { if (accept) { if (audioEncoder == null && activeSettings != null) { - startAudioPipeline(activeSettings); + startAudioPipeline(activeSettings, activeSettings != null + && activeSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST); } Log.i(TAG, "Receiver accepted audio"); } else { @@ -695,6 +808,10 @@ public class ScreenCastService extends Service { } private void releaseAll() { + if (!resourcesReleased.compareAndSet(false, true)) { + return; + } + finishSessionStatsAsync(); stopping.set(true); casting.set(false); CastActiveState.setSenderCasting(false); @@ -708,10 +825,16 @@ public class ScreenCastService extends Service { encoderKeepalive.stop(); encoderKeepalive = null; } - if (videoEncoder != null) { - videoEncoder.stop(); - videoEncoder = null; + if (cameraCapture != null) { + cameraCapture.stop(); + cameraCapture = null; } + if (calibrationDriver != null) { + calibrationDriver.release(); + calibrationDriver = null; + } + calibrationMode = false; + cameraMode = false; if (audioCapture != null) { audioCapture.stop(); audioCapture = null; @@ -720,28 +843,36 @@ public class ScreenCastService extends Service { audioEncoder.stop(); audioEncoder = null; } + if (videoEncoder != null) { + videoEncoder.stop(); + videoEncoder = null; + } if (projection != null) { projection.stop(); projection = null; } if (multiCoordinator != null) { - multiCoordinator.closeAll(); - multiCoordinator = null; + multiCoordinator.sendGoodbyeAll(); } else if (session != null) { try { session.send(CastProtocol.MSG_GOODBYE, new byte[0]); } catch (Exception ignored) { } - session.close(); - session = null; + } + if (sendPump != null) { + sendPump.stop(); + sendPump = null; } if (fanoutPump != null) { fanoutPump.stop(); fanoutPump = null; } - if (sendPump != null) { - sendPump.stop(); - sendPump = null; + if (multiCoordinator != null) { + multiCoordinator.closeAll(); + multiCoordinator = null; + } else if (session != null) { + session.close(); + session = null; } releaseWakeLock(); } @@ -752,10 +883,17 @@ public class ScreenCastService extends Service { } private void stopSelfSafely() { - stopping.set(true); - releaseAll(); - stopForeground(STOP_FOREGROUND_REMOVE); - stopSelf(); + if (!stopping.compareAndSet(false, true)) { + return; + } + Thread stopper = new Thread(() -> { + releaseAll(); + mainHandler.post(() -> { + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(); + }); + }, "CastStop"); + stopper.start(); } private void startSenderForeground(String text, boolean withAudio) { @@ -771,6 +909,41 @@ public class ScreenCastService extends Service { } } + private TransportLossStats buildSenderLossSnapshot() { + TransportLossStats snap = session != null + ? session.getTransport().getLossStats() : new TransportLossStats(); + if (sendPump != null) { + snap.sendDroppedVideoP = sendPump.getDroppedPFrames(); + snap.sendDroppedAudio = sendPump.getDroppedAudioFrames(); + snap.sendQueueDepth = sendPump.getQueueDepth(); + } else if (fanoutPump != null) { + snap.sendDroppedVideoP = fanoutPump.getDroppedPFrames(); + snap.sendQueueDepth = fanoutPump.getQueueDepth(); + } + return snap; + } + + private void finishSessionStatsAsync() { + final SessionStatsRecorder recorder = sessionStatsRecorder; + sessionStatsRecorder = null; + if (recorder == null) { + return; + } + if (activeSettings != null) { + recorder.setResolution(captureWidth, captureHeight); + recorder.setCaptureMode(activeSettings.getCaptureMode() != null + ? activeSettings.getCaptureMode().name() : ""); + } + recorder.mergeLoss(buildSenderLossSnapshot()); + new Thread(() -> recorder.finish(getApplicationContext()), "SessionStatsSave").start(); + } + + private void logEncodedCodecSample(String mime, byte[] frameData) { + String probe = com.foxx.androidcast.media.EncodedStreamProbe.describeKeyframe(mime, frameData); + Log.i(TAG, "Encoded stream: " + com.foxx.androidcast.media.CodecNegotiator.displayName(mime) + + " — " + probe); + } + private void updateStatus(String text) { status = text; broadcastStatus(text); @@ -790,6 +963,90 @@ public class ScreenCastService extends Service { callbacks.finishBroadcast(); } + private VideoEncoder.Callback buildVideoEncoderCallback(String mime) { + return new VideoEncoder.Callback() { + @Override + public void onConfig(int w, int h, byte[] csd0, byte[] csd1) { + if (stopping.get()) { + return; + } + sendSafe(CastProtocol.MSG_STREAM_CONFIG, + () -> CastProtocol.streamConfigPayload(w, h, csd0, csd1)); + } + + @Override + public void onEncodedFrame(long ptsUs, boolean keyFrame, byte[] frameData) { + if ((sendPump == null && fanoutPump == null) || frameData == null) { + return; + } + boolean tiny = effectiveParams != null && effectiveParams.skipTinyPFrames + && !keyFrame + && frameData.length < CastConfig.MIN_SIGNIFICANT_FRAME_BYTES; + if (tiny) { + return; + } + lastSignificantFrameMs = System.currentTimeMillis(); + senderMetrics.onEncodedFrame(keyFrame); + if (keyFrame) { + logEncodedCodecSample(mime, frameData); + } + enqueueVideoFrame(ptsUs, keyFrame, frameData); + } + }; + } + + private void startCalibrationVideoEncoder(String mime, CastTuningEngine.EffectiveParams params, + VideoEncoder.Callback videoCb, boolean tryGles) throws IOException { + if (encoderKeepalive != null) { + encoderKeepalive.stop(); + encoderKeepalive = null; + } + if (calibrationDriver != null) { + calibrationDriver.release(); + calibrationDriver = null; + } + if (videoEncoder != null) { + videoEncoder.stop(); + } + videoEncoder = new VideoEncoder(); + float density = getResources().getDisplayMetrics().density; + calibrationDriver = new com.foxx.androidcast.sender.calibration.CalibrationCastDriver(this, this); + if (tryGles) { + Surface surface = videoEncoder.prepare(captureWidth, captureHeight, mime, params, videoCb); + encoderKeepalive = new EncoderKeepalive(); + encoderKeepalive.start(videoEncoder); + calibrationDriver.attachSurface(videoEncoder, surface, captureWidth, captureHeight, density); + videoEncoder.requestKeyframe(); + Log.i(TAG, "Calibration encoder (GLES try) " + captureWidth + "x" + captureHeight + " mime=" + mime); + } else { + videoEncoder.prepareBufferInput(captureWidth, captureHeight, mime, params, videoCb); + encoderKeepalive = new EncoderKeepalive(); + encoderKeepalive.start(videoEncoder); + calibrationDriver.attachBuffer(videoEncoder, captureWidth, captureHeight, density); + videoEncoder.requestKeyframe(); + Log.i(TAG, "Calibration encoder (buffer) " + captureWidth + "x" + captureHeight + " mime=" + mime); + } + } + + @Override + public void onCalibrationNeedBufferEncoder() { + if (!calibrationMode || stopping.get() || activeSettings == null) { + return; + } + String mime = activeSettings.getNegotiatedVideoMime(); + if (mime == null || mime.isEmpty()) { + return; + } + mainHandler.post(() -> { + try { + startCalibrationVideoEncoder(mime, effectiveParams, buildVideoEncoderCallback(mime), false); + } catch (IOException e) { + Log.e(TAG, "Calibration buffer fallback failed", e); + failAndStop("Calibration video failed"); + } + }); + } + @Override public IBinder onBind(Intent intent) { return binder; diff --git a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java index 2e9098e..140e49c 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java +++ b/app/src/main/java/com/foxx/androidcast/sender/SenderActivity.java @@ -14,6 +14,7 @@ import android.provider.Settings; import android.text.TextUtils; import android.view.View; import android.view.WindowManager; +import android.widget.Button; import android.widget.EditText; import android.widget.ListView; import android.widget.ProgressBar; @@ -35,7 +36,6 @@ import com.foxx.androidcast.discovery.DiscoveryManager; public class SenderActivity extends AppCompatActivity { private static final int REQUEST_MEDIA_PROJECTION = 2001; - private static final long DISCOVERY_INTERVAL_MS = 30_000; private static final long BACK_STOP_INTERVAL_MS = 2_000; private static final int DEV_TAP_UNLOCK = 7; @@ -47,7 +47,28 @@ public class SenderActivity extends AppCompatActivity { private TextView statusText; private TextView titleText; private ProgressBar discoveryProgress; + private Button refreshButton; private View advancedPanel; + + private enum DiscoveryPhase { + IDLE, SCANNING, PAUSED + } + + private DiscoveryPhase discoveryPhase = DiscoveryPhase.IDLE; + private int discoverySecondsLeft; + private final Runnable autoScanRunnable = () -> beginScanPass(false); + private final Runnable discoveryCountdownTick = new Runnable() { + @Override + public void run() { + if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { + if (discoverySecondsLeft > 0) { + discoverySecondsLeft--; + } + updateRefreshButtonLabel(); + handler.postDelayed(this, 1000); + } + } + }; private EditText pinInput; private EditText manualHostInput; private EditText manualPortInput; @@ -57,15 +78,6 @@ public class SenderActivity extends AppCompatActivity { private boolean useManualConnect; private long lastBackPressMs; - private final Runnable discoveryWatchdog = new Runnable() { - @Override - public void run() { - if (discovery != null) { - restartDiscoveryInternal(false); - } - handler.postDelayed(this, DISCOVERY_INTERVAL_MS); - } - }; @Override protected void onCreate(Bundle savedInstanceState) { @@ -79,6 +91,7 @@ public class SenderActivity extends AppCompatActivity { pinInput.setText(CastConfig.DEFAULT_PIN); statusText = findViewById(R.id.text_status); discoveryProgress = findViewById(R.id.progress_discovery); + refreshButton = findViewById(R.id.btn_refresh); advancedPanel = findViewById(R.id.panel_advanced); manualHostInput = findViewById(R.id.edit_manual_host); manualPortInput = findViewById(R.id.edit_manual_port); @@ -143,12 +156,22 @@ public class SenderActivity extends AppCompatActivity { @Override public void onScanStarted() { + discoveryPhase = DiscoveryPhase.SCANNING; + discoverySecondsLeft = (int) (CastConfig.DISCOVERY_SCAN_PASS_MS / 1000); + updateRefreshButtonLabel(); + handler.removeCallbacks(discoveryCountdownTick); + handler.post(discoveryCountdownTick); showDiscoveryProgress(); } @Override public void onScanEnded() { hideDiscoveryProgress(); + discoveryPhase = DiscoveryPhase.PAUSED; + discoverySecondsLeft = (int) (CastConfig.DISCOVERY_PAUSE_MS / 1000); + updateRefreshButtonLabel(); + handler.removeCallbacks(autoScanRunnable); + handler.postDelayed(autoScanRunnable, CastConfig.DISCOVERY_PAUSE_MS); } }); @@ -170,7 +193,7 @@ public class SenderActivity extends AppCompatActivity { } }); - findViewById(R.id.btn_refresh).setOnClickListener(v -> restartDiscovery()); + refreshButton.setOnClickListener(v -> onRefreshClicked()); findViewById(R.id.btn_start_cast).setOnClickListener(v -> PlatformCastSupport.runWithAudioPolicy(this, castSettings, this::startCastFlow)); findViewById(R.id.btn_manual_cast).setOnClickListener(v -> { @@ -188,36 +211,60 @@ public class SenderActivity extends AppCompatActivity { @Override protected void onStart() { super.onStart(); - restartDiscovery(); - handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); + beginScanPass(false); } @Override protected void onStop() { - handler.removeCallbacks(discoveryWatchdog); + handler.removeCallbacks(discoveryCountdownTick); + handler.removeCallbacks(autoScanRunnable); if (discovery != null) { discovery.stop(); } + discoveryPhase = DiscoveryPhase.IDLE; + updateRefreshButtonLabel(); hideDiscoveryProgress(); super.onStop(); } - private void restartDiscovery() { - handler.removeCallbacks(discoveryWatchdog); - restartDiscoveryInternal(true); - handler.postDelayed(discoveryWatchdog, DISCOVERY_INTERVAL_MS); + private void onRefreshClicked() { + if (discoveryPhase == DiscoveryPhase.SCANNING) { + return; + } + handler.removeCallbacks(autoScanRunnable); + beginScanPass(true); } - private void restartDiscoveryInternal(boolean userRefresh) { - deviceAdapter.clearDevices(); - statusText.setText(userRefresh ? R.string.rescanning : R.string.searching); + private void beginScanPass(boolean userInitiated) { + if (discovery != null && discovery.isBrowsing()) { + return; + } + if (userInitiated) { + deviceAdapter.clearDevices(); + statusText.setText(R.string.rescanning); + } else if (deviceAdapter.getCount() == 0) { + statusText.setText(R.string.searching); + } if (discovery != null) { discovery.stop(); - discovery.clearDevices(); + if (userInitiated) { + discovery.clearDevices(); + } } discovery.startBrowsing(); } + private void updateRefreshButtonLabel() { + if (refreshButton == null) { + return; + } + if (discoveryPhase == DiscoveryPhase.SCANNING || discoveryPhase == DiscoveryPhase.PAUSED) { + refreshButton.setText(getString(R.string.refresh_countdown, Math.max(0, discoverySecondsLeft))); + } else { + refreshButton.setText(R.string.refresh); + } + } + private void showDiscoveryProgress() { discoveryProgress.setVisibility(View.VISIBLE); discoveryProgress.bringToFront(); @@ -303,6 +350,21 @@ public class SenderActivity extends AppCompatActivity { } pendingPin = pin; + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { + launchCastService(null, Activity.RESULT_OK); + return; + } + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CAMERA) { + if (androidx.core.content.ContextCompat.checkSelfPermission(this, + android.Manifest.permission.CAMERA) != android.content.pm.PackageManager.PERMISSION_GRANTED) { + androidx.core.app.ActivityCompat.requestPermissions(this, + new String[] {android.Manifest.permission.CAMERA}, PermissionHelper.REQUEST_CODE); + pendingCameraLaunch = true; + return; + } + launchCastService(null, Activity.RESULT_OK); + return; + } MediaProjectionManager mgr = (MediaProjectionManager) getSystemService(MEDIA_PROJECTION_SERVICE); startActivityForResult(createScreenCaptureIntent(mgr), REQUEST_MEDIA_PROJECTION); } @@ -316,39 +378,61 @@ public class SenderActivity extends AppCompatActivity { return mgr.createScreenCaptureIntent(); } + private boolean pendingCameraLaunch; + private String pendingPin; private String pendingHost; private int pendingPort; private String pendingDeviceName; private java.util.ArrayList pendingTargets; + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PermissionHelper.REQUEST_CODE && pendingCameraLaunch) { + pendingCameraLaunch = false; + if (grantResults.length > 0 && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) { + launchCastService(null, Activity.RESULT_OK); + } else { + Toast.makeText(this, R.string.capture_camera_not_implemented, Toast.LENGTH_LONG).show(); + } + } + } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode != REQUEST_MEDIA_PROJECTION) { return; } - if (resultCode != Activity.RESULT_OK || data == null || pendingHost == null) { + if (resultCode != Activity.RESULT_OK || pendingHost == null) { Toast.makeText(this, R.string.capture_denied, Toast.LENGTH_SHORT).show(); return; } + launchCastService(data, resultCode); + } + + private void launchCastService(Intent projectionData, int resultCode) { String deviceName = Settings.Global.getString(getContentResolver(), Settings.Global.DEVICE_NAME); if (deviceName == null) { deviceName = Build.MODEL; } - Intent serviceIntent = new Intent(this, ScreenCastService.class); serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_CODE, resultCode); - serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(data)); + if (projectionData != null) { + serviceIntent.putExtra(ScreenCastService.EXTRA_RESULT_DATA, new Intent(projectionData)); + } serviceIntent.putExtra(ScreenCastService.EXTRA_HOST, pendingHost); serviceIntent.putExtra(ScreenCastService.EXTRA_PORT, pendingPort); serviceIntent.putExtra(ScreenCastService.EXTRA_PIN, pendingPin); serviceIntent.putExtra(ScreenCastService.EXTRA_DEVICE_NAME, pendingDeviceName != null ? pendingDeviceName : deviceName); serviceIntent.putExtra(CastSettings.EXTRA, castSettings); + if (castSettings.getCaptureMode() == CastSettings.CaptureMode.CALIBRATION_TEST) { + serviceIntent.putExtra(ScreenCastService.EXTRA_CALIBRATION, true); + } if (pendingTargets != null && !pendingTargets.isEmpty()) { serviceIntent.putExtra(ScreenCastService.EXTRA_TARGETS, pendingTargets); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { startForegroundService(serviceIntent); } else { diff --git a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java index 7066c04..015f952 100644 --- a/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java +++ b/app/src/main/java/com/foxx/androidcast/sender/VideoEncoder.java @@ -1,5 +1,6 @@ package com.foxx.androidcast.sender; +import android.graphics.Bitmap; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; @@ -9,6 +10,7 @@ import android.util.Log; import android.view.Surface; import com.foxx.androidcast.CastTuningEngine; +import com.foxx.androidcast.media.RgbToYuv420; import java.io.IOException; import java.nio.ByteBuffer; @@ -25,6 +27,9 @@ public class VideoEncoder { private MediaCodec codec; private Surface inputSurface; + private boolean bufferInput; + private RgbToYuv420.Layout yuvLayout = RgbToYuv420.Layout.I420; + private byte[] yuvScratch; private Callback callback; private int width; private int height; @@ -32,7 +37,10 @@ public class VideoEncoder { private Thread drainThread; private String mime; - /** Request an IDR (keyframe) on the next opportunity. */ + public Surface getInputSurface() { + return inputSurface; + } + public void requestKeyframe() { if (codec == null || !running) { return; @@ -48,19 +56,36 @@ public class VideoEncoder { public Surface prepare(int width, int height, String mime, CastTuningEngine.EffectiveParams params, Callback callback) throws IOException { + return prepareInternal(width, height, mime, params, callback, false); + } + + /** Software-fed frames (calibration chart) via YUV buffers — no input Surface. */ + public void prepareBufferInput(int width, int height, String mime, CastTuningEngine.EffectiveParams params, + Callback callback) throws IOException { + prepareInternal(width, height, mime, params, callback, true); + } + + private Surface prepareInternal(int width, int height, String mime, CastTuningEngine.EffectiveParams params, + Callback callback, boolean buffer) throws IOException { this.width = width; this.height = height; this.mime = mime; this.callback = callback; - - MediaFormat tuned = buildFormat(width, height, mime, params, true); - MediaFormat basic = buildFormat(width, height, mime, params, false); + this.bufferInput = buffer; + yuvScratch = buffer ? new byte[RgbToYuv420.bufferSize(width, height)] : null; codec = MediaCodec.createEncoderByType(mime); + yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420; + + MediaFormat tuned = buildFormat(width, height, mime, params, true, buffer, yuvLayout); + MediaFormat basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout); + if (!tryConfigure(codec, tuned)) { Log.w(TAG, "Tuned encoder format rejected; retrying with basic format"); codec.release(); codec = MediaCodec.createEncoderByType(mime); + yuvLayout = buffer ? pickYuvLayout(codec, mime) : RgbToYuv420.Layout.I420; + basic = buildFormat(width, height, mime, params, false, buffer, yuvLayout); if (!tryConfigure(codec, basic)) { codec.release(); codec = null; @@ -68,20 +93,73 @@ public class VideoEncoder { } } - inputSurface = codec.createInputSurface(); + if (bufferInput) { + inputSurface = null; + } else { + inputSurface = codec.createInputSurface(); + } codec.start(); running = true; drainThread = new Thread(this::drainLoop, "VideoEncoderDrain"); drainThread.start(); - Log.i(TAG, "Encoder " + mime + " " + width + "x" + height + " @ " + params.videoFps + "fps " - + (params.videoBitrate / 1000) + "kbps GOP=" + params.keyframeIntervalSec); + Log.i(TAG, (bufferInput ? "Buffer/" + yuvLayout : "Surface") + " encoder " + mime + + " " + width + "x" + height + " @ " + params.videoFps + "fps"); return inputSurface; } + private static RgbToYuv420.Layout pickYuvLayout(MediaCodec codec, String mime) { + MediaCodecInfo.CodecCapabilities caps = codec.getCodecInfo().getCapabilitiesForType(mime); + for (int fmt : caps.colorFormats) { + if (fmt == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar) { + return RgbToYuv420.Layout.NV12; + } + } + return RgbToYuv420.Layout.I420; + } + + public void queueBitmapFrame(Bitmap bitmap, long ptsUs) { + if (!bufferInput || codec == null || !running || bitmap == null) { + return; + } + byte[] yuv = RgbToYuv420.fromBitmap(bitmap, yuvLayout); + queueYuvFrame(yuv, ptsUs); + } + + public void queueYuvFrame(byte[] yuv, long ptsUs) { + if (!bufferInput || codec == null || !running || yuv == null) { + return; + } + try { + int index = codec.dequeueInputBuffer(0); + if (index < 0) { + return; + } + ByteBuffer buffer = codec.getInputBuffer(index); + if (buffer == null) { + return; + } + buffer.clear(); + int len = Math.min(buffer.remaining(), yuv.length); + buffer.put(yuv, 0, len); + codec.queueInputBuffer(index, 0, len, ptsUs, 0); + } catch (Exception e) { + Log.w(TAG, "queueYuvFrame: " + e.getMessage()); + } + } + private static MediaFormat buildFormat(int width, int height, String mime, - CastTuningEngine.EffectiveParams params, boolean tuned) { + CastTuningEngine.EffectiveParams params, boolean tuned, boolean buffer, + RgbToYuv420.Layout layout) { MediaFormat format = MediaFormat.createVideoFormat(mime, width, height); - format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + int color; + if (!buffer) { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface; + } else if (layout == RgbToYuv420.Layout.NV12) { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar; + } else { + color = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar; + } + format.setInteger(MediaFormat.KEY_COLOR_FORMAT, color); format.setInteger(MediaFormat.KEY_BIT_RATE, params.videoBitrate); format.setInteger(MediaFormat.KEY_FRAME_RATE, params.videoFps); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, params.keyframeIntervalSec); @@ -117,6 +195,7 @@ public class VideoEncoder { public void stop() { running = false; + bufferInput = false; if (drainThread != null) { try { drainThread.join(1500); @@ -136,25 +215,30 @@ public class VideoEncoder { inputSurface.release(); inputSurface = null; } + yuvScratch = null; } private void drainLoop() { MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); boolean configSent = false; while (running) { - int index = codec.dequeueOutputBuffer(info, 5_000); + MediaCodec c = codec; + if (c == null) { + break; + } + int index = c.dequeueOutputBuffer(info, 5_000); if (index == MediaCodec.INFO_TRY_AGAIN_LATER) { continue; } if (index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { - sendConfig(codec.getOutputFormat(), configSent); + sendConfig(c.getOutputFormat(), configSent); configSent = true; continue; } if (index < 0) { continue; } - ByteBuffer buffer = codec.getOutputBuffer(index); + ByteBuffer buffer = c.getOutputBuffer(index); if (buffer != null && info.size > 0) { byte[] data = new byte[info.size]; buffer.position(info.offset); @@ -166,7 +250,7 @@ public class VideoEncoder { callback.onEncodedFrame(info.presentationTimeUs, keyFrame, data); } } - codec.releaseOutputBuffer(index, false); + c.releaseOutputBuffer(index, false); if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { break; } diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java new file mode 100644 index 0000000..95cced0 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationCastDriver.java @@ -0,0 +1,199 @@ +package com.foxx.androidcast.sender.calibration; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.view.Surface; + +import com.foxx.androidcast.media.PcmLevelAnalyzer; +import com.foxx.androidcast.sender.VideoEncoder; + +/** + * Renders calibration chart + VU border into the video encoder (GLES surface or YUV buffer fallback). + */ +public final class CalibrationCastDriver { + private static final String TAG = "CalibrationCastDriver"; + private static final int EGL_FAIL_THRESHOLD = 5; + + public interface FallbackHost { + /** Recreate encoder with {@link VideoEncoder#prepareBufferInput} after GLES failure. */ + void onCalibrationNeedBufferEncoder(); + } + + private enum FeedMode { + GLES_SURFACE, + YUV_BUFFER + } + + private final Context appContext; + private final HandlerThread thread; + private final Handler handler; + private final FallbackHost fallbackHost; + private CalibrationEglRenderer eglRenderer; + private VideoEncoder videoEncoder; + private Bitmap chartBitmap; + private Bitmap frameBitmap; + private Canvas frameCanvas; + private int width; + private int height; + private float density; + private int scrollPx; + private volatile float vuLevel; + private volatile boolean running; + private Runnable tick; + private long encodeStartNs; + private FeedMode feedMode = FeedMode.YUV_BUFFER; + private int eglFailures; + private boolean fallbackRequested; + + public CalibrationCastDriver(Context context) { + this(context, null); + } + + public CalibrationCastDriver(Context context, FallbackHost fallbackHost) { + appContext = context.getApplicationContext(); + this.fallbackHost = fallbackHost; + thread = new HandlerThread("CalibrationCast"); + thread.start(); + handler = new Handler(thread.getLooper()); + } + + /** Preferred path: GLES into encoder {@link Surface}. */ + public void attachSurface(VideoEncoder encoder, Surface encoderSurface, int width, int height, float density) { + this.videoEncoder = encoder; + initBitmaps(width, height, density); + eglFailures = 0; + fallbackRequested = false; + try { + eglRenderer = new CalibrationEglRenderer(); + eglRenderer.start(encoderSurface, width, height); + feedMode = FeedMode.GLES_SURFACE; + Log.i(TAG, "Calibration GLES " + width + "x" + height); + } catch (Exception e) { + Log.w(TAG, "GLES init failed: " + e.getMessage()); + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + requestBufferEncoderFallback(); + } + running = true; + encodeStartNs = System.nanoTime(); + startTick(); + } + + /** Reliable path when GLES cannot use the encoder surface (e.g. EGL_BAD_ACCESS on MTK). */ + public void attachBuffer(VideoEncoder encoder, int width, int height, float density) { + this.videoEncoder = encoder; + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + initBitmaps(width, height, density); + running = true; + encodeStartNs = System.nanoTime(); + startTick(); + Log.i(TAG, "Calibration buffer feed " + width + "x" + height); + } + + private void initBitmaps(int w, int h, float density) { + this.width = w; + this.height = h; + this.density = density; + scrollPx = 0; + chartBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + Canvas chartCanvas = new Canvas(chartBitmap); + TvCalibrationGenerator.drawChart(chartCanvas, w, h, appContext); + frameBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + frameCanvas = new Canvas(frameBitmap); + } + + public void setVuFromPcm(byte[] pcm, int length) { + PcmLevelAnalyzer.Levels levels = PcmLevelAnalyzer.analyze(pcm, length); + float instant = Math.max(levels.peak, levels.rms); + if (levels.silent) { + vuLevel *= 0.88f; + } else { + vuLevel = instant * 0.7f + vuLevel * 0.3f; + } + } + + private void startTick() { + tick = new Runnable() { + @Override + public void run() { + if (!running || frameCanvas == null) { + return; + } + long frameStart = System.nanoTime(); + drawFrame(); + scrollPx += Math.max(1, Math.round(1f * density)); + long elapsedMs = (System.nanoTime() - frameStart) / 1_000_000L; + long delay = feedMode == FeedMode.GLES_SURFACE + ? Math.max(1L, 33L - elapsedMs) + : Math.max(1L, 50L - elapsedMs); + handler.postDelayed(this, delay); + } + }; + handler.post(tick); + } + + private void drawFrame() { + frameCanvas.drawBitmap(chartBitmap, 0f, 0f, null); + TvCalibrationGenerator.drawBorderOverlay( + frameCanvas, width, height, density, scrollPx, vuLevel); + long ptsUs = (System.nanoTime() - encodeStartNs) / 1000L; + if (feedMode == FeedMode.GLES_SURFACE && eglRenderer != null && eglRenderer.isReady()) { + if (eglRenderer.draw(frameBitmap, System.nanoTime())) { + eglFailures = 0; + return; + } + eglFailures++; + if (eglFailures >= EGL_FAIL_THRESHOLD) { + Log.w(TAG, "GLES feed failed " + eglFailures + " times — switching to buffer"); + releaseEgl(); + feedMode = FeedMode.YUV_BUFFER; + requestBufferEncoderFallback(); + } + return; + } + if (videoEncoder != null && feedMode == FeedMode.YUV_BUFFER) { + videoEncoder.queueBitmapFrame(frameBitmap, ptsUs); + } + } + + private void requestBufferEncoderFallback() { + if (fallbackRequested || fallbackHost == null) { + return; + } + fallbackRequested = true; + handler.post(() -> fallbackHost.onCalibrationNeedBufferEncoder()); + } + + private void releaseEgl() { + if (eglRenderer != null) { + eglRenderer.release(); + eglRenderer = null; + } + } + + public void stop() { + running = false; + handler.removeCallbacksAndMessages(null); + releaseEgl(); + if (chartBitmap != null) { + chartBitmap.recycle(); + chartBitmap = null; + } + if (frameBitmap != null) { + frameBitmap.recycle(); + frameBitmap = null; + } + frameCanvas = null; + videoEncoder = null; + } + + public void release() { + stop(); + thread.quitSafely(); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java new file mode 100644 index 0000000..53a3009 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/CalibrationEglRenderer.java @@ -0,0 +1,236 @@ +package com.foxx.androidcast.sender.calibration; + +import android.graphics.Bitmap; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.opengl.GLES20; +import android.opengl.GLUtils; +import android.util.Log; +import android.view.Surface; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; + +/** Draws a bitmap into a MediaCodec encoder input {@link Surface} via GLES2. */ +final class CalibrationEglRenderer { + private static final String TAG = "CalibrationEglRenderer"; + + private static final String VERT = + "attribute vec4 aPosition;\n" + + "attribute vec2 aTexCoord;\n" + + "varying vec2 vTexCoord;\n" + + "void main() {\n" + + " gl_Position = aPosition;\n" + + " vTexCoord = aTexCoord;\n" + + "}\n"; + + private static final String FRAG = + "precision mediump float;\n" + + "varying vec2 vTexCoord;\n" + + "uniform sampler2D uTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(uTexture, vTexCoord);\n" + + "}\n"; + + private static final float[] QUAD = { + -1f, -1f, 0f, 1f, + 1f, -1f, 1f, 1f, + -1f, 1f, 0f, 0f, + 1f, 1f, 1f, 0f, + }; + + private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY; + private EGLContext eglContext = EGL14.EGL_NO_CONTEXT; + private EGLSurface eglSurface = EGL14.EGL_NO_SURFACE; + private int program; + private int textureId; + private int aPosition; + private int aTexCoord; + private int uTexture; + private FloatBuffer quadBuffer; + private int width; + private int height; + /** {@link EGLExt#EGL_RECORDABLE_ANDROID} — required for MediaCodec input surfaces. */ + private static final int EGL_RECORDABLE_ANDROID = 0x3142; + + boolean isReady() { + return eglDisplay != EGL14.EGL_NO_DISPLAY && eglSurface != EGL14.EGL_NO_SURFACE; + } + + void start(Surface surface, int width, int height) { + this.width = width; + this.height = height; + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (eglDisplay == EGL14.EGL_NO_DISPLAY) { + throw new IllegalStateException("eglGetDisplay failed"); + } + int[] version = new int[2]; + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { + throw new IllegalStateException("eglInitialize failed"); + } + EGLConfig eglConfig = chooseConfig(eglDisplay); + int[] contextAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; + eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribs, 0); + if (eglContext == EGL14.EGL_NO_CONTEXT) { + throw new IllegalStateException("eglCreateContext failed"); + } + int[] surfaceAttribs = {EGL14.EGL_NONE}; + eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttribs, 0); + if (eglSurface == EGL14.EGL_NO_SURFACE) { + throw new IllegalStateException("eglCreateWindowSurface failed"); + } + if (!makeCurrent()) { + throw new IllegalStateException("eglMakeCurrent failed: 0x" + + Integer.toHexString(EGL14.eglGetError())); + } + program = buildProgram(VERT, FRAG); + aPosition = GLES20.glGetAttribLocation(program, "aPosition"); + aTexCoord = GLES20.glGetAttribLocation(program, "aTexCoord"); + uTexture = GLES20.glGetUniformLocation(program, "uTexture"); + int[] textures = new int[1]; + GLES20.glGenTextures(1, textures, 0); + textureId = textures[0]; + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + quadBuffer = ByteBuffer.allocateDirect(QUAD.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer(); + quadBuffer.put(QUAD).position(0); + GLES20.glViewport(0, 0, width, height); + Log.i(TAG, "EGL calibration renderer " + width + "x" + height); + } + + /** @return false if EGL could not present this frame (caller should fall back to buffer input). */ + boolean draw(Bitmap bitmap, long ptsNs) { + if (eglDisplay == EGL14.EGL_NO_DISPLAY || bitmap == null) { + return false; + } + if (!makeCurrent()) { + return false; + } + GLES20.glViewport(0, 0, width, height); + GLES20.glClearColor(0f, 0f, 0f, 1f); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLES20.glUseProgram(program); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId); + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); + GLES20.glUniform1i(uTexture, 0); + quadBuffer.position(0); + GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, 16, quadBuffer); + quadBuffer.position(2); + GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, 16, quadBuffer); + GLES20.glEnableVertexAttribArray(aPosition); + GLES20.glEnableVertexAttribArray(aTexCoord); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GLES20.glDisableVertexAttribArray(aPosition); + GLES20.glDisableVertexAttribArray(aTexCoord); + EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, ptsNs); + if (!EGL14.eglSwapBuffers(eglDisplay, eglSurface)) { + Log.w(TAG, "eglSwapBuffers failed: 0x" + Integer.toHexString(EGL14.eglGetError())); + return false; + } + return true; + } + + private boolean makeCurrent() { + if (eglContext.equals(EGL14.eglGetCurrentContext()) + && eglSurface.equals(EGL14.eglGetCurrentSurface(EGL14.EGL_DRAW))) { + return true; + } + if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) { + Log.w(TAG, "eglMakeCurrent failed: 0x" + Integer.toHexString(EGL14.eglGetError())); + return false; + } + return true; + } + + void release() { + if (textureId != 0) { + int[] t = new int[] {textureId}; + GLES20.glDeleteTextures(1, t, 0); + textureId = 0; + } + if (program != 0) { + GLES20.glDeleteProgram(program); + program = 0; + } + if (eglDisplay != EGL14.EGL_NO_DISPLAY) { + EGL14.eglMakeCurrent(eglDisplay, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); + if (eglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(eglDisplay, eglSurface); + } + if (eglContext != EGL14.EGL_NO_CONTEXT) { + EGL14.eglDestroyContext(eglDisplay, eglContext); + } + EGL14.eglTerminate(eglDisplay); + } + eglDisplay = EGL14.EGL_NO_DISPLAY; + eglContext = EGL14.EGL_NO_CONTEXT; + eglSurface = EGL14.EGL_NO_SURFACE; + } + + private static EGLConfig chooseConfig(EGLDisplay display) { + int[] attribList = { + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL_RECORDABLE_ANDROID, 1, + EGL14.EGL_NONE + }; + EGLConfig[] configs = new EGLConfig[16]; + int[] numConfigs = new int[1]; + if (!EGL14.eglChooseConfig(display, attribList, 0, configs, 0, configs.length, numConfigs, 0) + || numConfigs[0] <= 0) { + throw new IllegalStateException("eglChooseConfig failed (recordable)"); + } + for (int i = 0; i < numConfigs[0]; i++) { + int[] recordable = new int[1]; + if (EGL14.eglGetConfigAttrib(display, configs[i], EGL_RECORDABLE_ANDROID, recordable, 0) + && recordable[0] != 0) { + return configs[i]; + } + } + return configs[0]; + } + + private static int buildProgram(String vertexSource, String fragmentSource) { + int vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource); + int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + int program = GLES20.glCreateProgram(); + GLES20.glAttachShader(program, vs); + GLES20.glAttachShader(program, fs); + GLES20.glLinkProgram(program); + int[] link = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, link, 0); + if (link[0] == 0) { + String err = GLES20.glGetProgramInfoLog(program); + GLES20.glDeleteProgram(program); + throw new IllegalStateException("Program link failed: " + err); + } + GLES20.glDeleteShader(vs); + GLES20.glDeleteShader(fs); + return program; + } + + private static int compileShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] compiled = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); + if (compiled[0] == 0) { + String err = GLES20.glGetShaderInfoLog(shader); + GLES20.glDeleteShader(shader); + throw new IllegalStateException("Shader compile failed: " + err); + } + return shader; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java new file mode 100644 index 0000000..11e839e --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/calibration/TvCalibrationGenerator.java @@ -0,0 +1,159 @@ +package com.foxx.androidcast.sender.calibration; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; + +import com.foxx.androidcast.R; + +/** + * Draws a TV calibration chart (color bars + pluge) with gray reference areas transparent. + */ +public final class TvCalibrationGenerator { + private static final int[] BAR_COLORS = { + Color.WHITE, Color.YELLOW, Color.CYAN, Color.GREEN, + Color.MAGENTA, Color.RED, Color.BLUE + }; + + private TvCalibrationGenerator() {} + + public static void drawChart(Canvas canvas, int width, int height, Context context) { + if (context == null) { + drawProceduralChart(canvas, width, height); + return; + } + canvas.drawColor(Color.BLACK); + Bitmap ref = loadReference(context); + if (ref != null) { + drawReferenceWithTransparentGray(canvas, ref, width, height); + ref.recycle(); + return; + } + drawProceduralChart(canvas, width, height); + } + + private static Bitmap loadReference(Context context) { + try { + return BitmapFactory.decodeResource(context.getResources(), R.drawable.calibration_chart_reference); + } catch (Exception e) { + return null; + } + } + + private static void drawReferenceWithTransparentGray(Canvas canvas, Bitmap ref, int w, int h) { + Bitmap scaled = Bitmap.createScaledBitmap(ref, w, h, true); + Bitmap keyed = scaled.copy(Bitmap.Config.ARGB_8888, true); + scaled.recycle(); + int[] pixels = new int[w * h]; + keyed.getPixels(pixels, 0, w, 0, 0, w, h); + for (int i = 0; i < pixels.length; i++) { + int p = pixels[i]; + int r = (p >> 16) & 0xff; + int g = (p >> 8) & 0xff; + int b = p & 0xff; + if (isGray(r, g, b)) { + pixels[i] = 0; + } + } + keyed.setPixels(pixels, 0, w, 0, 0, w, h); + canvas.drawBitmap(keyed, 0, 0, null); + keyed.recycle(); + } + + private static boolean isGray(int r, int g, int b) { + return Math.abs(r - g) < 25 && Math.abs(g - b) < 25 && r > 40 && r < 200; + } + + private static void drawProceduralChart(Canvas canvas, int width, int height) { + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + int barH = (int) (height * 0.55f); + int barW = width / BAR_COLORS.length; + for (int i = 0; i < BAR_COLORS.length; i++) { + paint.setColor(BAR_COLORS[i]); + canvas.drawRect(i * barW, 0, (i + 1) * barW, barH, paint); + } + int lowerTop = barH; + int cols = 8; + int cellW = width / cols; + for (int c = 0; c < cols; c++) { + int level = 255 - (c * 255 / (cols - 1)); + if (c == 0 || c == cols - 1) { + continue; + } + paint.setColor(Color.rgb(level, level, level)); + canvas.drawRect(c * cellW, lowerTop, (c + 1) * cellW, height, paint); + } + paint.setColor(Color.WHITE); + canvas.drawRect(0, lowerTop, cellW, height, paint); + paint.setColor(Color.BLACK); + canvas.drawRect((cols - 1) * cellW, lowerTop, width, height, paint); + } + + /** Blue/white vertical stripe border with optional VU-driven stripe width (8dp border). */ + public static void drawAnimatedBorder(Canvas canvas, int width, int height, float density, + int scrollPx, float vuLevel, Context context) { + drawChart(canvas, width, height, context); + drawBorderOverlay(canvas, width, height, density, scrollPx, vuLevel); + } + + /** Draws only the animated border (chart must already be on the canvas / bitmap). */ + public static void drawBorderOverlay(Canvas canvas, int width, int height, float density, + int scrollPx, float vuLevel) { + int borderPx = Math.max(2, Math.round(8f * density)); + int stripe = Math.max(1, Math.round(1f * density)); + int maxScroll = width + height; + int offset = scrollPx % Math.max(1, maxScroll); + float vu = Math.max(0f, Math.min(1f, vuLevel)); + int blueWidth = Math.max(stripe, Math.round(stripe + vu * borderPx * 1.35f)); + drawStripeFrame(canvas, width, height, borderPx, stripe, blueWidth, offset); + } + + private static void drawStripeFrame(Canvas canvas, int width, int height, int border, + int stripe, int blueWidth, int offset) { + Paint paint = new Paint(); + int cycle = stripe + blueWidth; + RectF top = new RectF(0, 0, width, border); + RectF bottom = new RectF(0, height - border, width, height); + RectF left = new RectF(0, 0, border, height); + RectF right = new RectF(width - border, 0, width, height); + fillStripes(canvas, paint, top, true, offset, cycle, blueWidth); + fillStripes(canvas, paint, bottom, true, offset, cycle, blueWidth); + fillStripes(canvas, paint, left, false, offset, cycle, blueWidth); + fillStripes(canvas, paint, right, false, offset, cycle, blueWidth); + } + + private static void fillStripes(Canvas canvas, Paint paint, RectF area, boolean horizontal, + int offset, int cycle, int blueWidth) { + if (horizontal) { + int h = Math.max(1, Math.round(area.height())); + for (float x = area.left; x < area.right; ) { + int pos = (int) (x + area.top + offset); + int color = stripeColor(pos, cycle, blueWidth); + float next = Math.min(area.right, x + 1f); + paint.setColor(color); + canvas.drawRect(x, area.top, next, area.top + h, paint); + x = next; + } + } else { + int w = Math.max(1, Math.round(area.width())); + for (float y = area.top; y < area.bottom; ) { + int pos = (int) (area.left + y + offset); + int color = stripeColor(pos, cycle, blueWidth); + float next = Math.min(area.bottom, y + 1f); + paint.setColor(color); + canvas.drawRect(area.left, y, area.left + w, next, paint); + y = next; + } + } + } + + private static int stripeColor(int pos, int cycle, int blueWidth) { + int m = pos % cycle; + return m < blueWidth ? Color.rgb(30, 144, 255) : Color.WHITE; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java new file mode 100644 index 0000000..434b4b7 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/sender/camera/CameraCastCapture.java @@ -0,0 +1,156 @@ +package com.foxx.androidcast.sender.camera; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import android.util.Size; +import android.view.Surface; + +import java.util.Collections; + +/** Camera2 preview into the video encoder input {@link Surface}. */ +public final class CameraCastCapture { + private static final String TAG = "CameraCastCapture"; + + private final Context appContext; + private HandlerThread thread; + private Handler handler; + private CameraDevice camera; + private CameraCaptureSession session; + private String cameraId; + + public CameraCastCapture(Context context) { + appContext = context.getApplicationContext(); + } + + public void start(Surface encoderSurface, int targetWidth, int targetHeight) throws CameraAccessException { + stop(); + thread = new HandlerThread("CameraCast"); + thread.start(); + handler = new Handler(thread.getLooper()); + CameraManager mgr = appContext.getSystemService(CameraManager.class); + cameraId = chooseCameraId(mgr); + if (cameraId == null) { + throw new CameraAccessException(CameraAccessException.CAMERA_ERROR, + "No back camera available"); + } + Size preview = choosePreviewSize(mgr, cameraId, targetWidth, targetHeight); + Log.i(TAG, "Opening camera " + cameraId + " preview " + preview.getWidth() + "x" + preview.getHeight()); + mgr.openCamera(cameraId, new CameraDevice.StateCallback() { + @Override + public void onOpened(CameraDevice device) { + camera = device; + try { + createSession(device, encoderSurface, preview); + } catch (CameraAccessException e) { + Log.e(TAG, "Session failed", e); + stop(); + } + } + + @Override + public void onDisconnected(CameraDevice device) { + device.close(); + camera = null; + } + + @Override + public void onError(CameraDevice device, int error) { + Log.e(TAG, "Camera error " + error); + device.close(); + camera = null; + } + }, handler); + } + + private void createSession(CameraDevice device, Surface encoderSurface, Size preview) + throws CameraAccessException { + CaptureRequest.Builder builder = device.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + builder.addTarget(encoderSurface); + device.createCaptureSession(Collections.singletonList(encoderSurface), + new CameraCaptureSession.StateCallback() { + @Override + public void onConfigured(CameraCaptureSession captureSession) { + session = captureSession; + try { + builder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO); + captureSession.setRepeatingRequest(builder.build(), null, handler); + Log.i(TAG, "Camera preview running"); + } catch (CameraAccessException e) { + Log.e(TAG, "Repeating request failed", e); + } + } + + @Override + public void onConfigureFailed(CameraCaptureSession captureSession) { + Log.e(TAG, "Camera session configure failed"); + } + }, handler); + } + + private static String chooseCameraId(CameraManager mgr) throws CameraAccessException { + for (String id : mgr.getCameraIdList()) { + CameraCharacteristics chars = mgr.getCameraCharacteristics(id); + Integer facing = chars.get(CameraCharacteristics.LENS_FACING); + if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) { + return id; + } + } + String[] ids = mgr.getCameraIdList(); + return ids.length > 0 ? ids[0] : null; + } + + private static Size choosePreviewSize(CameraManager mgr, String id, int targetW, int targetH) + throws CameraAccessException { + CameraCharacteristics chars = mgr.getCameraCharacteristics(id); + StreamConfigurationMap map = chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (map == null) { + return new Size(targetW, targetH); + } + Size[] sizes = map.getOutputSizes(SurfaceTexture.class); + if (sizes == null || sizes.length == 0) { + return new Size(targetW, targetH); + } + Size best = sizes[0]; + long bestScore = Long.MAX_VALUE; + for (Size s : sizes) { + long score = Math.abs((long) s.getWidth() * s.getHeight() - (long) targetW * targetH); + if (score < bestScore) { + bestScore = score; + best = s; + } + } + return best; + } + + public void stop() { + if (session != null) { + try { + session.close(); + } catch (Exception ignored) { + } + session = null; + } + if (camera != null) { + try { + camera.close(); + } catch (Exception ignored) { + } + camera = null; + } + if (thread != null) { + thread.quitSafely(); + thread = null; + } + handler = null; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java new file mode 100644 index 0000000..a9f2ac4 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -0,0 +1,204 @@ +package com.foxx.androidcast.stats; + +import android.content.Context; + +import com.foxx.androidcast.CastSettings; +import com.foxx.androidcast.network.control.NetworkStatsSnapshot; +import com.foxx.androidcast.network.transport.TransportLossStats; + +import org.json.JSONObject; + +/** + * Collects anonymous per-session aggregates; persisted via {@link SessionStatsStore}. + */ +public final class SessionStatsRecorder { + private final long startedMs = System.currentTimeMillis(); + private final String role; + private final String transport; + private String videoCodec = ""; + private String audioCodec = "AAC"; + private String captureMode = ""; + private String resolution = ""; + private int clientCount = 1; + private int disruptCount; + private int idleTimeouts; + private int networkSamples; + + private long sumRttMs; + private long sumLossRatioMicro; // loss * 1e6 + private long sumVideoBitrateKbps; + private long sumAudioBitrateKbps; + private long sumEncodeFpsMicro; + private long sumDecodeFpsMicro; + private int bitrateSamples; + private int fpsSamples; + + private final TransportLossStats lossTotals = new TransportLossStats(); + + public SessionStatsRecorder(String role, String transport) { + this.role = role; + this.transport = transport != null ? transport : "unknown"; + } + + public void setClientCount(int count) { + if (count > 0) { + clientCount = count; + } + } + + public void setCodecs(String videoMime, String audioMime) { + if (videoMime != null) { + videoCodec = videoMime; + } + if (audioMime != null) { + audioCodec = audioMime; + } + } + + public void setCaptureMode(String mode) { + captureMode = mode != null ? mode : ""; + } + + public void setResolution(int w, int h) { + if (w > 0 && h > 0) { + resolution = w + "x" + h; + } + } + + public void noteDisrupt(String reason) { + disruptCount++; + } + + public void noteIdleTimeout() { + idleTimeouts++; + disruptCount++; + } + + public void sampleNetwork(NetworkStatsSnapshot local, NetworkStatsSnapshot peer) { + networkSamples++; + NetworkStatsSnapshot s = local != null ? local : peer; + if (s == null) { + return; + } + if (s.rttMs > 0) { + sumRttMs += s.rttMs; + } + if (s.lossRatio > 0) { + sumLossRatioMicro += (long) (s.lossRatio * 1_000_000); + } + if (s.sendBitrateKbps > 0) { + sumVideoBitrateKbps += s.sendBitrateKbps; + bitrateSamples++; + } else if (s.recvBitrateKbps > 0) { + sumVideoBitrateKbps += s.recvBitrateKbps; + bitrateSamples++; + } + if (s.encodeFps > 0) { + sumEncodeFpsMicro += (long) (s.encodeFps * 1000); + fpsSamples++; + } + if (s.targetBitrateKbps > 0) { + sumAudioBitrateKbps += Math.min(s.targetBitrateKbps, 256); + } + } + + /** Final transport/app loss snapshot for this session (not accumulated over time). */ + public void mergeLoss(TransportLossStats loss) { + if (loss == null) { + return; + } + synchronized (lossTotals) { + lossTotals.datagramsSent = loss.datagramsSent; + lossTotals.datagramsReceived = loss.datagramsReceived; + lossTotals.datagramsInvalid = loss.datagramsInvalid; + lossTotals.fragmentsReceived = loss.fragmentsReceived; + lossTotals.fragmentsDuplicate = loss.fragmentsDuplicate; + lossTotals.reassemblyCompleted = loss.reassemblyCompleted; + lossTotals.reassemblyIncomplete = loss.reassemblyIncomplete; + lossTotals.reassemblySuperseded = loss.reassemblySuperseded; + lossTotals.wirePacketsDropped = loss.wirePacketsDropped; + lossTotals.malformedPackets = loss.malformedPackets; + lossTotals.sendDroppedVideoP = loss.sendDroppedVideoP; + lossTotals.sendDroppedAudio = loss.sendDroppedAudio; + lossTotals.recvVideoSkippedAwaitingKey = loss.recvVideoSkippedAwaitingKey; + lossTotals.recvVideoDecodeErrors = loss.recvVideoDecodeErrors; + lossTotals.recvAudioDecodeErrors = loss.recvAudioDecodeErrors; + lossTotals.recvAudioQueueDrops = loss.recvAudioQueueDrops; + lossTotals.protectionMode = loss.protectionMode; + } + } + + public void mergeSettings(CastSettings settings) { + if (settings == null) { + return; + } + captureMode = settings.getCaptureMode() != null ? settings.getCaptureMode().name() : captureMode; + if (settings.getNegotiatedVideoMime() != null) { + videoCodec = settings.getNegotiatedVideoMime(); + } + } + + public void finish(Context context) { + long endedMs = System.currentTimeMillis(); + try { + JSONObject root = new JSONObject(); + root.put("schema_version", 1); + root.put("started_at_epoch_ms", startedMs); + root.put("ended_at_epoch_ms", endedMs); + root.put("duration_ms", endedMs - startedMs); + root.put("role", role); + root.put("transport", transport); + root.put("client_count", clientCount); + root.put("disrupt_count", disruptCount); + root.put("idle_timeout_count", idleTimeouts); + root.put("video_codec", videoCodec); + root.put("audio_codec", audioCodec); + root.put("capture_mode", captureMode); + root.put("resolution", resolution); + root.put("network_samples", networkSamples); + + if (networkSamples > 0) { + root.put("avg_rtt_ms", sumRttMs / Math.max(1, networkSamples)); + root.put("avg_loss_ratio", sumLossRatioMicro / (1_000_000.0 * Math.max(1, networkSamples))); + } + if (bitrateSamples > 0) { + root.put("avg_video_bitrate_kbps", sumVideoBitrateKbps / bitrateSamples); + } + if (fpsSamples > 0) { + root.put("avg_encode_fps", sumEncodeFpsMicro / (1000.0 * fpsSamples)); + } + + JSONObject protection = new JSONObject(); + protection.put("mode", lossTotals.protectionMode.name()); + protection.put("fec", "stub_disabled"); + protection.put("nack", "stub_disabled"); + protection.put("fec_stub_packets", lossTotals.fecPacketsStub); + protection.put("nack_stub_requests", lossTotals.nackRequestsStub); + root.put("stream_protection", protection); + + JSONObject udp = new JSONObject(); + synchronized (lossTotals) { + udp.put("datagrams_tx", lossTotals.datagramsSent); + udp.put("datagrams_rx", lossTotals.datagramsReceived); + udp.put("datagrams_invalid", lossTotals.datagramsInvalid); + udp.put("fragments_rx", lossTotals.fragmentsReceived); + udp.put("fragments_duplicate", lossTotals.fragmentsDuplicate); + udp.put("reassembly_ok", lossTotals.reassemblyCompleted); + udp.put("reassembly_lost", lossTotals.reassemblyIncomplete + lossTotals.reassemblySuperseded); + udp.put("wire_dropped", lossTotals.wirePacketsDropped); + udp.put("malformed", lossTotals.malformedPackets); + udp.put("send_dropped_video_p", lossTotals.sendDroppedVideoP); + udp.put("send_dropped_audio", lossTotals.sendDroppedAudio); + udp.put("recv_video_skipped_awaiting_key", lossTotals.recvVideoSkippedAwaitingKey); + udp.put("recv_video_decode_errors", lossTotals.recvVideoDecodeErrors); + udp.put("recv_audio_decode_errors", lossTotals.recvAudioDecodeErrors); + udp.put("recv_audio_queue_drops", lossTotals.recvAudioQueueDrops); + udp.put("recv_damaged_video_frames", lossTotals.recvDamagedVideoFrames); + } + root.put("udp", udp); + + SessionStatsStore.saveSession(context, startedMs, root); + } catch (Exception ignored) { + } + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java new file mode 100644 index 0000000..c0a4f24 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsStore.java @@ -0,0 +1,73 @@ +package com.foxx.androidcast.stats; + +import android.content.Context; + +import com.foxx.androidcast.CastConfig; + +import org.json.JSONObject; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +/** Rotating flat JSON session logs in app-private storage. */ +public final class SessionStatsStore { + private static final String DIR_NAME = "session_stats"; + private static final String PREFIX = "session_"; + private static final String SUFFIX = ".json"; + + private SessionStatsStore() {} + + public static File statsDir(Context context) { + return new File(context.getApplicationContext().getFilesDir(), DIR_NAME); + } + + /** Keep at most {@link CastConfig#SESSION_STATS_MAX_FILES} - 1 files before a new session. */ + public static void pruneOnAppStart(Context context) { + pruneDirectory(statsDir(context), CastConfig.SESSION_STATS_MAX_FILES); + } + + /** Deletes oldest session JSON files until count is below {@code maxFiles}. */ + static void pruneDirectory(File dir, int maxFiles) { + if (dir == null || !dir.exists()) { + return; + } + File[] files = dir.listFiles((d, name) -> name.startsWith(PREFIX) && name.endsWith(SUFFIX)); + if (files == null || files.length == 0) { + return; + } + List list = new ArrayList<>(); + Collections.addAll(list, files); + Collections.sort(list, Comparator.comparing(File::getName)); + while (list.size() >= maxFiles) { + File oldest = list.remove(0); + if (!oldest.delete()) { + break; + } + } + } + + public static void saveSession(Context context, long startedMs, JSONObject json) throws Exception { + File dir = statsDir(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IllegalStateException("Cannot create session_stats dir"); + } + String name = PREFIX + formatTimestamp(startedMs) + SUFFIX; + File out = new File(dir, name); + byte[] bytes = json.toString(2).getBytes(StandardCharsets.UTF_8); + try (FileOutputStream fos = new FileOutputStream(out)) { + fos.write(bytes); + } + } + + private static String formatTimestamp(long epochMs) { + return new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date(epochMs)); + } +} diff --git a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java index 93f48d7..bf3f6c5 100644 --- a/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java +++ b/app/src/main/java/com/foxx/androidcast/ui/AspectRatioFrameLayout.java @@ -29,12 +29,19 @@ public class AspectRatioFrameLayout extends FrameLayout { @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (aspectRatio <= 0f || getChildCount() == 0) { - return; - } int parentW = MeasureSpec.getSize(widthMeasureSpec); int parentH = MeasureSpec.getSize(heightMeasureSpec); + if (getChildCount() == 0) { + setMeasuredDimension(parentW, parentH); + return; + } + if (aspectRatio <= 0f) { + int fullW = MeasureSpec.makeMeasureSpec(parentW, MeasureSpec.EXACTLY); + int fullH = MeasureSpec.makeMeasureSpec(parentH, MeasureSpec.EXACTLY); + measureChild(getChildAt(0), fullW, fullH); + setMeasuredDimension(parentW, parentH); + return; + } int childW; int childH; float parentRatio = (float) parentW / parentH; @@ -56,6 +63,10 @@ public class AspectRatioFrameLayout extends FrameLayout { if (getChildCount() == 0) { return; } + if (aspectRatio <= 0f) { + getChildAt(0).layout(left, top, right, bottom); + return; + } int parentW = right - left; int parentH = bottom - top; int childW = getChildAt(0).getMeasuredWidth(); diff --git a/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java new file mode 100644 index 0000000..80b05f6 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/ui/PlaybackTextureTransform.java @@ -0,0 +1,41 @@ +package com.foxx.androidcast.ui; + +import android.graphics.Matrix; +import android.view.TextureView; + +/** + * Letterboxes decoded video in a {@link TextureView}. + * Uses view-space scale around center (same approach as ExoPlayer), not pixel {@link Matrix#setRectToRect}. + */ +public final class PlaybackTextureTransform { + private PlaybackTextureTransform() {} + + public static void applyFitCenter(TextureView view, int videoWidth, int videoHeight) { + if (view == null || videoWidth <= 0 || videoHeight <= 0) { + return; + } + int viewW = view.getWidth(); + int viewH = view.getHeight(); + if (viewW <= 0 || viewH <= 0) { + return; + } + float videoAspect = (float) videoWidth / videoHeight; + float viewAspect = (float) viewW / viewH; + float scaleX = 1f; + float scaleY = 1f; + if (videoAspect > viewAspect) { + scaleY = viewAspect / videoAspect; + } else { + scaleX = videoAspect / viewAspect; + } + Matrix matrix = new Matrix(); + matrix.setScale(scaleX, scaleY, viewW / 2f, viewH / 2f); + view.setTransform(matrix); + } + + public static void clearTransform(TextureView view) { + if (view != null) { + view.setTransform(new Matrix()); + } + } +} diff --git a/app/src/main/res/drawable-nodpi/calibration_chart_reference.png b/app/src/main/res/drawable-nodpi/calibration_chart_reference.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0fa7b6a1d9d6bb0dcab838ec2dfbe84f0012aa GIT binary patch literal 175302 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%0D6&7bWp+N0p zL61|ayor)S3q|HVjOp}%aulKdxZo_gg33=Y5I7qwoW$VFNNVC3+BC8d!S8DI$BU9n zvgi0T_6X+bUl%H5bWij_TVC_nigLMLu&PbRKPk)#T6S|AG=jS+&dPh$yzlaJC>jBJ22S66LfW{*jL&Eo2 z$8JoI$*9#oI!uY>Zi!bh!*y2ov5>Oh59H{ne=M^hn(UY~l$f)&nN|hANW*i+5c2k>w{sMv(@9G< z3|-@`P@PM=h9c(vUAv1JW+f8Kby>1w>ht-p?PaL!Z}0`?UsU}ogloKGPcW%^uB7kT z_uj-`-rI7;*gnuM^8E{2&v)k?<+@@#_f~mFo##BLlL~l0krun^JN}thQ5h_K*Yx?mIUrt zt2;)#jyGm==;r-W^i{IE20m!;E#pRP#n%i*_lJF1Vwlr^$Kx1XOSKKuFBip`s_cBG z9I*^tXFZgwJ>aRQw8q=H#k|O_Zji zn&F0$l7S?qwZS2>0WzE#TT(?uMd*~nFpt$}4`5UoTgtA=634Yjj@^QFyYr|kS2l+A zO|Aay#w)Y;)BG;wbARNg9?GA`w2k4q>-kCr8#hu0&9oSPe|OJOzE;o+aoK_=YU`G& zVwutzaMlgPDJ(&u6bKR_J$8}&@H|?8*7B$wl9tX5Y$99vBC+D_OdGwNWj891SH$*A z2~3~2`Q4fxok>|%8qU7YCr4?wjvMrMD*oG(t}!XNPCJ?Z2?v~%F1|VaZgAnWTGB6{ z#d1pK;n8k88J(_k z6G-9yF6D3rm+3nNw4uu%I1LN(E8>E_)o?z@l9FEk{I*G zx7%h_&0$UfFoEw8yibFoh!Fk|&3|nb`fm@S&yY=+8@I29mV|7(k>>PWw)+&b9X4-kcVyyii@&_No{i>9S6p_CI1_2E- zm}Kf52ZG-^G#r!84|oKsv{-bYW_}8^OtI3}b z)8P5gY7&*AF}VI)yJbdorRde!0Q~3lrual(&%*W$WsYxZeQ!0p2F)kq;64+(vZNl+ zA^C!E#r7zp&y9k6{ZFljq8O99lQQZ;k{a&h1H(s~mV%hqoT5#C`_k=RW`L^0k+i1E zXTE(0kc|WI6;1mhiMwg+(mc*G(GJK|+MyXvU|j^FvajD=?3w|h3$^6k{8KKMj{VEM z`y|{&FFN0#GlrYfz(zAbh&Ydt^4wT|V;T^mG}1Sx&oB!tLC3UMcQR~`U;TgFEbgKn zNC5~zbpNF}l{^DJeRn?0Q4CDrW;a2jv13XY;mZ3uq?sw2599`3P@|_<{E3MJj4S*# z0CQfty8B2%!!;SCiB3_uD5gn3V6%EmB?kEqj3=I%Viween9two-{r=tE7{3Sjb)AT zDUBQ@s?U)FbR;K=T$R$iK72|rTdbVjZD&VAZc#G7*+&aRoyyO{6IZ;McW`D*FG1Zca=5( z9jdd2b?$+{MI{OeL;`-|wxJ^g5#PNl&L(+JyeOma`2kGINWouCb8}G5F_cdvOHe_E zYbJ5jodY5ZoA8G?)dULUvNacX&L=~ZEwYAhB&gO;ZP=F!$aQG}{j^R|e--o=1s*oK z>*nEU{+BhbR;7M;sd_OtXc&y0``Oqo-O9>|Y#bc@Kp#E>-n#=GPHtczZ1GyBF((@= zgQq?X4T*D5$N)ozr(lZw0=RC3cgY9LgPItvl=5iZz>p#^Ni&?YXWX5cO2DKAAdbMa*nO(2#DQRxe9st;p&oDJx}X?UPX|%1$H19$2=X+m0SjL^BnoH8Whtn)b^jL;fBM5 zdkO~}(8R%Eymnj*|I2^>u?#l=*)X@ddc0AAmNMwuSLk)^?uahc{q(xABXeJN1k!gF zu*D<+DtEIhl&4SFJ2=<`*QM5apzlf&N?oriBvAhv2O0PbPChO37mNfyV$;&goAaW39C1AVQ1L6t=%r4}VdsNsW!ejpilk8CHPd-i2>M(=8zP1_N)SyI1f$!HEf&KB!Y+GQt00bFOA+d9 zhOj>t+od$@5!1f~HaqA}uiTF211=tPF-l82H@d?zJ+rLIR?ysL zu$Vy*#MPDW`gkb}2-{!at=aEkN*|_WqDVr3e{{6kN_y~`Vgb!A=_JIG=H+{O`ozjg z6tGeyyB0s|R()V5h{y#y9-JYlJ-&H%TYYHA6L8>N`k@xs@@c!wYkj6%yu7?QAcU>{ zly)guS#y7481=5o^&m9)(kMy9?ijcS(6<3al&b&~R@eXt2?+^wFll**X}iF#3K&(- zfYOfxmv@ZW@SJJl0A#|9_x?HSii_;%lQ8~#cbZPH84g3YfXMTyQ1lX*h&b3kJ=`E5 z@mT^!^%xYt2Lsp5W~C_!SXI9-CeuI@1*2(U9-pmRImsYawlcFnz!_7Ra-sidYy0Cr zpQhih$3PVa3Vn#4eQOdS+eazTi+Z7{X)tPuCk}E1j@wDvc?}Ij+4s}M9-V=&Jnptr zeu7aZaI5Q3Vk{1n#%VCS{qlVBvcoef2p1!E&j_C0|2rwrO&)>iIRTOy{y;tjp(s{l){h;>3BssspuPZ6%|gK;Yg%O{e(Zfin}`RsgmT%>|pgnnE2> z;I4rk6+Swu1RwNv*XQiRRlcW%C_Kk+`@HGbBju}{Npg_#aBaYaR&(#8$Bjf$ttIzz zZnQNWq)PQkxu##wz2lR1(RXQ!T?>A$>0`S?Yr+||Y$=3BZ2eu`cH5CMh8R}mKAz}u8RWUL;k0f7flr9jn*Bo)8_sZbCEdJX){YEUI2LHZe- zDJ8$<_{nKc5TD@$0zdS}!N|j?g$xZtuvKj)drFkyq%W9Uh9>djx6KCMu&}dB1L(O_ z7o}&{7(x>VFwlNY2N2e7{!z!tE>n2;5gNHT@!l1=gyswUK)7feOu&x-z>otivL{W4 z>`g#A(W&q=m_YjJ@?T3?8H&V*Trep4&d&HhYwNd+c;M3y`hoL3P{RsLYDr+cdW%tl z8C?wkQ_0W}tVc71q@%iRvZYrx@T_iZJfLz8gwt>$IP@}>!h6vvMO&F|Utt>5l z3vLp8JDI~sDcQo&(UA+(pK&GU$rnBDX5Ruz2ER#95&w3 z2orp}+=LDdfsq4$V35kXuX-}rgYn5xkrPqF28Z0*=^U?!GO9$YB5N8l46~Ic*A}}K zVZeo;F^7N4jdZXQLmz5BfsmE_z~Rvm6ortXw&v!-fKeK}>whcq&})LauK@l+wKFr2 z=(Nst5(rYnpRF0yR#)#1HCYFPmvkOh+?>;5ACp6X$N#vm><4X}r+`vjSczQ_Ll^J1 zzWs^KX9Ly9_&0R9k8He>{=VB_!`2Fy>e{Ha_c_#a0fYSKQ0X3sq&(1s93Dm(jK|XG z`^1m4olc6K%Jkex^9G+nFmB+!(#w$T%{{2*Uum=A5y*(|tq`!W~%n~_OoAu@0k{sfk?C-j>|B~;ddu*2nrh*Me_6pliU-a z6?xvBZ@Ww>3J7_P^01*Etj$&ENqv3Z}lR-HtzkvliTT{kv-8U-QWh zwA2w@P zFi)9^Gv0wpkwr6cu#5(TRgk5*1=;D&D1p`gk#yeiShsB$w~}PElt_eB5~2{2B$1IB zvXWJbWRGNosO+q)>{XQPjG`n&8A-^9tYkzY^d8sq{?q6Ay!Dp*zJK?1o#%0WkK=5x zZF0CIVCcEfsq*^il-Y&|)D%#f@!fC%XqL=FOU)9^xn|6D`OiiDX#Y0Z0~H<~33AXz z$yw+7>f!~yXRfV<3VT(gohGyaJWz-2emFm|m-hBa_~MRrH{(-;^5kowFM+pp46Krx z*7pjllyme;Mt#q7^HmknG~Ui&3}o6FxrdwJabNE|p2R%es_FBRrADJiw1QqLuKQ^DiMuTj1c$c&!$_I{waW8@1=%z$8C6RuKLeK4+UB1FpR z)Bd6@_^eQpC>a>EXeUbw4n@IdD4BE}{pohlRH#3%G^#D~_NEG*+S#OSZ2WV^5DW!g zPkUvVO3~J}P5wU@|K^|x${|L?!gX!$o1A{Rzs}?1770+(-vvGsHTRv#8NBQNz3>QS!97idA9{N3;q*#PN%1*&@Sxk<5Dqvg;&$RzfPSEo7Sy9x_MXuP zU*Gnlh2IsfFD%viZ{(;J+P{AQ-kF?x@@~Z?B|`-AWJc!-?g#UcH1zuN!SgF{@wh{B zk@$8PGfKREIUEt9Ng{)M8dThJ4i3-1J=wl}`=#aiaczD5prN6m1nImr!LL;f735|I zcE^A4Ajh<%mcG$;e9XW@B3=D8ucN}dz}Y$5;0L1`MZ(N=RJ<_ujSggf8~lAh;p+!y z0lY&FGr)^ZqE-d!GX}Bz3;>|^63HF%U&j^p*!^(55cE3g!2^kpN5T)_?`!cunfY;r$Dq(TP%N-< z;|{@LWx;Yka-&}TB(UXzvSSViq3(B)!L9Q*&bv6mSMnqCM*NGpeEMh$3Kd~SPT38g z6KM8s47>l^catv*(;W?U^#K*CXWTSyQHOnBJ&Kg&PMn4(Lr&Y>K6uAf13owlZN8L* z4-H+K7$1K{up|)*xchM4&!jMW(!J&Ml4fLJAf2N$5U+iX{08}WZ#6VEBNV8O6q_6t^h=yh zCNjlc&ge2SF*zZiaRp<@%U7F$R~Hmu26;7EN-0Nki}f^VP+68s5}2KzVLm;^;-Dd zn>6170rA{QTm9`I@kjPcMNRkpQ9dfSt`rYn)_udj2byduq#GYlbI<8-%bQU_HL$R_ zX!b5*uO2Szr~9*WpLU$mb?nY!>jE9CrXRHJto{x9PD4&^`)IZF`^~z8QSh!=8Lm4s zUQ9^IsR-S~&RDTRn!c%9dUG@BWpK9UVthM#q{=Ck$0Q zw=mqk*rODi=_4;4(y{DLXD+NFc;>@Q-Q3d69EM+d9moC{J?7vT1S-9c-H1bdR^BOn z?w#!R0=TS(7mDZzE{;3sZGh>fu+(BdiOvRj!7wGRo#Vcfu)Oq?B$gyL zFczf9G^hg|*Z28%%KQ)+#2 zsng;s*&hrIfv7ByD2U0X4TT(d91^+mN#E)Qp1R1FdUz_@MYp;)X4z6q7?mpZWls+J-jm6+bvU#;lxIs20xHzXOgIdLnpF91K=URzZwd0#irKL1)w&c%xb z1M(feU*=xCOGn+mD#WB@xnp?G&PA2;h4FWiY;sOF|hx5;mx@>r-cqqPwc*X zQ@0A%6dp=kANN%^C*BvkB*CjadnCH-yP7s}r2fm(0XlO6bN zv@ISFrRz+3Q$o}*yCW8dSh#kn`4$AfkIywd*4Os|99$`<;{`1`|%tA!^%%u*CT&;D-o8y%Pf#~2e8vRRH zIh=@GXWd?rBkko&isI5zv#Bq|h6s8g9QGDfT=~EF`j#IA?Wp9J6!$;S|0|qX?v9}Y zmlnr$^EFHA#N_$QOcV(ye_MVldkO^6O-)UeEl=d-Kv(g?9B`bJ{I)f#&JT}V+0sNQ z;Lh3mNABpd=+3CPdR3cKCdO?&bDGx$Et(S=`;U#MNB$uc_pt#baT@zz13qJQH_1G$ z4eS@G8N3^4V`a^KH<&xbGJfU~zP2fXOD~{*y$GY7bd&|X*|6M2-bWV(FI~y`tL!sK zr5C#g2t6@d7&(4CZ3qRIbgQ=(U-S=RfMlokhAP11KG(orfvl|tj2m*M$M&cdUyl>G zUeKsyloqLeT~#$$ORR@xNtZ^4HE)kv-?uGcH`J?Gl}gwk)1v#@eD4hoz8g68z)A456W)Wg-Z%vfi zK|CY}3o7M^J_F~$H1D_b+S-pWKG-07Z%^2GgMOdzs(6`+WxA9E8v5PQF6nE1h%MWy zCP6vvIql&FOk_VMEFC#ov&sLKl4C`!Lax5Bbx?^)oY?i=^4jFhLpKEm#6HC_+z+3T zM~)nZQO~z!4gEU5({%hIMZLOaw5po(b{<-}6A}9<>S(r74%^R9p18!Fef1%?V^dSp z9es;j*-MrJCe;am^?fk&z~xQML&RZ&Q$OLhN{7t5i`lhek#_C=vEJ_EihK&1b;Z_F z)+P#6U*rc+6q;_8e=}mDlRaLayn9_l|8U&O6Fy?1GKJ>R+uN&h=+GhD$eRHp2fzjK zsa0NmUnyJW?x3tEb7?V!6}3)V?^D^Pd@!^rJ8)aYfycm&#boo3h?%4C;i#a|CYo*i zPOFQ62RdP0S!rVy-`(L5^_AhGC8_V1j=>ywyIq}T6 zjV6x%;E7q(_DLTqfjhVoqpEt+EW%_@p&7)Vdx_2TZd0u{HkBa zdzhQk?)i}{l{jA{*xkjo@o(vFe*O4y! zA>3=v<#QyZaz)l@8aP}>PDBN)O#gM_xTA}f`-U^Up-d#&iWLEEFua znA>(SPBi~eka{*VcRZ={J^dkme*5Fcg_56a`?o`u!%Tcq|6hDy!l8Zkvdz#y)Zx~H zHQ=k;=wP17D*sV2u{qej+d|krT{*Zg)li-oIRhn>y>h8#-l?xJTy=Pkq#VisTxKD4r+Y!))IGJ zj$;x7@QkqjdpE0bN&!+uqt@~1(;NvW!FVFUYi)@%_|iz0LICF(0|RzE0NsTGTck58 z{YL&Vjz3a3=Ckbtu2xAmriwdxd@@a%{ctM1#~G5TlXpHPCFNDQv5xuU38hI5C8IqX z1?@LQB%RD>AJmN59jDRX>a1-q^XPY4W@dnDJMUqHd6u4P(WKugOl|)8VrDc#znio9AO~R z21XDg8fglqD}QE5W)D$vTCSWAFqIf}QZ7>ndGz3ig?`M|`lzGY-1ckH?j3mwor@~% z52Ui&^A~rytJznYSI2aX)!)Agwv&n@e#7lswQ7*#3@9!xY%@Y|w%vQf%36K8oiRXq zn>(O|KO0T{P+^EjoMCTvRL`tSI^iYbim8$wz3ANGT?`Z9bFwPqg%q_Q?k#*HnxPN!(^B+?WJ{Z9a;l_7H3{$(&qGR2<{H*~kLwk2#>()J{9m2rl zRG}xZ{gyYFw2RfIjIfbr zITr+U9hhfqATfJI{H=x`aVduh;T(+9BmQ>ZyL*?obRPn{$AD*l^4O~nDF=zD zvz*jeGtvD^?jQP2{eBa?`fr|=f2`w>Jeko@RS0s`?@}gS$F>U2I zm{eq%Ub3r6pT@pD>E`-@AH|_CrCS_9vS-M8L51>!om}>8-VVh%{8nc~KGpIdmZ{~H z*maX^qq=os4HrL)?Ky2r&9GIL$~%17D*ma&mAdZ_uAXB}ivH*9?dL1cwSk(B$y!f` zC6j}L<3BNex@yt*A>5!RkfCq{k{p>}b_U(V%sdU?1rhX1s}~NrV~9jzcfpXNY31s_ z^>@l~?86>%z2Z_X0!(iCANKyCm^R^hT&($hxw*=rTOQL=4|WZ zw&i|sxM#UyLMv{zUc&iWX0K5%1}f-{i5!|5sU{f|bt*7HF)Y8iZ(HKc+*X}eJaX-M zCyx&6q}9}%c%pi2Mxp=qG;Jnv1Qot_ zSJRcg7qS-HQhiiauke@fFQxYJjx^;!&;*q4hgK~KN{e4TgoxJ>Z^%CSQD=eft1 zy)HMi*B1s}>R?pRS;7#7NKZBM8A2Z+Z2y<2=Q*xyGK5naqu`ux<-~LPmOL0Jt5?!E zR|E0AdWHlm^1q)W7c>U4ekn}`f28`Cm~6fjZIJg{{kzzX(e0o77e7-mWg?S9BtBin z(IqV`EQ<^pSRLMww6-g#nZ^({a_$^6;=Zr0AVm2-v_?pb!DMg6+n|eVJ#qNw3 zzwSJt>E(qYj)J0QZJ{913P)Q#WMxH@)d}65if-mLxa;+1Trm!yq^MbXe?39u#NY1L zfX!29PAqSxCa(2_z24#vD>E+yUw_TU(lAnG;qmWDp<&fb^1z;yGL*5ftpmm(BhVej zu6xLgC`sCF?&%ZDT<)%_Z3U~FTTjmM%A=lT7nL#&m_JqRv6*W^s;&CguOx5lA8wn^ zYG%<-e4R0L8oPVJXrq%y*VK#L2lM;s!z6#XBZr3-VQoVCg*2obS;bUG0X8$(6-jIY z9nG%FzKACsHT?{`o%2pj{H|Eb4GlM#%h)09cuHsDo&TL?e_UGwOQXqINUmTj?X}++ z80fX~jkEpOXhPj3R$NV)FsP$G`@)yUUQMj5;X@| z;z%1Oz?!IN$+hmGN|(_@7fE_|GLpP9lSFj2P#604qn?Ln zkWqYRE_;65j2Xpi+Q5K$iEb_t)2$a%Da5#+Zb0gcn1s`@jhiW-Wozm4?Trkg)!l*j7i#!qf`;Mw^8ucJf1ASSQDpvZOIQ52Ie)08_t}o#9Vv{v89AlxPCxdw zY6o-sq0}^w=gSh`>-H^wy;7Xe=o0aR&t(QOv?sNb_c41UMOryl&Qrai#p51>iyE2_!kjKfr!$p+CA%St;#w6O}SW^gIt7Y6) zYmbUOC*}1Z1(Gy`I>b%nl$FII@hv*KgN|ZhVd0|XE}5IGXw(%!eoarGfnW{`qK2Lx zU22C$2?h{2wPcYzj0O?T3{@g5E`PWq^O{uj$B`5RvAmu%vvf4Xk_UlxH2Vv#G0S;oPIJ=mNN5iSEL)xAZdA_xW{3v&6J<5GM#&S){$)O#Hki)UleD@TK z=lwOTnqw*Vr)Y6>p@c(7JN(UL7A^0G+4cHX5D;0_;OsVSMyU%5VYnzGDFv2M@^N7H=LPwZM!TukZxJcQTjH23CnNd{t@DlHDp;U2kC8k^H7v}5dRdQXYC&_Xt?|8A%7 z{?6Z(1Eci@6)tfqe2MaJP#X_nhA9n&s{)tIjoJ6*M)=s9(@!;X* z-&^{Z(vE@Z*k40)ehS3KNEI3kfPWBHZP!<@V`pZupH02*f8}9zLBaa4h<-diZtW&t zG}8>Iy6t$*#8F?|swB0cUHGH|l@f!mgb(lDaNeza40^fyCdIP1q(;VTBr}RQp%HA{Z+=PnlO+xUsQM|K z%JEA2AQDH}6uM=OL?{Nx$V{*XYHnrw{yb6f6L05_0Wfsdmo)Pw{lr8lhm&02N$5;2 zPV^~N*-J&vD^>Nij{YLj4jIWpg3og#*6mZLs6#nV+=`{r+Imn&vRZ?SyFn+6%Gk|> zVNZFRj;le$2i{W>?27+7ICh|ll9z98W)iwrsM}Zd7 zk>~3QK$jM=zqa4$&R_G;a)MrS!L$7Rdu68EZ2n=*4y%TaRZLMr9f~2=+p=ksSoh;P z27s(K^t75uXf;OijS^S|8t;YMp%}m5-?($=sCke5fx`N!s_ZgI(i|w;!Gr(9-esJRMLz;HW{Tne$LOjsKh}?E=TK zZx?LqW+f?UVzuIm0E~WDbbJWzYA%ISBL;Hf(%7wY9Y=6c5C8nRq3} zKYX}9CMMNmf9)8A9tYb*`a5;xOoz{>K0R9n@rJiZ#?U-m$0Vf;WxhHB0>EHv;B6;6 zBOF_fjoKju=z?46yP;2O?6Vy$eT;^mIoa#{+SDr zd`mnj2y2)_L;#Xz<$*vklj!67yN|PBevQA={(d50S2DFf$j^^D_GP4WWQoPjb1f3* z_#z47i8z8J=Wnsre_p&hIuI+c{km?%b2OcVngX4se?V!@0`J)5l>o^;?Ki3a_(rq~ zwkn>c@2i)a3N_Fk!|}uLQYm1&B*cZ*Zqo-rUv2v#Z?z|gavk>_V{!bVs5weruhT?_ zX=#Wh6^l5u0i?LRU}LupIVk#)%R7=w%v6htPay0S#pY|4!aWx^H7M4K-0$$8kM17@ z#;T`Cp{JTep}B_*+q$M7O*Tvme^){zi3MG3K~JLN3d@>HQUh!x~~eHk2DgkGLvn zx+p1apz`q^{k2U#0G$))xJKum4n?lWt_-$i->VQl|0_W-_?|_3--axk9e1YvWpbq( zi`Qw0)2Oz~3$v=cxDghZ@b`mGci2JAl*Wy2CSyY35(SRizUjSr9~@vCV3X;VCev#E zAiH1m-MJgi9fvZT--dq=Q;+xWEw@q9Z84tMylGd@moH>UMaLcGS}u*CDtzr8p$@`#dbjiC zzOqH z<-ro?E7NpOgfH?|>g(%o;Fd#p;q5%ZnabwdK+h2Ci#aNa?Mrrc!a{Z0qnqPKm*!iq zvd2jqALdU#C!dpj9FJ71BO| zJ|b2oVke)i@Upf!hT-^Hyy$3vqn0`fJJf4gWQ!vYlC<#`+N`P zMay=#a-<{2QA-*x&1;P$|DTW^ee-X~F-I~307k^0M5X%7FB}m*6oHK1*FJMS_PB98 zT2OkYDckF^#v6)`^`_lokD+T#7Q9H}Q+YV%CmE>e@d^hW7*Cw~c4$|_tCRLMux#6Q z5#%>AS42gFy+2AInFxg$nCLQN?!hQK>xeFJQFb!}CmYS*R~+_PlV%IPgsjsazm}dN zyCNd+wIZv$KoKHTeiw@6`nDMIhu{SfJAp_XZ&RT}oJxc#YjeV&O8IQJOBfr5E2Lw_ma9>KTq*N#8sg1u4+w=|nD1-_ok8{am7dW5a zs@7orvOa!C)sYnmwqQ;Cq&rwx22n0RTg;N>difh6@x~MCZ#@)cMK+tF8Bgc*&#jrJ zo#;jGh?)MwD_5`b0O<{?nHNJ=4~mH!SFT+7dUfX5gxX(64?cQ1zdUzumavKI;FbV_ zpQ6CKg?Zq)dEGC=^4uq}mjHtX5+#R%{n-4RoJ&7G<~~?osm(po zm!tT$#>dC&S26R(-*3=PUaNZYgb}eo+vsSdKbl;Pgx=GBgs?OC8{@Yt!HYncQ5+aM zDM;qe*%4Qo&F6S8hEv9y39rgr>nWp_xh-+|??r!`*AhX2lzJ*J$E}iHAxpgB@&O{; zkfd`F&EJS{%E(ZTnM3`(0SR<8qipIdXV0D`ddGJm5Pps@hdnj)4KH}9(*sWR&+4?- zITK)F4&32QZ7+bJjr(m?8vyrV_2giT4_7igsg5NEu2W`Wt%Y!b{l}(}2r9_PZ78Nc zoEUwUVy`pRBvjK3PK$eWW%R@-oQmyeCR<68O-g&bG$SSw^!}Gumlsw*#ywCwHnOdH z;gi8QN}a@zo~P>3Uuw`Gr>1fPfSLyxW)DDz7?>B_WtLee;E=j2&s~XN2dlLH#QJNz7#&g9qi1+aPq z_A9dP}3(u}EuGBqFdZuN7{lQRlKEgNu zUOd5kmL#|V>20u`3gw*xBRbnvvljK=D~0bA88}ixeIn)(ggLFkB=yH{rm%>8B{ttM z*HIfW;P5!Su$z*PeShiMK~ee->_1QXdI~o%Uu`(c&7k@-JRk;i<|zy#w^YAZu2^2Z z^|?e90w9mE+4zfu4f1ZFLEkW zQOQ$~!WZ^7l<)`(eV$9_`zp2>9%_<qL$lqnyK6pbY{jLRje}qYf$mOQQ&Z%}9NVDol9aCJTv7wGrE~NoZ*y4qQ0pb1b10nrwAizhXcku$tux|; z@I8{9-qhF#s{n>oJ`n!C{rMKUkNXmGODvmN!2?sM2CH74ynN@%^2MGFv3x$?#I}S> zT-@osnZkkU8eafHGz=b9_=o3E+q>SXzIx7jUsX|L4%1Rcq@sMoJ3l)E@s9Ngaf$K* zOGYPT_imyqvpwms(P+F!aPAlv_trxl@Aj;oKV(x7#2N8m?|ekzM1*&Ya1;*q&eEUR zRA)1?R$^AmYfnM$JqhoWV9koXAp#f;jg6B^7{4ddT3^a$KW9ZXc&6Jr?dSe?+db{R zZCvmyu9VE5cwx-Iv{Uo|PgzdubD!oD6m=16UpG7oiTPj#+7PP6cf8rlBSKz5P>mmf zR7Ux!B%#wpHNK%t{=sh}?htG4)QUs8>y2z128G&^+&^jys`uzD$?6tK7L2cqWNuop z%1!9JlFsKhK9#fKYVNgdb;kYlhyLUh?WoA@nd{}IHxAaX>et-v9hv>G;MalrC%Mep z((8D$R9@0bX(-nzc7`j~o7T#{+~IeH>UOxX{FL6=lu3^BCq$38hlfReGVi%xy93qzl2xj)Dwd{?Qj_Yp$8)l%%kw zX&TonbDUY~7}e)P!Ts(DWyt9Szw4EAaguB0=Cvp2wNGQE(+nA$LDUZVPk|#V`cO}@ z-W564Jzb&lfJSR!%((j}zvqs<(ZRnbKHq&DUg9TSX}fuQlIDr$2ds(@CF;t5g5O~) zflAKnaz!bNAPUd9A*xY4S4G!rYq=RKE#QRqEL4o>v9fN7$2~a_={Y9<;Con(!>hcy z1JjCrO4UujD{lTVadJ%;pcwTQE&pkdAHFWkx%~ah0g9KjbS055cMJOlemTr)Nps^a z&z4gacaoG3Si}cnytrQ1wR=k%kYQ{v93dbkICp&C5c@~Bpyt5gNNaA{>(cK@w8cS^ zT$0yAZ!TKXOT#NZI~Jpm{q+*m_;%oO&cEe0R&NQ2@nbrs{t7uw*sFzKu_x=yp}RWU z@4X>)d4lxty57eY6eKMJK7h^i{%s}Ch9@b~AL?Wju~E}!tpHID2$tP}h^ zd@g2fH8J(Glc8Tfqym!urxGjdgBoWXTi4!#rr34e==k)>j04_9x5MTcT>4n;({j%8 zbSi2ew-NgGsQrTT*26>?_J!)Ft*gr{D(e*YNWs_yLzV>3-`!=?)qmCDaIO>zC}n+s zi6aVfI2JYLaO!Tq2eXZ&ct+4;SR2QF{#0|wL{Wofo*ZucR&#k0N)I%gKQ%}{=;`io zI~I}1vgPoDsY246YDY1C_uH)r~2AAerek0DURP70}6QJ=*#LLBq`E+ni;dvrCnqG*i<*LFo zf?3k5$&@ws&XeEg>~uroq7=kZL(#^1u0Xe1IT2>W^`d5^2m&;s z2`t-V!@IK_|MlC?P@i0=bUjR6xAkV_rzfJ}6E-i33w$L%iIh8r8nehRN8C3}&#%6( zkXAO|ru<*lIE;(|hpgzBrjJ-KUHse`CVOk&o1m2SyVBxv1?*2NXq#qmn&sZEb~XaArv+p~VQ~tZk=)IA!inW0W&}OF=_OCM~k1&A`&hrFQ02LT4DybV3 z4s4h`k|9WOMR^B=80xT!P0F?!)ZWkyq^1LI&Eb~yOt zGSCdiY8Om7ySNaPj)n5#LV^+O%J8YM!9b|eF7M@af(R`r_DRSjz-Sy&h-ijy*`CP@ za`k_k1hLy-m)FXy-g8SveBK1X2)DLjvIrp|xxfnF{Oo&i*tS;!;4n2@187uSYER$? zhWbFhbEIfNSkKV&C#S<2_neSiojaPT-O6q9CQel%XHe|u>ud!zbLK@j@?{L#ahjxrs%!$rIiWHW2)Xe8Ysh^)`Z`v-S80VDC-$gx8^1#d-| z!#GQr2t6;=1^j(*!hu3J!!r?!0LSxBF1(q`XB%lcvfH+ZTGQ;n`nmgl*PYg%F%e{0 zSJ1Zn`R}QK@*{cjzL&yk{xa63vZOHZPmO$UFI6#M42JGO`uY zu-V+ZZ5i&j zmKC3zE3Q^4YT`n)-vj#ZK=g~DJO1WJ6^jN>esNMMFCp!o(dSm^tj$$~%U~_U;{XyGDYb7vD zs_{kgeq9E}br>R~U)^8=mD*@1wkRZI6;$;Nh*@dZoj>ZzSoacS1<6=WYsHovn6Y2t zd!8`!?5C>o@d*b?Ar1PzrY^5va%YadJ9~&{k6je4E!XCUDCf+`rdl*{=Lpq}{*gMT z_$Ry`m+*OGw<3z#Z6q`RBnj@Ji>Qpqf)o0U8_^^OLXAb-E4igx#+@Ku%6WQLIW%R! zA%}s_VRSqY%9!O9;C+K$0Q0(S-lzGvm0p%Sc_8PqHEGGY zH~84E4Oaqv;Spk$)O&fXS~GDM|849QCNaw;J+^f?Cv?8zsm3x*tt+39rR%VzjaPqf zxMKQ~eql~!Ywoj8Mii0d8w%4pm9E_536F?*o{@~lMTdfJFVWph4B;0gPRe=qv`Saw z?k^4IM=Yqsm1TNfpb7^$fk&tgUL{}nrSEJ%bYXb^FGHv7vTBa711h;gm$2*x-eG`; zXI$M`b`QO8D$8l)VGGXm=c^jv|8*JOc&48?q`=Xgo?zM}pS3K?rl%E;6&3p-rPJ<&$&RVI#>e5gY z^O#LTMDK_4m5vV|2KFBpKlS>ED(vaKzy#;Xjsj83<^j05_dU7(@p4Cb*2&@Ih8`>l zkmn9Bp%t7C{1LH;2vJe5j>24l?Q#4~+Az>vF&TdGp!081(|-GQGhDYsEQh1zab;w2 z-`MmUE{X5kC{<`#8Q!XvZiqg~WkMxS$*YxB#S?o(i{7aCvxw8m6Pg9RPpA(`QJsnS>g(pc8AeWwCdCq~w@yH`+Z3#DgOGm+`FZ3J z*C=(kn{h@V6wMz}lD0z`TDZ~Dx$m&b^|C=)>`kykL;Mwsp?@J!WN@L9O1usYpc%S5 zERseqK*=~|(<$oEN0Y!L{?(NS=U_P|%m2CiAXW%7++Jw7z*nh#0evsd>*u>XmRe~p z32!I>N#<>E;R5ONNFZSBPWVCcze%ZKTY+ij5uAe&%sQgw3SlS^%`T}p`uT9J1JML9W7CQZW==X)hytB`~J&;|p`MQC{Ue`((+ok|2P#Sn5!8t7OFm!S+Q%33{g7j~Q!5xD^09K`{A-@Uk~J zv-D`>GBW9xSfBsXI&I4gJaTrg)58*WY%` zCo?lM?~jNMW_eLf8}g{F6F0liXQP4rCg*O=MM_`)DqR7&382uo1gac6aR5-)uHvU^$i(|<04r4`FpQYolIk`=-9AtQvjq7&(a9dKtWxPvOyB!_2mpBrcf1&xo^I z*3{JWVQOSpT7Ivn{%zZo*&od!34d%=q&3z57Q%m7fl5lB>DVSq_A9G#d_g0WM7gj6ec=iG;XMn`HMXjDmdmi^W=uH^1B*sR_U zqx(N3RSj&{M`L%L|31H@x}Co z?E0e=qrEePwt>C#yJ=TW7&u`iM`jvT> z$Jm6-T&eP-Tot}rCFu&o4eZlytsbrjYEip92ODxaO4T8p_1^$u0WQeg8=_REVI!n< z;#cY1eFm?ZV=w6Dw+3`5rF(4lUbNA=zJ09d!EY#m7z_x$_#r|D^3T9=Z1;b3&RvJA ztaC|?=4fTMRl#>HWB;4Kdz20Mr+TH+Ra$!|+HZV3864sEMq^^xid9QoeKz{s_=|>S z5c57Yu-bkkt}SaD8`YkO?3m6|Z?7M1SaW!FGV1n(+OE~11C|PKy6^T{xq;msg8clo zfK;wvLE%bF&5HkjL993*HXMK5a6^=-Z{W$@2DhkkTaAMYajl~cTh2O3_a{AL|EVpK zb^iU%4Y8S=!i>CTrt^zM8QUsaN5831FFSkh663xPr~M4LoFJ0zK@?-xxHBMTVBbr0 z=cO)$n1<9WjVx6;kwLUM;o#*|8cdVa~=8zXUU z?TyE`-!la_N<5)@$5r+jY=1E@+sgIFX)s@=DeGp*a%HrOs9*0k4Y3vb!F;w&#((3Q zo+6Mi{#h>DbrEsZ_SeJ)g3?m2*qS9sJj>%LmGR{n1#T7hg3%C8&8TrA|EB6!A z^ld@%>Nso2CP+9Y5;c~}Jgc>6))e2`r3YwN2bkw)*T=I&+!U=peSMKrh_%YcfMy-5 zt$>mjytX73!yZ*CXg4tFg?tGk?YF9rv|D8GcLi!Lrk;Qc>obD* zR2QhaINY=6dQ`_pY7LVA8SgQWT5=tKY1iEa8WLSSEkWAUc6(+u?4y09M)k~tL!XXojo%pE4?MRV@OThMj-Z0JF+#d)QnFi8m~1i=Ow*PD|qpp z?NrcALw~eIN=*$X$P_Z{>n^aZ?>NyI+q{lvHc&xREkmh#clFu4YZm=Z)_;jrJ;*HR*#En2Zn@bAH*UO)-P?&EtkzVp_sr}}S`H!FjC zzDYgV3V`RVMZXzKTPi9Z4u9ZS9Xlt!Yvg(m>yMt&P-T!IK8@StT833~S7L+yiOde4 z*dpTAc>PR#_ZOYyn!Y0*`$F2;{jY7_?36?#<$r6dWYr4RZX|5HIq%|>LAC4GO{|`s zZeVvxYLY6S*~TNMW*zA!xNjF$MSVkB)k|!60rgO1>N=i$>2=;}n3kAMxfN>w&nc&4 z0$E&*o~*geEN=WxxzkNAxU4u9uI-%=!{kpKAVlZCGkYw$ja~JaE zz<5^jcQPAbD0wK6#*?!Zz9l+h$%Co&EO4}+KkFwt^V|@+F<-~I7KRv_2hPTBe3S(S z>d1Bk3UV7G0%2B?z>i!ZQVa&k6ZK5*y^)a-V!(t|w_>^s*p_%4j@tqwL%Q^9stk6G~CkuXg zt#)~R#LI5Mq7<9%y7yaJ{L;MHh2DvJu74fRXsh5a_@h_$XzkatA8u$ek9~YQIi>cX zV^*6{=4FrjYR}0NtLi+jj`rC5FUKdDA1p%1CGmmvJ9Qa3cIxQPV%n%=d$?n=jnVAz zV^0}Q=cz9vyCUo{Cte|YL@-3rJa_KgCkA&GbV5Gp{3{_pkpvLD+abNiP^^fNK^lSO z*4M5Lr>3SpuFP-W%N9I@S#FE}dBhw*BRI{w52EA0R~3hkNMVJkeZt|2CZ8M@z$e4YEc!poIXN2gG^`6cG;O8-UT<@xpK5?fde=MF_Fvu)GdKPM(?%SV%xiP))& z^)gcvtBprr*{|j&d90oHjsF;1VCQ=9m8xrlXw|CMYq7)Pl;RP7HKp{uc8y~7wi?`} zYYmkMgLr*vPKYUG8qHtWT8QaG=xbR#Eg*j2FTb(eMo|e&}slqJOjXEs}Wh% z@h%buFB}N?xmY11BEX15HM~`Bjh=ZDiht}i|+AN3WiFM%gCw#3MAIdkY>ILksJw(^?uQbtlg(%W*rjfN+D zZh#YY;00LWUL1aGpr=^;2Za(0vQSCxxXs(jYq1n88wnJHdn3~nJ=8G?O`@7!r8ex? z1XgDk&ci%X?HJ|l8)MURrEBHg(QN+wLOwo;yDS39%cog2vsR3-qx^8&quUkhR4P4| zz%>1TquxkSmdMkyJ384vZuJ;m#iM|4*_d(lzm2)@0I%nixOt8w)|l8-XP zt8$qyuTIgFK8vd9XQJNEK2|ilW!!b&NwET>>6n`_wr? zu`2&?cB+*Bg~e549L8A10aFgHVXiLWQMOa#LKNs4d63Ju^A!1v{-chaFQ@ZgFCELU z?#*lY{%@uA--mrCmXx|aG=gEHNLPF0_-jt<;-1UPn^URqZLgz9eRuh63Td(&@kyey zmwLSmaxkQIyDwL=X2}w8Fa?okS;x)sbYUFhAQ@qxcT{WaT+%(=-j0sfFp|3BvmHk% z(!zGvPn#6%LmHF2Z3W+itBy@fXE&;2ovzQ+`A>{lDi;0{ApJ3=8W<`|OIl z&=qQgQZz$Hq1;xTxJiYFrmQXL`rxlQkGpR}PYgBJKl7?=wZ$vYL!u>!L4oSje*M(BW%fMd&qR z9WqWA42PwnUYPsv@$0bVYn&$64F5^4{W@`?$&aQ{+4@756kUz!D^kJzw?rs+dTabWQg1NOwhaY_U@ zGS`6w|KLVXpp0Iw)j=AqLyE--w&vMe^*J|b=ozzWW34FX_vq?sSXJkd;226N@zoOr z$t@VywQwS08dglQ@laqg;oZr19W&!9!)Zx^J!8de*!sNwtr9`L>fh?Lm3~2+{}Vd8 zpoefg5$A#9->Fh5Y3?GD`GTM-|2v$wVnYFgv*GLEoG;7AYJHpRn`& z#NkyN)yP|QJ1EQY<tJKN*iu80pq*JyM6G<>Ig;41^I@_v+^GVpLh;HJUAVa@khVg|9 zXGwM`V$5RjNiG3ce@T|fk%JV-LE)8$`Af2rn>M2?AT}Xse9}lKNpawPI->^qVr|ju z-ws&8l0{>{q8m*{UUdSFewR3yfPDYM?~0R6boPuzJqfe{1+CQh;zHrMPaa*=QqJGY zQ3oQ-ri6|VHysGZjeeefdpD*qVbv37yWUGQvJ`(@EY15`wyYAYH%42=M)|Soa?Yu0 zPDs?m_cH_TC}~7-lKilrcG8?S(c`v?`F9CEr%=pdml!itYz z)cNuAXT`!+y}b|FBW&-!LHZrQP1q#xDUkR|m|d>GeT^y>`$$mg)`1^#SpNK0rXHn4 zom}HG1?AfZDK~ZdyPh09zAfipenHLf1!@;+BlVHOnql;W*}Ucwt>Olk^_q3 zE3x`&ylE&@@m9i>4ykB}e2IO0wNjZ^E+J3|vZd4?jB>LejIWpF57&;$arY!yZ75DKL&xtjK{LjW zvN=dz`rU=Y4`3bwuyxpv9eJ=M;Oq!|pQBYEILY1}e7>1T{III@KJ%v2CZzm;nz-8& zNh?jkF4r8V9Gmh(nB*@Abm-KjaKQ<1cK*S%O+g|s{Cq<@pv?_5T z>u~6Y6eFKm<4g)X7>J}E<$tAO9I|G_CkM76LRx!y@?)U6$awFD8c3PF_DzQ*kfp4j ze55UamFE98RH##Xr`!%(o2#lgpTo$)>WrMso)z(pSl*uB!Q%dVVc~}K?tM-Lvx>F% zdDb1T=;-gC{vgg4%)KQj{fWgI&&wmKI3N#GXuj3V=Qd_9}^|Lo!xJ^KEfE;)~Nad+&$ zuNNi#%#xs+3|SP5d&G$Rc~k~G5u>)r6VtZyO<6w;Tea;(cMV@(Iux!rfOl{!S=RcU zMDU`xdWzS_Ut)lH(})8}PIYV?-S#F9VYwUB4>26)`jYf>p+vs5P2 zjIU&Wdu;WkCh?I+c*S7Edr7an*|t6Lo&TI~pJgvqY(5cT+x+%tnEF!Zd!AOmwB`KW zoo!2UYck*Ni~dnIS>8f=O5V`%V}iDo)1NY9Zn-ZV4_71r6*(A55=lhacfPcKx)q*3 zA5?!gEF$=Ph&b6HI4GWQs0%hZ^Q9!7n!BRo{mdS2R`t}DO;fmsDj1s0>&jn@Bo9^a z%ueu9uK_{Hg1RGBV`aANA~|+Dn=j!wY8=POCnRC|>$4W8b#XaKH%~J6RP3aR=q^VX zVl}Ro@xJL>;#Tx#t0wEj-(;&b{F_|;CqB$PaZ__N#*|fJ^6_&rM6E+%rx%gR%RgZG zr<$cvhXkBLg}jM7`|Y>T6Kd|9Yu&OgypC+UB^~KX`<&UQfC->uQ5&90WN4O7e{GY! zBlDlue#d9A!-m5|nZ^Yo1DHhgy?tAk>DO_FG0VU}t2HuJf$MbaN9Tikmfp>i=da7B zWPeTQ5v1x4eo8F3Z86pIWFg3;{$$*zo;AjQ0bUj#x?FtBL>jR{xAIrZx2pwJ(d6td zv0;93TW;Y~-~A==yYU8|So#M<88(^YBwI1U%6)z?d7btt!wefdYsA3sJ{*1~f%k_O zZJjjZiGA+ZD}S+6R0JF}wnjyR#_M*E?TcX4Kcf|bD{2}7)(scdmnzXvkU$FC@{#Qr z?UJq9e}(!cZlyiC6A>6PMHOac|6>SO+#ep!B|0PfcYD$QNT{giOg^8c`BI}F_C)Ak z%<|H8*GXCCUyWIh4NQ>IyFx}=_~+^hodu_}cW3snO|ny#@*dImB^x`D5ocRDs~`>0 z;eSNE1yCH()-{?Cf+V=RLvVL@hoHeBI0ScRaCaxc-95OwLvVL@_kq{kd%yoxbxlFh zMRiRd*=Ow~J!j7krarPGPX9?W#BW&l=2->GiFaZxux25o-{OV1kRYinvLyICMhlS( zOwJzxwk7Y|768=Yy<5WqhsIMYSg}Y*=0Jm6HnGR&cVF&zRXLFF3Y@fM0H)lU7fYfE zg%I&jkRUaS1F%6obVBnBzDtEPqFhzrzvbMFPP;rA4MH=Q=f7&DzTw*Yawjue=^4wX zpL+r64VV9cZ-C4|b-G+?B`2AMKWuMK+B+?2o#LX6K+Mx6U>aehy$mW8roaIh%Ukb# zi+9uQcS+z>eDkhutP2X)e&fG{efO78NAN_BR!nLmy8)YQ!N3U+1yVY7 z>j3NpU>|>99yL3>!2-0zPI*}r{KNWc@Ac8aI}zvIla?el1T|L0MXqf+2O0ZN4z3E; zXxJX~&DWZ(M57O29=;cRnJ+uvUfy2U-yG;^VqDe_vzW(#adrt1(=+RB)}sJs03473 zdhNQ)TF9|1f~kPL$f(qS@y^5oj?_JX{#x-zrA4C4?A6;BSbJ`USC)&#j!nX^*4SrP z4LHO=J|!UT`A-7UJB;vNPMUix%!bFUjW*@uLc1rq0B1`;t^@3Q|1t7nw_J!(gle4@ z)RR5|+ymduR+|A2pObZG$IsAxW-<_C=HssTPckoub+ z*t>2imU~@eorjuF9~2kA_g-oP9GB@oVLe)~Qw;!!_5wgJg_+z;hh{%Evw+ zJL(WHAt3|^4ZTN@SW*v0c}W+x+EyO|RMg^r^0T`Uy6qr80n2GH-~ccP&%cOr;FuQl zZ7D^vJdaZX*|l4P4=pI<-31yjc8#6{{CPF3t*xEmaow}w+k_Uk_x`n}XI-_9zftQV zRKmvw97f)I=a;iZL!0A>PxtZw$BJo0{Kt~Hi8*7Ur`NfWs^XsHPvK!Eox z1@E1H!u4Uw6R`YH)1LVOM$%88_@h7PZuaq)89Lt{xzy(L#nSdah(*S$QMhA}_Gslm zNHT!l0aYM}=dsp$_q)E}KX#O+ySVa~NAN9jDUQu~2~AckJ~K^|fbwPlsJC#NAN&8< z{#^G~QTx#)Po^v^hU^MONseU+WhtMIl2Dhvep{~`pZ1aL1KHG({c|S7r-DNf68#=V zTGO$!m+SSnx5eiPv*n88cGQ=#rn|NMm4~yht}u|WK0}a_enJK){(j`)31(t638zW< zIJrCef5{N<6f};U7gu$1W$K$tX04NhmzuVu{1^JUZq~zdZ zw)R3`PbCK$Q4sqxW_UH?zFn$FoU1oRa~)T}ipJkutAmNe2r^OH3RUDTDCQFb0bNSz(p7YvdJQzsS-kkS7o!_wmmv@lA-DJKhudl3}gloYE=K7m8?$c7E_gI|VbD%L} zRJyTJ>o4@&Bg}-*90g5g*BvlpnWjcMFGA8fJ1*KuWb5M&JKdM+v`C6PE6qE#0zm<-$T2PK^1Ya1`A3@ewjx!#hg?&I`Xr}B zmKQkzSq>qcb^MHB=eEfQ_CHuzfHPOjQlFXBrIizriV%{E&4R4-=t6I9rH@D$X5Px+ z?^$CW_2Bra^~BLK-nA1K)ki8sw9)7!MLE}VUzfA^C%Ct_SZRF=uKMz$;mZB@d;$3q zpU`dU)?GT6hXMY(z1#0GIL1U@-1G|ev{+ov$-Ss* z3-=O;Fgo*<=j~&P`C^Q2O1EpT*LtBKBQV_4K^dE1frswQuom3o0hiu^keh9!S9fdk zm}CAn8G!bLUb)>-WM}9=TkHqxS3L+`R^eK3?0`z z9pvlxUhDhczGrLzmfRwE6g(}Iad{5RFLWCArUi1aKD%Nvft()H9%ES!GL!TAwkToP zZ5%+Gb_H|r zOMX|H#x^GBZRPJy9F${Ywaf&gXh0MKO9)DFYr!=}{y2LDW)d%sCV6D}d;!!!S5CoA z91-5fx=+&xHl*Uq6C(O_+Ug$G6rCGal9MS%n6;&52k;GA@LDI_l$WrO3>=g zuL`mB#7nZ;%Dpm3R@{#L%ZPt<&j%mk%4hKQeFx7ByiDbPO`&3{?<@Z4^8k^t8WG;EuayjX3L*N1NMFMmqu^a4t5bV0ovwjE|bCv;i+{6$)*D~u- z%!4Cj<$gFHw3{6R|Ks5AxrKW525td`k3}9ow4Xl<1t0^K=8hmsDtV>^LFG3Z1J;sX z>U|akjUU;?1hZRd_?}MhEN4jlZy?}x^K^Uph>Jkxc+sK~`eo)$D(oz^! z)gY74Mpbqc0mB|rZ+$+ii8v&p1x2co>61~Euw9Z98l7dNkD#6FFe@!SmVlZ)>6UWS zGZF4#C_Wd@pq6J-dQE|(e3aw;FyL=;1^D9V0xPsPU@qIIccTXg z{F0S~tQ!jvpa1Gv-)^qZ=Jb6&5d2CF6RUHHEkcN3 z`|k+@x~darBrmeLVjq*M_-0p;JpzyjxLxJJr5^P62eHaI%;L&7iBGeBh!7`j-{xr- z0qpkqU&q%UdEW7jt38g6RGWt&n+pJ1vjtq>?!oKPcIcY*lSzCECjEe6^&mjTr+uI2 zV*n$C2=E~CS8o`$(0kSt#Y@Jo;JGg0AKPYL*8lA()ccHegGH)3OGE#IE(!R3?QxD(#2C2AV@l?uU35d|57PmTWJATvXIO8(|a19C>x+42k=fhMNPLbBe`f7sOx{ zzDhVrd&;Zi{8)MZrB_;d90DmCk(h`qHyv~2t|cyWRSSj~!GoQ0{?%^=|EKg12?=Cn zv2pLw5tgbUuk{mCMx{;Lij>9OssrLo@t+`KcQsY?F;`KnSytznBgdgpbYW^}zF&3s zg`5$?x53YyNcacbl0+JT)U0DENJ>#5rP)YRYm7LNPu02_*i1N)AD({MwcT}y(%|zy z6GO_UEyUckU6-h>4koud624s!zJ3Dqj_Zj)(nSWM_XTbfU|9E}TIsH;mvKjA!jBg% zMm>Ot{RkZA$KM&Gq|)JvlqO0zAJ2haVDNF}&;4`<)gBh?@%II<_xe zE6~mWhleMx=OS4*PG~@|6kE!}KwMGWcIfrw96$K^A}9$qa@W%i5X!XmYw`;K6e2zc z29#ky4DfxL)xmgsG(sks{t+-^T_3pV`p0L!nm#rrcLuokc6dJ4q+lmA=yTq)2!M}_JJ+u?JWt0x1dLcxBQM{o%6W&72`j@C9``2`$MMZyo;AwvGU1# z_!$+O?ZEcsN{()Vwemum-R^LpXM{bV9+xUkOTf4-Y?JNJe3`RJXgLQrW^%Kj+z&b$ zcpkno*gwfq_cMxO?oGm~#kipf!m!Hqfc2WAhm4>PTz6tCFDkvSLDvB2b!_bM%p7+L zGJv)?qOYpw->PWg-*;)hP3JOH5`(a#WysofOmH*DCvZPe^xkgZKeO=~&@#3+j$7Ib zJM0=AU-fcwQhnX1n0Q`(?Zuz^OLyux{O#@3^hLwmT$p^-$o(e|f^uA+3nIS{iFP1~ zMzYi4{kn%hH5-iYgxBWvSyx|aLMKT{Q;V{jtB^*4YyCv?HZ_58jCI4c$J+&gbF-Dk zWh`i`0W@#(hH8`a&rH;kMRwk1lz~=)!2y-8rm4M)(DUrN-N8<)*?PEkf`7!+`@FWo zid?NiTfe|7q0OJ~beUzo{yJ@GA4gq7YqwPI@k>ifi#n#;PImBP@7wG0E6~mPPMRA`DI+ZFJiW2{DlB;@D0NGd^{52i2U6bK zpl6O(fP}3^uiJW%1HDj|<#NKpvc8}lzy1dP{tQIq(uby3)v{~?y^U*rE7d0~7u1zi zvGBsJHO-5&GbbGx7+(g@&G7cx)nP{i-n#|Vck{VRo0mzB5rGGWR~i?Ri|f`+c$br9I>F&;%t6v1B6(Bh#uCD4#cnPZZje06(Vco<7_G!cKtyj?W zpEWX_`RcZ|F+fDn`1bnD<#LurXpD$Q`KhM9z8|Q2wt;U^$(u*=@%P^mRD)D>UY8y- zU!64eksE1p675#kSYYA4!*l}-{Xagr9w=dg0TQaZ`}Dh&`}>sqDQ{2hn1B!t^Nz~w z0<0Cj($doRGUsKs<4J87NH}sxT}R*sRp;%UomHgqx=o1%biB^LH3LiYQS%fw?|2iv!FrzPHVZ7YX~l(PBQN1!f^iFG8w% z5#&w4i#tRcbf*g3wEkN7%6jg0A8Z7W{x?93@q2ffW)?NYF{GD~l| zu3$zpOGz!cF&E&4T&^{&Q{@-9`A*Ctnlzr#6GOJYeFsx%luKAzm;JOGc{>+)uC1wW z@^$~R*r-Y6_DV6R&}=$d;%f^9u-n5^`)+nw{^1UnYWur zxN~(EXi}|IdAEHRM}1{@i|(M_zD#++M$MN$X*Q z!@I4lqGHPF5=N%O%vne@;rw;YCNc362lo%DF8Bx+{PqNTrU59eOy5>QLq;J>+Vu)7IK9?;I|8p0Ap+1= zX0) zfEoX_^*a50BlLhwMbw)Ko07{Ij&S)=v_`t!g&OII~Chy;F8lVV2+P*pYj6-eaY0 zbf{IZdLg>xOHmO2(0HqYD$#GSRcISzC2|h4uK9u46x0hZV=k7JdA5Hp%qPb6eUc%} zkJps!WujrtKjZMiriMOlA!NkD#UtE8RneO5xc5xqyY3c92}^76czn*pTo6xRK3a=C z`4wd<*;XMDu<$cpCq@u@28&A*9T~r@7BfiJWE&fao8y;UpEnBg?5-QHs|XD%8sqB7 zz7_9`O8T@^Z&6V`Z&R>DWx`*LQ2?kRaPvCLN25}@Bjo$A$(_JEmhWosYN?YdTNnb3 zLczGqBfaL|H^0_49^O-NZ{_e^dN~ePPY(6j4sbVkhJ7)qmLOA2{F}(eizQ7IGU?`4 zpZ=3hC#sg=pLEPw!@+i)Wn_Xgy6EGLxJL^ca0JBlCog#v zc-JKq>+uKl=?Q?-vom{<`Gj3WuMra$PV1=oEeUDK@$0-kl&CN)GP1JH5MiJ0&!fRnf$&uC3@?5ul*aegCu4R zzoT4tkabJ%&=Q5k3bPM{Zc1IRx5-cZ{ri*GoVzr{#ML}EWd*cMw#3oCEq=hBWI>wf zP=+)3y_{Sxq{(eF{i_35QrrNH zu({A-ml%)%ZXI{L9^b=hKYF>iBm>P0UWS<6xDkDvtE~;XHA8{l$qUKrhMYq^Qaa8r z8IiOOtn*N_!PT>Ggk9r41(AxhH857+On#F&#m4-o6jIs4GftuldVD?y^>aszCAcSniS|NhvS#%qwT@7Q0TIM_L)l#CZsp?ZWm>- zUB=Z^Ct87&qsJC8`d}%!PCcN=k=0#B8dT}?=H<57gorKZR!Q&XRgel}oodHLLG%h&@kZxp>LWOVaU1hF6k=q# zl|kC=eCe4m1O^A?I{Fi&B*J2q>HdBUy)fuhK~;UIt!l#9GIPHrX39rTRQn;PH5s%q z{>N~q2Bu_#E4txR7coNe>k0j+W##8nC4;(l#H}U0To|YrS zrYL-CpH7qnK`owr`qxddWalznpyf_5t)d@#gm7HU4c! zKm*vvVGPC#DymTC%nDHxe9REEme_DHM{`uQL9#&8`LgISd z$Zt9>E@mf?3~3Z~JEleo|MT4Pc?!8a-kw+E_eB+D0Vr7=Hm>EHeJD|il*%lpPOn6@ zK0z&P(ugw{?mY$nusHV547hBwvdcJ@bI^CLl%;5)-koGm_aq*ZU3_pM2HmtwEmO-p zV`$0Cifyp5l4ui~wsBZPDEj*6&Z*Ih-)NS<$_-so=Gife)Mb}M(6KjFx54u81#&p5 z(5TiBNOG5+8BYdso`ILVRL0vSrpK48SzDEk%2a=`7A5{lk;tA8^JoBjN6CHZQL-&0 zeS}cQLSEwVEBo{@F~K&ML0u@#aO04l1&X2P8A2VZ#wkmhK(MbXo)-~pVSBK(l2Lc_ zV!X}iY6CMUm1W#RbLPk?)2FgO3uAnDB9@9t6lea~#3ZstfXtViuVDyjn`DS1A4Y_W zYBFbz-~T3DL<_@*j7*5uMQggJ!4%6>7NJ*JxLh;N|Lm!sBiup69_A*ckP?1|v1d6a zj=8}Gu^*h}3GwVjzfL#3@pVE2!`f?s%^)IT!0u0V6V=9YO+U1CT_wz$$Tqj?E{5suA^#d~EhxAoAnDv~QQ3k;B4lpySzhT$p zixthLR*6{o;-^kVar=GtZ0N=z6_q>bM`1l7ad2M9xHFs*@m7dhM(i4zr(9x9B(a*3 zob}Cmh$0pVuG+k1W^qAL{yLiCJ~cB<5=>KSZMdKn zCxqz+yC+zZ41&>NDC*oip))9o0};~4Ee^V-EUGEV#0;FH`@9;Vd95D2lBCIqxU59x zyLRuw=G*BUedAiq2aAF;M{Y6yZfx4@-b2JAvfUb6oqDwUoX`iM(YLGv$XNu)wGzn2 zydU5H0GLksx9|Yyz#0C(_fRO8=rkUeaYB!+OV)8Rsgl?r)S52xMg1+Ftf&ezgM;w6 z0ld1VtUh{wtf2LCQ?-TYL;4J5sGoLzce%!zZrRyJKfKo&sz8d}G@0-*U&_MO$zg?O z1l?)>j4A{@=(=U9#xhsOohJQO_8Mi%w^+Z)e_%8F(BMgz*;Wu`O4mCTv`Cn&@F_1l zSJ*RE% zKNABXLhi{*yq~gy&zGmx&uf34OINxS^Y0u*6Z@Q z&jB<4uW`DrQH4nM)~~uh;HAHrto5=N>N9K8`_$ptzfX3BmlkHSJd;|vj9jS{ec|H^ z(l*N6R*5)%WfI_LeLUl$|BAkQcvzVVLdAcvzP?X@#QcK4b7GQCZ6N&Z+44cAi1$Lo z3or=&f&Nw zt)c2w+wQM-Y-m7UpoGv&rzy_$35A#_&Bf~mn8>MYaAWcCKB(ua>#H>#3Ae}XPGw9? z4f!X}1;%k$MdWXRwD~dLp$`wrglGE{$5zY3qNIOv!}8FQc)rlcQb;~U)q0Sk&1bwI>?>a}{+dB(47L+7D=*g* z5(CeaZZ2Ny%jR$^ytTr*hI{f_GVpjnO0Pu$zp=#h+HN|kut*hL6GO{^&?nbPm$tEo z4=v9Ig~?1?eNlws&=z(cpyls5OUo7XG=GVi&dQ8E=J=+i=}PN6KJcOOP9}^@NF4uD zy~yajQI+%ypDS`1GI2}zG5B(PE#!c>!lpYPlmVWKjU%O= z)St?aln+8A%q!`J4lYHfj(KIK@UFXk#_Rt5;dUpTVTzal3C@KyB%7X!_FwH!={S+; zr4Lj`3ty$gKA1GplYi|?9lB2oYAbTRG{miQXzY?;>J4{LGLVrnWasTYS>UuBt-1Fv zfCuTRFx(&K9CTY8j%@DJ=Xbg3_-rttYrz)dq?rh@Ed0v|Cnjac#7x(*dT8z}OFk8S zK9vt20cR1*Zd-*?!4`>X*R~X>HB!2sKU7b!-K`HG3MVGxw-C~i%u8?;PBQn|VWYH5 zvJiJ8O>n{1%wmP4e3K6Ix4O@HFO0Y_Wbn*2WbOhXZR%XW?y55adfl`ro?xB|_OzTEj zK#Og9lF~s0U19NROBB`ZmWQ2)LXyYzjOm!Ub|b7d*k&hug&LeTf+A|6`irXs`Y9ur zKQ(?fHGlE-*gMw5zH;DmaYpw0SRp#}hu2S^FV7pzU9lmzwEfL8eT#U%Z*X|xcYS8|l)1wC0Uy;bRDL1%>?QsZ%lpxPMT#*PBsBB*NK+qd#Ccv?5MtV-|8c zCVkh9Nw6G@pyw7h`X-+u8BR|TM96Ni2p{}t5u(wA6lyZ$c0My0{h?C1IEAIz@qrKhP3RgY*_Y*2__EgzM`%MxZ z%VO9@nUY8ZMuO;jl|+rXDzF(VjvbI~Z^(J@-$(mQhK=UIT~G;+!+hzdtp~IL0C?2? za!c$(22cWg;0GUmZwZ;Krvt1al94ys1)6OX?y}IJQ0MZ+mpf+)dXb9BXR!bT^IE&z zGZjcN;Bo@-64W*|4LNe}0-Ee+;p_a^p7rR|=l>2mCa_&$-%&P`IH0NQ(OQ4{QGAT! zV#(Mo>;EVy(SPJ&m_sSn%hLZ+x}saqrJHwEfGYY!Z)0Ph`7Xu#&PWTO;$-?-DDmaiC9mbo2{U z4It@F0J+QyaM7FXNBHQnKc{6w9w4|}uc9gjU_pSj7SJ3%F`uMKO#POx=2j=x8)nd>T2lrh(pZx#N zaI<3meG>!M^X^6ikK3*Qi!+6rnB|r)=2=06(0wU!UD|tboA>np-9JHi>bv698EqGl z5KM$dt1F}(f|I;F8Jswi)O8-d=K9Qbpzi(qj<0xLjX#L(It&ep!r|b>tjN%(m)D;XG*@ z1mEn5$(~(wn+7Qn-G%QYejkxg@r@GOD`L3*$g~KeYUZH`#=B--=Lyd`gxCrT-&C7D zz;uT|v>4I9*VB&Nb@%6!AUXSD;3`EMA7-Q{;-TlQvZu~R6S+4;KRN>PYMW#0v{7XOM{IoK8%G+FuZW$hPke>4k7+SwrSx+VQ5gbvsDYi&Dot0cc+cJ zL)F{E9lqMbXZ+gp5jmzOq1E`)gMkU*7{2T=OWzWAY%WtRrvK)tfRA}4jG7z#>T@~` z2|~qC-YZ!A8By8Vm~%D|(>gF!e&ZgHAyf3+XvZaaBhqzvFD!<^_I?W%a1$HhQh_&h2$;>wSWT!8qwOiq}AZpp92Zzx~a0n zmg0~yW-Dw=jwvn~9{I}BpyO+tCz^uuFy!fW($G|i{I?hF0}IoDpx=)%#lYU`YhvqR zu%|fXzWV){ZT%SaN_a{rBz@UILm~s!i=wNOF00E9Rbj%t4@FBuf2UnwoBnA*rY$m5 za%wWLESm<|G_Mk7B6>ejN&H?YgbRHTjgdPv0@efj$zWlr9g{GxkmqEf(uq1mG*f-R zl4|A_I3c?=1q{P!M%gFgJk1L(tsBT=#U-y+xX^3POm^H!S(?PRnjBhUf4L0n=0p5TgEYF4s>EDy?~HY(KbRs>Uj}+x z|4zw9H&j#^mnWEl44#ijO8IU36NFwa6$cT&xYy`HWYDkv$$JMs+;+n5(-Eu(Z`fbD zuqtuKDaO$vwRMGUy zfVAo~5JRZuWsj|#FJIho&ABcS^)^Gs6BU82!P=iZkla&vr?zhxrhh5nsMF4*on~5@ z8#Hu-k3#lQ2(<7ATG&kolO$t@_KQ{9a8T32f>@lbqB;9LzQutAi3kz|x;QCYc|?&{ z=VWlkNtS>s>)vyhl;pf4)GDgv0DUYP z*sxuB3rb4bca9XznNU}SLalVapwOB#S&a>w$p$OCeO~kX(Rw4RS`^{6TznsiogKv)G_dnhl_UZ!FC34O>a1_oYq%TyTtWk8 z=qT5hsagReYmG-@aRJ5@{iU&mryuDDJAQ7lDQ2~ZC+Y504FB~GHV@Dn9qSosGq}R~ zp_+rsHtc#sNxd8#r;jQ~J89NT~1iA~!RZ zQWT^vNU597z?*-WHulf9TrD@~HWIb5kg)~jRnvfmi0gTbf9Dvkokf*QoR-t3>UXqV zpr8aCI*Jx28Ljohs2h8ElZ1FKI=~iS3DR9^=gdr6K5LYD<0jm5yExx%t~G>WmKVoC z>2dIWQ&xM(f7`m}^f>a$^3!ms3w2FZ-n7d77XnS-n)T*gP2m2BU~kXvKt1FevIi+Q zEY7EUiPl}*=x%TMqo`n->fPLahpp00ZlbZYWDAfV?~K`avy==!J#hmBiFF1oEiH#W z%`e;FQB(e&w+DgOXpfAn-2PGqg8Qp5hY7@FvIl8b%yDboQu4{;{QTc)mq*JH&NxJr zXBtJ!d^0Xo)h|B72-DVy$K5t|xNqbb+_XU!CThltL8#w=W>4lcriV2h430xAF*hsq z=??R^@eE_|T!U1^x6awmsAeB-XZTTLZ9g37I%e!c4MU?s172|IMz=xm^6A_|GutaV zccep0O(bsg&_rd_9o3J29^lPUzf_oV zWN&<((b8DeF04%&roj`j8`X2|=XY$ZTVW@sV#l>O_P#sO7;O!XM^&Egx}MZC-yF_g zt+k)CxuK%oM-HXRx`JWP-s$}l9m`7(MFYgz)IyYqu-+&|lrkC&S<8hWVZD7ly@Jwt3Y~RS+y&HV@n9k`&q*6q6>O|I2N-21XPjwCcIAv zxvr3E#AY~JwNy@gTcvtl3cpr(Ab46@9QD6U5tSUhJrKSbKE{KtzCSr~r?3TYVPea@<3FC`ph)5g)^R|=ug8EoZ_{{X)^?IirAmF)O6L2r3Vgu_~0MgEG-weF$ z!U|4xeX~#m)WZm=k_O42Z}LhUxjQ&yad;uxM2;E<3z;i?1DVjmA)=^D3L=FnLLtFF z%Zxj;WihpN2b@G;^w${&FtvL|jv3v9Wg;cXG)I<>q{h=0yGI$%6&gq>KDv1qGeU|j zMADgl&oBY+z=nvK@muydlVpUmQErK=6y= z(gOF`8+@oNm2x%7qV_K}$XX;&|E#+Ynk4jF0e+VR7U~7E@SmO}1R>k2Rr1Q9%m z__#NRlTy{z=C#x0$~8qoNQ3k|H-`aGP80l0flqrN9PR;{|Kj7L#M=R3Q|jfvXm6F* z3&gj{^TusC`^|20L?L^6D8rgl*>M{~6O}Gls4a0-dLR^S&hR;Ww6Veh5EE9P_he?_ zHaZ^02>|+%HhgeVeQGv%1L%4J%k3lcKx?w!m!4m_$VbyTiOAcKMNohjkc9NlX51px zP$+R3zfOp$qfH9R&*6ThlkzmIjt8t;Qz(VOEpGSjy?*N=Zjlf>W0n(53`!628lw?Z z6jCk*W-Ni@$Q(1DVPvY3xO|>I6Mqxf`I3zaC$vmPNg;F}a~Ud)Br=rxmzD@Y5iT@L zEvT>eLW>C};fIzVblES&zf2O`vdRZqnA|G`je>)hJ;93)dI~6lI60y~d!-p8HU`pK zPKb};=7}01nLgxxIJ8k+ScqGd_D2@}S8LEj(2}rvi#39*-@b` z7rmFosS_$Zj0Mja4FMV#AnlpB074h;I*iq0-j%l2CcRkiM?`#pn%6$z;qZCx+0bSX zCvr}TjH3UOe}Nh;soi1l7itBw89ZvEc42Lw0gn3u(3tgsg=D8GX** z$yB7?oj~?{kAE?AOhc{aGH3%L!Xql0D}h-b@4w8ZV1uwIA;U+T%nl&pWO0XthKsNa z<-=qIWOb{J*3Z7SDzI973Pm$*7Hg6)dgXx)YRMtqozg>Ny?@qmhZMSI8cO0vOQ@Bc z?2sw&^|}5#F`7&~rZZpN-aZZ(wFt`^7+F|^17C79&v~n|FwEIm>zPyK_Pcl$(5@W2 z-7SU(#AIkBM;%77wAltG!74e`nS?m1gcG=qq$PckMe5ddwSs9&mV43MxJn-GcgG5( z)pVB~<)stvDeJ%8t=gcqD%neYO9&>nunM-#BYEV)??RiV>MFqt_HI;O^;Ve^bJK~? zcD>wN6y$~=9c_k$6^}VNGga}Aq3aUj?e^MWCxRz$+o#$yvC|4Psw z`vESzZOncFCArtKWPyx9BeoZE<$?%%N&wF{Mrm_|wr7O>Cy8;ZE{=4F4KrH+k@XLm ztMXsPMM$7c)R2-fQTJdYUOR0%X-a)lf-8qT!CqM;4aX-P{wN(rzkdQFPEkE-6XT%Z zg_QV7&5SPdHF(RYE<|$y6}CmNX{E%Q7lr{Rk4a+%9an%f3WRpErAu_kT<5qi*rRDW zM_5^Q1X%7jfmOXT!X*p_`1x>M02tySFcbV9u>f7Q#D$$URa&it$v{EM{~67QSb4>J z(}1KVI2F;M3Gs9zJiReVz9Vh0rd+#NDx$SV%r3KvS7oQKAWZjnvX6(~N-P4<5Kxh| zLt=~YF^0yNt$Q3{V|Hj{57_%mI**1>ux7zNRMjoSpmVlzD#I* zTqfd%ywLI!dnQTA&oE6}<;47<9~q`gxQpi4-kE_!<2{hUJS7OlVg60j2(D8?1Tc=YqZ%x_ z%RK=dqW*y(wrw<|VgZH*Y|^h=vq;o^?5EiPG`jemjLNa+-Mswd;GzJZd}ty87zVbL zvo#S-^U>i?y4~{#!6K;FF$8x@$6n9fli@raJ+iiNx>gs3qa24~U;c~^ez_S}op(%y z0E=rJ6dDiw&FC6^awPNB4~=@?!S2|-c~1SDIB#uU@Sb{m zZ$dT89UI1oOhu9MN=+$E8$lwvea?LGwML?84C48ufmXeR@yka8c*-0%h9=&!6NxHpg+Z(0Jh|~!!j@70hpK;-3deb>mby%dk`}JdMtsqT zw@91Tl>exmk5Zy3r<(x>R`=t3hoIEH(Csa7uB}HM7S*ZTpdRF~9BN*KN&lFXRwzmS zCh|XAlKdYNsz8ASiM|cHAR!v^T}T)y6B18{dyu!fZ={7zBUO^)n+M)ZMqf8kFn+_y zb+(lUg5%_4m%|Z`Q@1wGSZ<#|*x*V)vMBP_=cU?3zMw-4%s3uOV$<%CP}Lx&6HV6e zN0q$xn9o)=0y2kVDX8gYpmEyW{2b<13NI~Ft`7&%muc9vvz!x54+!K<(uB2CL4d|h zInH5pwGjBzt)cU%qRjT0GGa@eAtxwF*_OVyMTQW%gSUR6?}@p7sPy*0c_Gevel@uO zR;Nsi5f}5%Y}`HboH_nuMm51W_yvdG6+HgtY@2Dh$^WR0&p%BiuBPo}Gwyo@Fx0np zMD3|smdj3Sy>I8w2J*C9Cf+v4aW`MUd)c>rZRVT|aC4UiAyeMP7QsVactDPpC48ct zjKp8PZoi9={Rwd#*! zHqpd%tke)Dcp284QvI;NAg+b`5q1}ao@Y^Jw@=t6**p=mk0}Lj7 zxF&r(5g5u_2$i2E&8a=jp$eRybRvGj8^F`EPgG$R4DjW+F8!1v+7G=`^6{l!?@=&! zNUEw>9~4s(4yHt&)+iP)1S}6=2uwBl^VZcrp&WM=)LEX6vPUYJ)LJ&1uaOj?jy;v= z90mQPGA8$-`88w`;4VM&TWK{Zk~Y#pqA*E09;eF!B5I&UQFGWmX(Xs5zhi1#gDFB^ z{xr9;heYsplw?19F6}eD7Yr7YP08+_j$Q);xld|=-Nb0Ekxyx#f(_#-uEDw~K-(HKy9l{tpgOYzu}_9tXl<&DeT|0d@^$%Ksz z3~d_yO0kBCi_M?B#6oWuvIdWp37<9^K}i)GCG$-1;%Zx zaEfy;8+F1j$B?OkR+^zGWt-qL^+kb=$B{(w8i!0IAA@V*OljahVGLHko2BGNza+Hs zp5Mpjfk0~zGiO9~_v)G(sm_0&Kk4$8dKL!hxI-X@_f#T7SqhWkIBv82#+W_0wr%5A ze1gtbWx|ZwOar$BLxh}e!;I3c<_KuIXM}@!Cx*IRQW&Tc-GWhG zLtI9XYKJ11lm?sDg{bvz5UoONXIBPsui%Y}Oy!95wn!G?g(5J2jx17b^l^(kM7s>C z5pu0-Kl!C6qxB0(L^48;e9Ao>N8PN5wy+c4ZqI7y3J!D>1jKVITNg!iDN!pT`;IG= zAK24Bwtn=7El_YvhNLX5DoT;AxXW+cxA(*T2!l|N^ic2AoA#BBsVD|A<5#~cG%4qZ z)R}5!qcw@T zwqsIR(UDa#pl{16Mv4tcld6zzI;BCflPG8k(eOqQ$oNnLh(E;((iSgRb=8Lz-ko-IRv$i;H+o0Txv7Fxg!Y2Y?Cx|A)xCSy?I zOzA}JUj~sueVS;%Y`%i{GY>+lcTl+UMsMQYLnyZ8cbn6oKt)h7VM6f@Wl@*nx$50% zpF?F5=p=Xw4y3j7SHyTWcwQktQn`LRpcwlA<`xPa`KUP~y-xHuwsS!%gPX~S3rTUs zu@L@3bX-!3H0y5)B>Mww_sR8~xmGx0b)%wd+xbl^nt8(dLrt?w&0g1De}E< z$}8NRO5W!mQNK^L=Wu0zz$oIi_>8aqc@AoZCIFd$4t;;{XR&fVe2%2&?>boSPhZVR ziP0TlG?pU?ibTXjCmUtZPT5+S5yQ8a@7d<1%8Vdz>{U6kbEGMKDK?98ve75N-(@wC zxy@l_WZ9I8kyodJ#K=Z}PJV=}45cb9TyN_}Tj5qg5B-?erDJ<)LgmGWe@xj=3Hx>K z#sV;(_ycQ1TnM_?ZvRoEQ19tmOs)3144w3sY(@ha?Mp&MT&l*CJa#_U-0yKAKYbYa_`2DY0Q|3py=si# z0}YkneJGMM%R^V`vqZlaDU>i`m=(iLAsNUz*dQNYvCypI1<=R!lQ9*u7XJMVLBoj( zF^*vM`?{jw%3RznptuCGkK8#WrO{OUh=e}OErzudKOpR<6~SM6^8;kwk|Na#w^6bJ z$Kl^$fgvPo$VjjRZK$DVTYH0baNN{722Hx;0b_iyslQy!kpDlH&N-^m?{CB3X=<|F zOii|J+d3JOHQAnQ+qP{^wvEZQ{XYHH`)8{IZgsCjD-;C%$|L#ef|Ot{S$<&C zJ#wJ;<4~cXCnu0c7$VZmtL4K+4{nkLB9{P^aMgnlL8TVx)j%&rk|a0g^nf4C(L7ti z5bC?=+hk@CKC`uK{QWoN=-p}nLPFq3IxToERL_sa{0WHeG32PeQcuC@-6MSpR{M3r9p#GFaaE469U&@Mj>Qp8VdUv5xiBN zfC0UXAtgI@e{G#%Q^qaLS$RGgi>w%YE#eSepf=DFIR>ZQ7r&T&U)$I)(IV7Y0x6C< zLBE-!jR1uk+Cu@12uy(}H&R_lXvUw4=|ZbuVI?mOKQgsBOmAA)x1}GyQ1&n!OLQAg z0lJbOPGF1(N0R|nS}dHnRqE?>b+&$Bc)^WI9nnF75)JK;Q&>RKV#mo;RWa3$&- z*q*ZbLDxzq>p=62g#fX!0VpMn3e?)IK$N&k=-`0xLZjJ8DV#;-9exHe^1!M61})}W z{y*%)f`?gPx-l^W5y zx>;)Av}wIbdZmgam8W^Cq5a8@tG>zRWvadI%f)fi8)R>x!Ei!VDqn1#%N;_%I-zQfPtXw15b z8HM(z4uIc8BH{FDfk+Fttf920tfUW& zQ4K-|2X%_huN5YgTIcQ zXJnX(`#QFmrTFJ8@ci)a@A-+~peGwQ>_2R1XG(@y!he@kskMLomSp>{FwJUvB!NnG z&x7^HKP0fvu*vxZJlh(7cnsD~x`P86Ba@1Xip%Y^*we9U*Ri$hsHUNTfnTF#m3hsC zSw*SG`%TsZ!0Ns{f9RCzo55=C5vRiY-F1q1vXlO_t!~BZ%+vgkfFGun&fF&+VY3;8 zmw05l>L=1Sqt&c-5%)`2w2jK{#-PeJ3x^A(1hW$m2?_G!;$#ud5UA5rvYc8# z|7vsCS0)l-4w=6ZIU@F+@Xeh!4g9EULmc*D;y9&or_F?I=K-o(_CU|qW%nj<#Txqp zD3M-pR9^n69oi7hNA-|9z6XI6R;0b!l!DK?&TqQ$p`Kh#vAAuM16|R&m?U*?FkxC~n|7?>#2ULwMsR^Wn1lE8_ot89;LL@P2)1 zFtg41jHvzmxcZrtkbn*pS3lqhWjkz z-iB1`vhzI}VZM@7^5v|6*;x;jDx$j#+O)~iUGYdQ0)ZS|TT2U60 zin3uW;Wxf<6No}{2{|96lvV!w^(IY}h~{G7N|sSiFk*%POrc1*=0?_Bv~hgr+8pol zK@U5$fj&^{qDAT*#;YH!O4B=VF)CC47}^Px4L|%O_ZxFQa^ia@VbxxHRz4}m$QDx$ z5e+L5e)~EfpWpd@FQL*dKZ#Q2Efcqu?w~ME>AzyC7=)t2kO5-ifw$Hxk`~7^1~T7a zN*q!bX?G8_0>W(!7d5M01s)*-+-=!8LsBTbkXZj}rg!G5Er!5pjK8)tG;c6Xw(2UT zpkf1&OPE>KWk2l@{r0O~5{|d2uaSkjILUj5NFwFm*0(4sd&!>ln1ICQqYRDnL^31XG>`^YUJ+CNdTdfUNJkkL-k8^%>OypB3~(#F#- zkd-o{zqhwn_sdBayN4t__71HDE0PQn~lm#SU5CPl{ebl}CW?+q~`_TgQ_;~jAMY1Q+%a}6tmw`r+<5j+*G9b88Jj(y*u0Mf-6!-GAlu;jcB1P&kK^sb z4D=Q!qh5!ifF`QP^K4ck04R}PyaO%suVn!1w-aDb4FRfJ0WY8nJ@>K9_X4GmsDMA&%j7>VcFxwPSxY2&V%X`Qxe~Z zwfDuvYXb);sr7>Mr^t*5QJvZ-HlO6WMlW6uoVSaNZRj|37N`ic&;Wmz&&T=XtFy=b z{-((&Yl?Z&S$|}x(o69ACA+oTWyI@K*Ms*jDY==7?<=mGtkAw%e~NTGGrb=V-oNN_ z+XpB)`9`w#gzEnb1(QQ{jRW`klPIdcGA9*YAyw-=>Y91kWRv6(FV)!-I;f)*r2JzG zrFUFw|HU;r?wWbJ5o+7Tc#crgAurbCk!WM|BkZRz^1yHb(j{->BGLp`yZSr)&+Q%d zA_;oa^pwu%J`clQ?@7eVws+2n?LLUU60l6iX7$M%uXh@-L3B1h zn8o6&?3!qMvh=6PJ)!Oe6JOY{bSi!la^>sVbGGT=vNTf?h0le`aBP3edS&wd>;0zt z*TPDm4b7_roHM(ZzVb5qHTgrREkQMJczP~$ATBZ`Te<$V-Yb?5`GX^)GxZ&a2NW zxcBPDP+6ZTNyW7O1_H`5i%9Y{5bIsq&f2m2YCiiTGw}@`i|CzLsU^dk(mRE2nL;*r z+D#Bn7`(|pkDGkt!H%tIWivNc`xgx~;qvr2G!*h~*%}pJ|7KDYqv!GF^&#q}(7Hzv zcuH-Zdq@@cbNt7(_lxfE@p0(~Fd(M~cE}7Mn)^dynqv1H2sYf%z4v!rX1%mz#brH) zWL*L)XS}5}L-~s%dSd46A$#oA?C@pPF(PmhD07bY6yAy~UEKePWJFxR%jq*-;MWY{Hpw zzu1B^*vjZNYjzzfbDv%htz3B8XW0qjBV_Yu%3RB$;Q&DdM4xsa;Y1$Ds8S1Q$5xV& z^7tpFH^KboM2sk*l+=4q#oy;(=UCMc>4|ev)Xy{d8t+Lnbnm916^2o^%r;avtHgR8 z%Vr22Oj^9L9@4k_uOK#~HWa=iJ(~M>oaljTUPQ2zxn`>=-$B90!h@MT&{TO^dSN->8A4iCMa%2;7AuuB3hZ5i!T_t zieX-j6BGR=@}r;^WRIp63TFC8wDy-lEFNYyM#0T4w+1qj=syGeAXQ`K|8V-KeDmcy z2XYb(WlM>XSSsef8O5vmYh*wKk@8t*LNxz)s4PqF3GF@XQ=h2cq(rq9wexk$Uzq299?Cd)dcos_MN8`#f06x&em4Rm~!O=U|$0aVM(*|)uD z%Yf{v6@L}kKyS{>(n)wOXB4N^h^%r1A}JUfsjYr1CR=Z`@sRk>E@br*Imr{!m^#!t zlV44rFmsKlvzT(tVfGjQQ|hK_t1+g(H=UajWfBCk;tA7#)HlK9SoIE@yoGw;6v(!L z>jwn3zr=c9c#(pMo>EhWU5;%+TTJlTU1K2%tSvS!JHX7`P4O0gL!*!N9H9_}&;HPh zPPU1U#F4R`Ly5aLjcz`$?-4{u^_P`UGM_Ndu4{{v&J4rnqe0YK5E&+#i zt8ZcQ15!32ahih;vPMDjJB&!jBZP7>GrQ89ST+O;AuO}#f94^@0Vyr~u}dj^WHJPX zh0P1<8!W-Y&}cYA@*SZ?_Yz{kEINBN=s7tBF{!`4R4KlPnmRgFQwgeM=cS8d_-fO=mesY1(&jak0QGH4_pp#f`&?4<~RO zV}B#f>KmNvdurbBppk!dF5 z6zHvV7mmfU@Oa-qB*ru|2AqpLC4mxkvH9wIK{fPQ*Wbt5i;r|^ESPS#0z_uBW*UQBe1``ib(hK&4i5n20uC_eheh75yx2w3eV{DefXnX^bqHIlX$>d^!iW3rL%WUz5& zHw%=%62o7%)&CQ*mCKEHK@sO370(oU{*ia%0C^Md2p9$NY4^W=sY{`c@Piz~g_k`A z2hoQyDFus3ulAKYVulb@ITI<)x@Hina2HX~nyHW5&-oWD+0_x>Y9tNJ{AVUnSdR)?huH#4IU= zOJN)%eWs^`byrW;^esz3*WQZN*;eUL=dHeu2ro_0*@-lks7uJ7MrXH^)6MYwnae>Q zy1dqmVXVC!3MrV3XZd=dcSv9a$(u8&hH<4olG!DCeM?l}jy{Cf1~GtpZ$JUxx}Jt6 zx!(lWCMNMZMtrw=ZB6g@CW7{0McSPVFYMFa$r3ae{hW}0rhnkumkELO#c`822|O0# zOF6T;#Qryus4(<1YU02{Xj)luahUiMrU)Z7wtxwv=oJBNchY2eKkm%q5*ZZnlzz&1 z%AcDGVZIIE$iBp4+*&2z8kk_(GmkXevB)9qIx{Yl3yS(cb`@q#$^4q2}O~2uM67iouEF{)h;awwsLnj#MP}+g>3gsb{V#$B)5RCm6|z=Jm6CwuK?y z&GtXN6Rb({Vie0f*dT$P27$xFu*!%Bl3h46-%@1pL{%7WLasrB>r1y#u`YClbeHwr zg6V^@O5Gl{Xaf;`-m6YfEUNntBIa#vuzF}WT5G`y(Y=vdVqdrB!O#RaQ(w0bT_Fwu z4H8rlkXf6hZ6!XQe3>ouTy1J*v4}DEk2yV)P;eG*Cs`t#AKok(0Ugno^HYp!p0ZpX z2+GX2Z|CmuH=`CDUbbVDh}_dW{Xt@z=ho`po-^dW3Krcg0K4l;x<{bP%BAw9m(j=W^THizbKk1 zQYhLll=K_)FlH+~1AX#q_A@kih(7}0_=+0Ev(($6WGON%7_Dzz&v`=NL!M-Nh%#3X zgsDx8Pi&^jX~0OxT)@pGGEX}KGykHF>N8wCPgC?NW5;-5gfWwm+@1f@cI{LOT&772 zYV-1((Vc}k{Mwx0P_Di5NEmF?tOeLsw|3GPt8T+aD!7x(W^cKZk5U+fZzPeAaLI&_ zm1&^;qGe#s)y51q4kY9RW1xfQq2a5!lcMo&V?Ad;amsTReErnr;c~ z>fA3EgQxBl7f4uJ*|NH@OuFrpg!bN*(j7}vkQXm6TJVI`x?H}(PG+_}a#6BOjSQwE zE(_|uM6mXZO|PyS*nctTI;vFyZeYbD9`z^(G2`(@7^P^MlyHwW=U$WU@V#=>cExTjs>HM6eD{-g$Co!{P z=mh5K+fyt%MVnZey5DoRsT{G)=L89L@QPZ_M^%Ui>=W@zgyit`Hv|wVB0|Jlq$VM# zUyk#Ql;Y_9a;Gdy2V-7v)keGT0i8H$4LdX-0>GYNQx#yk~A@Ne(kk67lOd=fmW zjVn^U&<*86bl9mj30(cgCxXm|GEzti$?;t`HC)v4t=&<^LXkkvLVEY-JgAL&_U;i> z4_%Of7%-*Hg>asDGk0O#Ww@T>e`4pW<<)m$4G6DYNvhm&LgM>OY-avB20DbA_>?E7 ztSO*#=chu8{D*`r`Sz8p(3j0{nY>(nu_2yv-dRuWiu5j4F z!TpazA1~!04+VjYRp%_Bg=PzasJfRFX!^oz2V7B$r)BNA`XmX~QQ}IyAPnqR1%kXjLeotsM>BT#!VZNHmP3upFB6wR0|t9|kt(7rih!57@& zsiUQs{ien)>c2=|{>E)+X8&DwGVuO2-d!RZyFTAYOoy93C=MH1paE`?PKQb$xBta+ zndia18v&_Zi?O{*os0Ur>k6~>epT!5-;NW3@{y79r9to$$ca$D;Dmj>5JEH4$>Vw1 z$4X<*EK%(j<#-{#eq6rGGA>P-z z;Taf8%;>ihCf~sR`=(cV?EWCO-l!T88ZD?MC%ferI&OH-EryXkUqvEi4<(;};8riG zf}_FQ)NUx}`uitGXKo%Q5%5O56ldkV;osyyRWex!;!A$0ZmvA4q?KU7e z&br#JqeIhF*&ZrcXJTciD)_SQ=CbMfjd7K`u9^X>R!@$EYQTN4{!qa01SVcN=LY^~ z=TGy(QS?1_apM^#9Bp<*<3@ATEIudxH+gFMp;xM~o!VEPx~tfH3B39NjE2pYP06%p zTT1ETA2tbK$0%pz%)Q?{bq)W8F>4|+q4r1DTECy(@TVO&ObRg7cd1NEC-FQmMa*3m z+%)~>8Rt29^R9EH@xpqUK5bGLt-t-t|7(Z?zOUnA6(t;>!IjPn%Fx9fkBsu|5-f>Y zUfo9lvrD~Q9eaN4aG265XH%YY;WBSm>F8Y3Ou_a`})k(@&VvnV%U}} z{FTk*u^zvUk2&+*l8UVn&u!0A4;13N|5kcOGjhJZRbM1d`?&u_uB;*dM=g|#*e>=V z`#^3#bMZidLl!{TC2h)Q_OT}@6ckoAe~I|qIo(Zk-@KX6b`v@8OrI;UML~Ge*j1(A z#xcI-bz5s~uyOR_=0>HyA8jPFJB|Ho)J8F z#_Vq$<7tyT;X*OFzunA2_0$R(htjjd4S3fK9B|Akta8}Ia))CKk{|`MYeS$T&v)-X zv+1`>u1#udyC<<&{wF|pqvlOAZNEbxWZZf9HeC=JNc|kp4(@>F@@7tUAD{*mCO_N| zxJVN#aV>}dr6!pl`(vq>0BCN0OWQQ~=^nt7PX{BxSYTdli0a9Uy)haXuFTnRd-kNc zDI(O=bK-+PxHy92iCjM!|DE3GzxHkQueUJy1Fs|Yt$bq_Wux31x)Qf*&vR|?2a`FA zgCHvE#VD6y=K#+$c37k_DG8F_md%&{T3`{*Wo>o{Csk#y;{W^d-+vOq0?LezIj|)f zAdR^yIL*z>mbQTwZYWaElA(JS`VRix{PqV7q!2jNs&+fm!W_C;!A#f?v(i!CU&oLQ z+vsAnszyS(#Hiij=<&ELeMER!5N4T8qzI_JT9)FMizqy7dL;8XS*}qSyl8cS?~EE< zVX4nIBssXma9Ux3Jhx7KlHU|L=LMe{ zb<+tAJlEzQphiS1GOdlAyZu{Pz0tgi*Li%WKYV}6HKJK}KT%)j_!;P|{q?TJAQVwA za1}Q?eb?yx@oS~M%q>{>NrTu6+-mLOl}%05Ut7N)u}A6@n8ihc4Q@;)X?*(&=QQx{ z70lt^aC*-dB|9hobSRH(RdXzEH1*Rjr5I>Qplj1`oRpd!K6zGTdXrFiKYdq`XiIFQ zexX|AHXq{Wpc^JfJv$gGJ=V`nkPe^F3_~PT$uVT}8--;BgCeGwSW1 zW2g1YL&NKeu27&JhWZ}~-9l7JwQ-Pi$Yg`m{)#i2#<9>PH&s!|#bi=P8=AH2yXggE za=$5EqbIxfn+L*o`v}jDRL2dI$M61n3r)!eBWo<{ zn3XZB9auQbCruB_%g;OoT_5=Me8@#=$PCku%xPDeBr6^ z>z8jdT2wET=a^vDW}>vhH5G{lrB0XCSbcQd7c1ZI^;cIas@!Uzw;6Glteuz(E?}%S zEuEJkBO$bIOq)WreebMhYSq*~R81o`dR*o~r=&=5#!yp%ToKhfj zNdEQj_GmV@l$NmF>+J5Z6d%+_ZPW_WlOwBm6^tCghD{l+Oz2?cuXUtu8coShq|EXM zsUM02fvrzjrPeoQQ5oD9QaXWi5r&PSls$I=ow910GwHURiLIfXDJ-95o*i8_LQzQ} zB%d)c*6y!rDr-d|4m>h0hYl5rQoym+9#{$f0&*$;o(}^7nRkY(MJrVTY{XpgoOs^j zP&6fGwY*{u4umhhBPFK#?37EX2S&SSu!H_w%dW*<;GV`JAp$R$wE$s>lgfWp#msxk zWy699u3hnV@&A~|?D7}u9v%LxvMu2UYY?lyFvta`EwTW)Ca<3;7mG^$lTV=9=vPKq zKwV?)#d7_{wn-&0(mSLmk-WlV^j!Pj+>;x}Lvv9s6)pU>BtD^z0)x~TAxE6f*#vn3 zR*y1NjRec!x(&2A^Hj9b&;pd8ff;Tw)HyDxQ-EKmKai5l(BeCGT59Cxb_lA<>cDq1n_QF3! z14W_+A4p;tydMvLX0lO=kwgVG#Gcfp3=T@9%Zl8;QLcgEh&DF`s~+taa=1$Qg;gJy zEJ;^PsZUIZmFi}q+~wz%>qIE1f7z7qBZ-EoQx1;EDTR{FD$%9pX(Wm8D%Lv{L=vkY zASo>IL8x(vmZBV|3}?{RBY}zd0t({sZDFcdv#k4+r1x7w^FA~0Cj-yfrbO7f26EzJNMU}x< z-;pVK;Ry^2E`csr2t#dM-Tz!5eD3$<@gr7{8GN^R${?Tw3s5_)kK1T}j7^5sn-Uh5sFZZ$@wrbNKozt4rF6*!iS(vvSq1=(j1IVn;XN zq+k({FBHH_rTxjWKc&{1k>VbOpv2@PM3x%UE(|XlQsR~zr9FelI|5t4$)PHhsU!q( zr3bA;P1?jnq~oEZ;LjbjI346#&&qtgN*6Q&FS(=ztsPVPsgz{GKeE>vaB&ZiSXFcG z5iMdA^!62ZY;zwhozWuG)ekY1my8~a-U$97)qa@D%VebT`0aP z|BGHGpR(^#2=huPDvT|_=5xuMVP!u|H2Gf!zcdfHT!3_@2zv-BFf^VOFB!$-;>Jw3 z)fV9hnzi#$rgWBxH|r9lo)>1@F@wj79AXl-wuz^-@ZE2NB2p#^)U~bb5)3$0Y{|2{0kuaBj3K#rxyM&nV*5=!Z3oIPcB;WHsW?v`QJ! zZ*1VEqi0Cff^0%CcCfQUD5z24g^`)((RU>fPmj@q<$P5rVnT}G=^CoQHbvX@le*)! z!`F_8Q=omt=uH$s%_6NwwC}qHOU8c5zZfc5`grXhc%sZDvG7-Af7IgY(deTrSSxyzU#W}~w>u=)7tBjeoyG+C?v<1&1X-2nh% zBtS>hO=zSe1o-ZN)(bC7wy5?|6HwWYxkg9hFbNk-%EeOtk}mN@S}VCyMtpldsWJhQ zXI(?j&#j-WW}W=tnZw1-`-VBk^&}~p%Nt+|+6DFydj$9bTd^Y>?L|Npj?0J>ArJaY zkiD#K(iX{P=yPV^xt!b3c7WWhE7>#o%-}e{_TXfpo(RboC>utOBP9 z^$?=UvPvTPJk3aHe$g))hk}&`=QPSJZZp`6@MOQ^=BpE#{uEJbPG<}xED)ii=fU(! z94;skAhVpY+)~y*hNM<+hYS9N{$4lyt-{)X_nZGOB*QT?((qE`R`nimHMVwG@Gnuw zmhlVeSEaN5=&ZZoX|@R^-v~q9^I41Q3n5g{f8iRaP>dJkHc$`{k9+M>l`Cv@hmwi+ z7zpQsU34d~glCs^J0@FaZ|v0;e=BU!dBQjzX(dm9YWORT%aY?#0SQKi8~^9U46LY! zOoA{e68}UPjGjPTh{#9jnXgQ1@pC8x7J+wr{G9Ob5?v|MW5&V_0)p@$ZpVakXh%|#-%w?6w0cz zUEjHIH2ZHwqZ_CncZ~=FHALcJpvsf&R5Bbf^#f}~z`)h)52c~+pgOiV*=r!;5Ckh# zE)1ri#rw%4TNs2Fc&asKYWFPp>7C#x&6QPcIE41fn!}O9Is(xf3Ka!@2e2Slvau8R zK)2Akn=FY>V@#A*<4~1WLoh>cOqWw?CMi~j67l9-p-+U`^(rx>;`gFK+Tt)N&(>jj zKs=m4wI;w;_OhuHx3KLN$63Z_Ro7XMmcH7X+&TU@GayK z?QTPuM^4+QkrltQBIdU&Kg6;2SRe0*z5a}-EG#Zkz`Yf_dr$jr-c>pekBq<&fQ#lK ze>&TMx32P}1wwAlH?8xhNauE;<5p47C*0mWztM8FWhd}Vt-Y|VuC}(}T~m|D zpC1GQ==`+}4gDh{BRQU0XgGjk0p8l>tI)0M;VaMW^s)Ek$DIeQ$;ywGY-MJZVT9vC zd@3rcPH9*SWUtdfFX>N2_V5MZKKaa(jbYyexsHGA06>StpKsGY?~nw617(DLnmFZu zU4T6T;Hn)=W&wj~Jc+-$bJe2wdZM)SA0n8m= z0z4X`rH)R3nI|-X&3TDQi>xdY47_kP%v^Wd&>j<)mWF%aKcd_|k18#}`2cdlRAojH zg5H2}*wgf8IB{zs>O+A>B*y-h^Awr|+Dek3rR~`S4W>A(K_Jk|h zAwq*u)?@$tDp~V6fr+XU5iTJR&?Lm(mR@ndjfmSa%NA0)v$5%4o3SVRPxgQ+JuXg7 zHm_t~mW=PVmvQAnm`ylI9Wpt=z-TS8!U z7zhczCkVwm*aFt)-?o`Gr^YmX>Y3g{XK5&A&0}V?ZrLbM!Wv)^e}%KJ(C%AYuhdoO zbb9h-C7jW!e^gaLlPo2kxHf(#sZ$#+S~>eF5na=L10l7`EMGj z!bNK`zlU%!I|>?wMWWaGq#D_2>3HIf6qiiZKaO+N8m@j1#)RXic`EBKbQkGp*LW?$ zM2GR&cQ}wE_E{>kH>rAe6c)Omlwp9q9cN0CaL5sI9_8OuWZzKS${Uke`&}ASnNlP+ zmqOvCJ4agoappoAX;P(JNNV;Tyn``EO@Aa8AWd-~8KFa?jAvj7@cN!_&1kiny9=a} zOjGxVUQQU5T>%}$mCG2QxSzi0D9{dz93Oq0N3yj_4V*2Kv)>y*2Y#Mkdr{S>5O1T% zQd2gIcYI)AAk&@ZdlSdEtSmqR(=mBXvw0~%%d`c**bzA@CkI8V%*Dm^x;FWi-hNkM zZ3Lt8x{scvYbDo20wRCAdEU{%FH?Q6l>rzCapX_E-ma2Q0fo~E-(KgN>%QihaLP7 zEgkuPl?pJX~N-rYv@&?_O}{R!UTvofkQ-O z&eOe?%Gzy~E4||6>CO8EQr}WcY0)eTBr&fB65uj_LTP9-kS&+?g}`K38@4Fl8Ml7n z^d7MQ+(>ldP>wTDB*gux)W6)o(9GLl5ksFV#Y#l-hy>bCa*LHe-v~ES+M_1tt#YYu z=t@17ka4Lan(4@ekxo;GU+2!xITpKur=byjVqxFMd|xVLS%@} z3z4sW;o=5Mf=!F8guj+)**2p^pu^U$Ad8e}D^jc+))P!Uu9tr^ZIdXiuqrTiixZaf z!AHkXU9LPP?$jX89%hhEC0-OIE1rD>kE7_JZ2eV|nkpu$TrZ96vtZt8?yn(}1Zx)G z+2Wr*&4%=fSmF^YNiv%g`^KRRm4Ou39~x4QR3X}5{jV4zdP(fBu#ZMWj2yDXYz+aS z3NkT@Whk=ZnjIDcOAj#K6{;Zh=kMdU-)B&wTov8%mug%6l)1J)@N8}iUei3TVP+Ou z#YQ{>o|c%gsVeg*8h4S^M!W4kXkAUs9RPvJ4fw&uHVlH?D@xr|4tIgLq`mXOwAjyt zv8uH(w@&8YJNoWHrZ*0p2@MS_n}CSn^LMU?h=7lBQSBdL!AYu2ajlha0Ku{rIAWD_ z1(nV;(f~}Ohpvw|giAm|s|6g|K%jByt$pR4z?bu9!iM5SLah|7UehB%l>Il|j}vd2 z&DZfwkNg--mF(UB$wL8GcJL;!h&$k4C`o-zD%)g%9OZlW$H3;}_iG`(TcP)dkN3@_ zt|zvS!cEtM47>N&r?0Cl@e`r5J#~P#>gnd-3HV%0KmhK2;l4Qv0FnSs_*cNOB$kLj z9{%}yybmptPm`}>lS6VJw{kCZAJ=rFlkcaK&s-Z<2(G6!gRka4_+bp~Ki(d0+`As! z0Z-FD@Brc)qI);-R+6>KFg7uPghh_R*x=7Un$0x29wE(x$tH`EiHf5B!t_0|hBhC)-_uov7g()O zni5Uhz%4H)V(3W*SZD(rt=H{68HrpHSheBn6R`%r9YAE@s|#JkQlZtA+A2BKUukne zNQY?TB=$~C)S<{|lQ^!cxK@cDH$NlWX3uNckxZTr8U9m&GGI*J- zQ_EoyPar4VTwgkz8azuOBJUK#z#vIu@NIzV;JDj~y1ao34-RD!cRL!QIu)C%KmlUFWc3KW9~XY(JSe15NRp zmcOAFLon#W2w5ZUg`R(rS`X8w!pf_GtDV3iN_R%7*I1Zn@+2(RTNeHpc9PIs0`0m5 zehZo?pye_gi0De=w(!SZ0QCMXqGeXD@Bd4OoO#xq|N&V1PQO` zZm5#I%{L_J_3Rxkc4M^h*RavxjXnd~q3bT9(Z=6(pat@sJEcXEC@DEx!~X~bNG4gS zYeCnc;-8y!2A8BT>h{+NH;P-$KyJQRUUPLlhjf@x6QBGMUKTJSxduFTPLm zk@3CF@{I!ruU9}P%~tw9^C7Knr!)v8#%S%NJqTRP&}F@>9lREK9T)AIc{F^6p!Eo@nj?As-G-F_V3?H=vSm+4WxaQTAzG1himHUoQvPI%5FzZGn?{@&O46 zVQKvt-D^M>KHtX;-^ItV?i{q(a-Gm=@Sh)rxPVuTB~3Iyn_-oluJhXc4*$JUIc}#NmzcV$J?TJ$LF8s5%y%k zwM-2JN=4N*H9n=EACk|SHfcY_^+E?eek-62RS@ue`rL#I&9p$SfXSTQjA{0Z7bZk-$D{Q$V2Z^ECST`gZYNi4qDRL8#=EMMuC{b(QG6K>b)f}t;O#avwSOTXs3&1_tEF{lxyTq3Jc<36 z@cPIVy>0X(md6{(Z5Fmf2r}oFuj-!yjt6m?sTl$dy>BM<+hN|fpv|Q<;y!5E(rZ<- zA!EG$3K37ps;`b%O0G#i1jkLakMfOB3Yl#a5-9{I1vkF06HQx4Q~I7WmM&sz8V(jE zWA*e11_%qa3ntGvpszP?#hxi@%E%k&2O>j*{5a>d`-FsO0yV_~rWXhj#G`PPR7*)Y zc&+3#*AmDj*rR~$9~EMOINMB@KrPHMCY1U2Hm;>~v%)O1MBEAIidnXbp6@@(^kuDr zQcB8-y)eMgC)?ez^YIq;@$<>v`#i{lyY%UATUVd%`|!`CuvM;bF_wKFz*t#Yx-Inc zIXa!)*0;5?it@Q=X-Y&G_$UkC4T#>V+~&)gcOtnE);};yx{m>_9J7oNl#ewKa>hi~ zGcd?=4%J+{dD!^^d1E6!bRpwVOm$RA1F|{O4akwhYNL(vMA(Ngw z>NP#hzypAB9_b<-X%&mttRbdczoCcXAdNl{vivDd(t}>3$&pF@*K&G07py-FOUl3& zWm+sBTT!9deUj3u7fBo$ZdN<08Xh98$gJidOgZK(01;!@0$-q2K=!Tbyt%Kw1+rh; z265PnY&b<|ok0$6W6IDTQD&jMTS8W*hmw=CKD2mVf?PqqgprtzVr>LiqNdO|(GxZ} z0QgQ$!%Y}kLysXPUN}LStX-5Cr8PQ%;d|qseGGKa1kz9ek5VkA36bO#gu6BGX}Q~jnvwh1edWAQ~U z(lItso}Woc&Mj+Co;_d6H!_@&T+5FGC*4%GV8LSKtxhl#a{Mqb3ds^x`Bbz_)Ff1% zM2!CIa%uXkeoDA6eRvKEl!1g~VY^Cd-mfgAp~~2^7P4-HgBF3kPzk0$8a|c`lgz2@ z&_{?zYN}1JfkdafW!E-Z ~Est%EnoOQcqnc0Jhg%xX+Q6PbsUOo^2Rpopw!BeL z_H~w)gFm!TUZ}w6$3UMNS)4H36on%`^s0t}x|mJgx@C$oa+Qiao4!SPaGs-||DRSA z9vfTd@3thO26a|yt82fs*lM|!@9EY*_P`O?fi39Zqs8O!>(5&=%%_=k=bO>4*U{Qe zS6|4VkCY$#J6$J6n~$x|?R7yVqnR!=>dZ;Y8<BA--Tp zIG#hyRSy6yhT66^sntfHX7KgKW1feA2SAs#b<7G~{O5q|oCkwTN$Qb< z#&7&&Y(ObZt-Yx2IFQAtGka7Y6%@r_JR}{G$t|;9953H zAxW)vxL*@``u=({wnRbE68AciUC_g#={RY`XG^+d1So~R?o!mAQdS-kFG;(|rDJw_hy7o$qf&Lc~U+EJPen;d>23i5DQQr;djzDUyIV>cN$CdZ?(Xi+A%|w@ zp&RBs{NJy@HP>Ozj%V+6KWimrlwGO?hVv!;4M@VQq>#doZtQV#P0_#k52Vm4a{fe- z+vsD?uS8#3D(Ymw{f+6{4^t9S5hLsfqP&u4_|cPl8uKD(?TfKAq+i)Fmm(wQWXWJNg5qWJQiAzScX{azU~Ld`%YmUmReIQ1DI;>Yr4NwaI_hY!fK_D} z0i>Rb$a4CFHH(YGhQ+v}R~((RjC%eixUj$&MHuxT|6{XWN8%}3JYV0$V?aqaiTT)W zG3*^FIUyV=bVd*w-}30+abxIkloFffvH5yHLD%=TQwC|paSeL=0AGjUduG|K&Zz@Y zq;ydNm%mLS3bAa=!O74yusci=@sj-)!AXW3-9rMnvaICS)6|H@!o0Ev=~KHGio2V< zqOr{r+pi~t$|iHq+b7;%(^c)BioTj)_S7*m)0(DR=a6{OVk*ik-sl>idpUbiTSsBM zk!nK$oB{0>*To4{o{USMo|B-7YPQ-VmsH6P`AqhY(QoEhvALm}ZF<)?KJtdb7v9a5 zH>iTw{2itaPpr$fi*TPGqGFxSJTTu*H*Jp8(L%;3i7xlI1>u!QN_H6}1r!DmQGNwg z|B%1EF6Y`{hRkqs;YJQwg|V1esEZbCCn0BbtN-=G61rm%wLvZ+Q&(40+hzgsj90g* zKxTykPSzvEAk52V0t`@-$zFW3~cb7EXrA zgSMq@^NI1Kk8R=)frt-c>}n?X-P?&s_|L}(>qjEA!lV|lfafqsKWsHTstE^v^PE639~gfY1)L^^AH6LY}OP z2LZthCGbo*jCG_3ej$3BzZoffPf$oi7C728kn!)`Qrc-9eYx6a9FZtoh8#U=QAFbo z!@~6rOv8%bd%_-u1Q16a=iAo;^&tg2WSOA7M8Y0%zES_9nzT*n&^Gvf*fql0thvP<8TZM* z^)l9rqvh@*krWYqF);bE%!mt523Z7z)*mlWOjF;3TL+5R<)fF}sUnY#5h*+G-j-3x+nhT4k8qX9_d$g`Sby^c zG;};2pXYP8AqQ7h)eW_|t}P7aD}e+TRsVAY#iXwhs+S_fZ0;zlJ!fS zIeoVyJPe3#$Q_;yqKqBxJ9P8fc|O$A?V{7fINp?stR3!MK*K0w+D=-u7V;5ekLpj` z2@K8Tg+U|t6H5!pBR(#V?xc+9eGXw<$=ec*0&gW|750tZ@nXq5FNOW(Gwz#{vfF0S z3Jk%|=I+eJQA~LK1ILab)(C-(J>6Q_-8fIz|I2hfq4KBMqXEm(ekp}N5&?u*^kij> z1_!+7I61`zUy@Zo=x5Z;iwMeD=aEVAL`)C{KBeQ`mb6Dm`3pYZ>rtgVu8#(+>J@w^l|u@B_!pJmQaqhCLWU)i>{50A+w&B37gKKyCA-6Px7`ZEaUjdd|CfmA8m%SI2AP;Evrn zzSWdda-W;k^A)I2B5TL-jsuWy)3&h$#Z39SOgj#NQvg;V1z-Mi{&()nBA$DQiEpMo zR{LL5$~tpLd$7%4pQSBS^98v1FadMrys{24PYsR;nQ+PE4PVgk?Y_XEILvzixMnEe!Ije_vZ! zXA~To(AekBsu+tNg%x9w4txcV*|lfeyk?>Qu8tm)O_}bc)T}v>RVSL(y!oHdtmkz` z%fdn{(hy*5(hX~ay6()*6*xKA_X8c!_Rm)xkLoHB#6pk345ygGIQ~pc6;Em1a8ZXtmnbKw}#MS>r9vUPTT{jBF$HY*@A_@9&(-rMM)uPZW zmZ(({k8ih_wfNh@t*|6CWO{nqg^+dF2s@flscg1)EI~=#Y$s&KtSm`3>KzoY71}8I zsxaA?K)`(pTHiaPc4JfU4JLC>a>O5}>I~C8HMlEpcv>T<;H+)R85!=oP;D-c#dzF6 zynBhz7B6$VnoqT%gO2W64Q~}>{udB^gUKNsuV82%Yq{Yy8xrn-$$9zdPrp~@(y;`+ zXz5gD-Wl{<2dvV-nj!F~QAz0}(o`br2II0wXjFFfa%88(^}(AEYy8ynA^DI4t9~Er z@CkL&|L~w$S0y_EQP`(mb|?e+@G&epjHo=;^j^EiY19U~a=4O#B4Oy3Ci?_;8ko`a z=<&Fe^NTrZT)sF=T{GL@BJiNb>wc@MO=Y%>?f-i=WJ0Rat8AhvM;;4n;D=7EqY;yd z_9&;tzl9ID_yW$ z)-PT985|QruzaXY;txTOXDU~SBX`N6rNoRtDqAtJK_VLM@f!yE$=^4=V!;-jprF|a z`|ncqC?}?;BrZ;f9w`&o`+Raf2`V8iL-m4*-stVqyl0kljabyA(MGR-%phdRYfXfU z!1U%RQ9L^yVAqQ~WG6~e7^z^4w^qU(vgt6DfeBtF3Q@e=C%;WR<@2X!QL%^D*t+k0 zmW7(NZ&@>-VQ=RbtjR{|wR$l>*}&CyTftsItoQic6ie^b8o`dtzjzd{?%qfAZ@`Sf zZOb(pCYMLMzQkG;vg8Q1gBm3{?!p56SBaO?8@=#y1<{?3!^24MoIl{ceV>>X%YjqkLIg zlv5nuJl|Z7%s}suT54R#2r6uR?k5Js;%R4&r_0P0>4}YJaQlwdPD}ek$4qni-QIw3 zf7J6TdGwq~@7>t@6Zoi(%>GSf@#!E`sHAw|L)E%h!dr-Au!rsF*ZNK}TAabtPLIDZ?tq1zy5Lu#oW!qTy%i}t}vV(Jz0`SBrOjTDR#-}IV2J+lmH%{HV_ob`)yODKG-vcbER0 zBob(ai#0GcRp9FJAo5EST#dHA5(mo76a(Ynhy%@K;}eEKA{!b-%fI zg4p*coUDAR;SZoJme1@XogFadn!i)WK_7+NHtDNylSQ8^N23jw@sPetL_U)Q7$~Qz)wUA-pYE=)?Gf z`$jOtne}(jnw|xGwX||WR9o%RgG&gn>G-i`CwSx_jHf_u-7%Vos=lj zasSwD4cJ=i#dA!!Z7tn=f-+fcIufA`w^Xc z@DN%Lwj?>Jv6BBp)Mn@ITR*y|&DnlKJsAYM;2{2mL6Ns1QI5$?OaDrM zDw$8sEH#`&B0!E}*KGDvyNKOib{)ik5FXR9sDt1AVxwg5=fyt$DUc17xLENb*wB_u z{ji@O;s_8{+5RgoA&AgG#&5@r)pRV|Qg*MRJ887;z>PKwDGHNu`zFjYW6b0H`T*($ zyB}{D%Ic9cUuNK5bHf2^JIb^noVbH+hi*J4ZWtFhe8xfQIBB4ZKK>*vpa9GCbx|-VzE4*hC99c_FwXgh zD!smyC~;DUrYZ`_V9W18hhFw94{QYYYdy}b9|v5B!=gjQ%O6%D{?-_)L?%Tx+}$Fi z(jT<*?f(JLGmW4l?`6h-ms{0C)$d;;dGI!J)}Mow^@Tja7@I|Iej^A+pNY=Y-cEPM zq>(!&cdO31tJlz2td9Dv&+^GI|7!0s{DiaA!ra-XHe{n5(00^t^kAHEw|UdU%aRIJ z{Uh+{7qn&Up(s4-vlgAe^?f%+RGr(XxS`1u60!H1cfdqF3(Z1(P2^MC3zquTGL>jTzh4kX0uU$3GA4AM-lBH?Zqpb!VuCNjV3eo9B z#@9`-uDYfAb627rX~L5bq| zplH+qGq-SJv?_0hz4C;>qf%2vhKWG-x#5;5>~%V7?F=$$o%owm2gAmo`(1-K*JF1s zVZ)xc?K&2htp`taI*bP`R-bGd7XI~lz;n%Dr#i|Sn^~r5E`QnCN;mT)9D?;Uu^oJh zF^rs@ogrZ^9ibcnon8KjeY}!LuV8{(XBr|%v(9aXLM_O(8xfe%WL7~TpNS$04^xVZ zU0g~RdkTv~0a)VavJ5|f?@=P8!QDt0|Y#R1Wy z`03Mjn5%A^-|<^j_%m=Dt^=NR_8M|WhnXYkdza45&E2isUkRfP=$$NqoV}mD(=pte zZ#eLBwB%=w%=*uqfmU* zV$8egC7Gt~D@riAzOi(F516gbiyKr1;rf|=2pB7qM}3gZP9H-8^?jmmuCFuGiYz73 zFtz410Z5jKRaeLRs)4Ul;Pw;Z?E>#}pXr=|sIyKe4C?9)ZP;;z=TPS-tUV~gzG?6J zB9e6@3!L6fDBu0lT16WoRDVmvSl=6+&tby0**0xs^8@ArLzb^R@ubc=#?B4EB%Hzo z><2`aou$5~RbX{Kq$K^In~~OBR_s>;zK$7AHBg=CzRn3@$q#twbbhUX>c&EqA@IWr zsg=*zb=B4Eu$aZezt8F9yDO~RfG>)%abJe5J9vJ6{uDA1)slI)lLoy(s-I~TH6*D| zmv}{k+f=apwKqhiC4@akzUgueZ5hRj`&;ue0m@RnwLAP_t)jwnhe;d+6&$!>{H*@{ z`%Bq@zSHo4&^22G8_K^9_(MkptH4d5v#K{RTprKJ;Z^O=R~3ovOe;`fxI3BqHWq;UR2FzIBDEMKW?^p z#s*eX(8!IMj5H9}Rm^CeBGIXvaONfl0@v%{Vq%T)j%>Q)39k2PX^=Zaz&x-s`` z{Vr<(vYNhPknm7=A(V4ddFVHR+0x)SQ9{E&zLc@?jyaPv=;D|1Kvxg7RF0pD?N`25 zCeBI}mA5J<6m#p$a{H%7rZOv8A79aI)lkc@Z#Ns<$(%`jYt{nSJU7cU(=rhbWcf>C zLPOZFCEiD1^ro48C(^Eu`~y|Y+-PO=I~DY~upI__j)67QrN+{DUB9b=UQ*A!9|wH2 zU&(AnXxZLjCy&vk7#dj`#u5ZHPT{j z1vKaDY8r1_u#A5x$|r3UBOQH(%|@T2$KO`N8OKe2+lhGTKH-t^5*o&@gASgvp@xnE zGzIzdY4FvH2OceS)auLDOy)DEx~@BI?9g`ggwMwc5C7{If}`G=iA_T2VR0h6n1tVD zGzB&|{$X)v8&3YbL4FXX_h_)sZv{92Mh}pAgaZMtx-MQUu|0r!CJ^Kbw6>V1nAp$= z0C75wk_uuP0IUM=$KmSgJf63o8Rv-z>CM1YImtJ3ooUc}IFQvG%}&ADD%5o=_H6(K=-b zgG7JoRZP03j;Xk^5LnKkB6})1!V(pew(WN&z;`xqUr}s9CUkVuG}{V1H;|b- z=(Bt4cXn94l1X?S0z6gUDkZ8D-rD*$+KUJTIRee-R)&W|YQ8v&^NMNUmTa}!9cdidiF0`%&YHGy$De zh6A^|w$CO_yD63lR0oL>;*z_~hvl&V*_4vM@nYg;KdubEik!UDO#{wFP3OZ*I{+GA zF!;WGKeb)jru}YL@G##P{4k{G9RrZ+h7h4Tdi>>;l{Ccz0E^nGK?Wq-ReLnn$T!Xu z0a&~U&AFdBN?V>!V9(|MV<6BS*TN_=`_dq%yYdC9bg@H`_^usyRo?o*x(AGyQtnHZ zVKW}Qo?21SY+Is^s;aYG-eb_hHLC)^6ql7%H8q)s%RgX>i5^zzDM?0>Y$!f&E4l#( zaw@9s`>r=mRRKs`SO=lkkKxP0Pxc_+j??VKkP;H2vI60|1-&9OAIZ>~7@e{&z3Pt%f8Wdr9Ag>EE=!Cgn^P4k@hg7Md$Z*-{v%E->;`nE zTWa+J|NmEASK#zY8R_!<=l`7L-ZmFDGUl)-*T~?TnkBdw%7Xr^BCv|+MEn;rB*0oQ zX%SpV9TK>lTMxb77j73Nhg9QltvzMJgCFA?T0x>nfWctqI_&8-N}Age=;i6Kn(MY> z{sl`OGkGx~d~p*~#oDlajOd^Q>v}Nz@A5scM;ifAZ9wzlRi-#2XC$54I=t!#!ai_i(_9LK!4eIN4AAScz7Il(dZR3 zwqcLy9XXlsN9fLg&*Q)g+|5e(tnEVXN+3_$~eMP;+W`>g7>9PaHF6fCi&O!nUBc2>o1pQ`3rey~pY^M4wZ{ zbHHsdeu*j5`xE2BYQJ6j6}tXyg{JSI9GT;U4O^K187vbG0pHxSuz_mfXn80snlgb4Fk+NZfz zPd71mN-Hn;&dUlcQz7=sq=t}t)d{Dz%Z= zr#3kqgF{B$bzt3*vIa;oK<2c(;C?`hp`eHU)7}KB;R_=Oypk8pL1WL%RPa_8FV*p- z2pEc`cke>g{RiA|;%7b`nHpPLA4}i$8jIAxt#~GY=Ee4dl6IMN$H$@c%HTUo(mn?6r{KGeV>E^xco zGt07`(&nBTrf&}u1m2S8Wezzm*2Z8oHUBBKhZo|n3J{1FSmWhq>;N}R$W8l=G}svk zM&-8gD2ID@Z1Cd6p97mCZiQQ5C=&VOI$~z+Gd|eNCp_zhROFwv7O*-Svw9?d2i46^ zw$Bm(%H~;8y)}^WC`1LMZ}I>mbLHYW47@bK@50dz^?>JVdt7nJnP^>D5)j?BtmgDt zlH1Hg^|`v*qfJySBrj8Qm4i(S-S!|B2n2aLag_zrw&JVB4xJ!@ zI5q*tKwwe00q|^_{;oNHxdxMv5+ndC^|_vSPG)K8gx#0JVqQ9wUfPu&J24)AqtiDP ziirmRg!%67ZX{v{exCbQ1oumu;DxI2mFf%f3b`A1J+BddsQCrtH`)Wgfl-WV)K=n& zCIn-UX9Ux~xQ2$^=&s$*H!yZaQ%mly>70lkceDU;=8eFz;Woezp#SvR0FdTd8CiES=-?7xVoc`wu zais^ZxpCNfUX@&px4?RlNzl9Ralw^U_P%_aG>OsMQ!9Qr)i|w=Jb^6kDx+#%6kVYC zB56vC;7utODowi>CHoM+iq} z*Af_!Y^BC>MgA_~S=h+Z7loFRGHAlgN05KujHYo&m%4C6!~2bnnM6&t9^b{5i}yNi zE+|8;>tS(*^pWhV3!A{+2i^X|#F^uHY3TDw_*T@Xpdk?0ZXh&~zb3v`^dZdxtuimf zprXtlpq0xQaoDvYN3W#~3|#yJ)mrP*u`O7V+$-*|q9X$s42hWCB&qD$nNMu@9T=p1 zoO)X?b>tc3V!ojrBp%?82-pCvH03q*2w`)HA?Z1eajp2kpZ@dQrY27&#(#40U<-Y) zHsB1M0cOc!=98wN!*&QzqB$rsR2q~05{7+oX+6DTU4P5^tKCMs5vfZvIZA^@#t6#a zNRX!$XV`l8MI2zZ&I@T0?K6K7|TT0C$N_|4sMH-=(&w7@o=##>|B^94Ez z$YQ;MH!D3i-msftV9E?UKyHNYy2u~DwQU3u-vX+TtEM3PEUF;O{z{W$>!A)T1}XsM zy-$Rn>wW*tRNm+ZRKr&L&n;ueyQ#LjKvTlza{KY}_T7Y!5c$)d-JiT1JTVd zXxi1))t#S)j!yi`Y0d++ISRJswiTgB1u|bwuq-$ZveDIarbJ2mbAq7Mw<+P-yJVIk!1*zjR4LR7Va(TZ3iHS-BH3wQvQDH zhSmC>7L8iKq-Q$~Dsyv@2^Ro?jy2$d7H-N6Xt>>Wq`{akrwm}@XfChgo(C>QMg-Fx zTLFjS51@4a(rZfu<`*m@0u{fhvW~Jo|NUSBYO|M_9Y#cgaj0z}XYlf+A-6b}=S?te zcncvlRYL!s;688^8?L@;xYfpkx)*V=dG()b@Yfc2Tf$mN?SL75kzC1Zl37kP3frAWCbwP)K3`rH6mg9Qa6Mk)BFAJbu z{08CyjVA>fUe{=fWiS@BO#U$w^O^#m!Q=hN1Kf$eALo6kNbENe8dhCWTld6SQzrBm zJre{A01;v`SSa_WrGC<`?n(joWm$12)!(K;$o_UVjauMu*$g&nZ9LQ_;p+uM{!fX( zL64#GO*l2x3$zTh+*Sg|s;%xQGC{!4Ixllux9r9n7?Xeld~tDZoD>6)+rWF=tnh1n zaWh+6?V!inARGWJ$83jzErmpxAFbj9^gC17$J?eC=pBE9A<${{W!ZQ42sX7-y1nUZ z4)1#&|CsMCc$EaleMXmjH=1^64ba*e7Sd#H179(yKub)v) z*(KQ=G6XDTlNGm%P^8TFSV$?ku)vn7yHlMIBD|>dda3!Q}j?1J5nc=O7Qt{L`la!>^SX?~+i~_Y~fj*!fXnWP6gt0$`$Q&-Aa)saC~S&sxk_&b~$tc$2UDs^R)Kfb|*9-kdDdNB-yV?V?^ zJv|HBpvbXh3VHo7tx48Kcl)9};B|nP^oFNPCCv+=)OvY(%3-;3+4|B_pFMf>y>A*v zl;AZ1n}kWtqH%?zGB`I5m~cmraQ%^^XV0gVdsx~?2+yva0mwr5yN09@YV>b4 zhcW)B5F`H=@$Z!$GB2V^Z49AwSAm7mV;CM6S8$Zwpe-6F_ni$BPQ?Cfh1>I@w*>fUD zkDW}^Xs~(45}iTVikn)fFvyz8smOVu8Y zJQK!i%)ibBG5XkH-itNly6W)N%8Vv>Tk7Lc6tzJk4yoH-^*S#L$1`50xh=wp>E*?b zxP8%dho+|o_sg3KAjWvg8w|^)7j8`qQC{+ksgH8?avAw8bPd2n|mc5a zKE(EqXhEnQ!al4{!C_JZ5sclhrLJT7U81Zrylap$-z=*7Z90AQ?#> z?NZ&hg&|a2K<;JqU`(dIEuVMDCr{*BI%_=fq@ufMW9fG0mPiyv*&q({IMex|?bvhi z$@`0^E#6v0pW$o%)zHu{4p3Zv9kV zWR;)|dH`lr|CUmb4vRA{iRfLb%0Uk9L0asFCw}I`=9#M&IILgRr!35Pq@^rsgX4z@ z+WU#N#C-Cr(Hc1REy1vn(D>KUzp@h!)-%pdV`~N))^etAv)kVl; zR^0pellCm&hq+^J7+3rFUje}%f(?WPevTq|l=uWd?z*McS`u3+Tpis`OYDnF zl}c%|vstf)dOr|G&6_z=O;E)<_S$qKm`(SPl=7#=9c1tU$J`s&4}SZFMmV?rE4| zvL8e~`RLsNQTdE`SItLH*Ou{BhxUh6lf|p62NsE%jN*+Bo?Tda64}!6DkO@sIN#J7JL`C?<7N&MYSG)iJJy$mjw~uEAaKAcA?g2c5_Fx9icELCmR{O8;fo(9vtV0 zh%QF{Yz@_Kqn4c4)YJkDn8k&z{wN+aIpXi$K)HRk8g_~;%V%p$Gyd`mKQWxNLRpZ< zFP1x=v^<}EA8sH0Zx~S2NqKD|(3%O0(AB1P|E6=_%iz)-oWk5ney z8^=_=@Lg2VMn1r+trJLL4&Zw7xAmD<9m^yc494w88V~Lb9U5CIjz99_GEaD~J;GNr z2G|QMZfn4I+BPj4&M;SNZ3{sbjyP8i-HZd?Ysc`Bh&<@Wpkn7)mS}=JH6m9enQ3%Z z#T>7Lnyq~dEd5r#YQ%WlkszO+A$1tfh8MQ0>Q6?uFgQO3VcJT7P zsQvx^T_!)2!foHmHy}Vg3+K>wMYlE|J*$dr$UX*Hhyv8-m+|E3Gmks>`)m@L<+AN3M)VWh>WO3yoFTceJy{z$M-Ba13K2XoW2N1w)QHkE5A4`PwTN?blVjs5m^@*hmb!%`X)oukR4PE?lgkkwv-ok1Rp z{v6P>)EQ50>bHW@2z4`6nlWsUDy96J)aW#-8k;wM1rb<@Z{jOL%uVjRx#(KX3h5;C zWJ#T8{`x6BfJ}}9Z19>6QkXOWvLpRRU1uFwP!a-fzu5;)ue;_P#wI z=slU-^^#`xK0c3@aj*2wJH?`E;Q-k`y8XPWZ@DLuU0AUAEkAuz5UaC`L*dcepq+|^ zYG7{;-IbXJ&Yo9kMV>C6L47{uyWan}e1>g&IXzms?T#4LCREB1`_T1g13v?_=d;u! zzpn`q3m}MJh{x{Kj`OGwu2bj>31YT3&!cj|LE;j5tMA{*sDg^v!&o+Y>K=4~{wF!E zAV4YF(eKFIFc{U(?G5NQ&Af%#pcd)dGOfdMOdngSJnEE~iQ2Gt8pijIa=+b>5X3~J z_AHCn$FmZvb27djIz`pov6$bC+YqZu|deRFc_!6-K2tW zcb2YrglS#*nQ>R{)NC!Dt(|N1lKmlobpy5;FLqbEziXaJmzZ8ZE}p3c8)_91qngf( z*~t#iXOQZxP?qMT3@R%h4;rb>zu$}IGdNLVqtMEyHtXLyV__#MW5fEEPF)a*YE_Ac z{)%}?;kVoYFz7*Zy_{^EEfTZ*+O#}rSEBP$ua4gx*HZ09nh0!8E)jv|PWN&!YV6Ob1ltbb7n#pE?q`W!4EWsK zjdmvjuAb*OD#4-4!q@iCdhq2AwlCa2(X?G}3Gk=G-(Z$# z%I%EUu|Sk>x%hOBDG(pzC*J5z1|1=|uq1E@@>>ZIJHTxn(O!k~LV0nPUm|hZA4fVo zB^6<0Y1eXoWSOK-1nm6`KS*#|!0);NQWIW;K@CF&*}l3-TVBo+>%S-;RcoY(PI&*l zAMrGTx|a0#gZ7pNDx#E5Z{1{GBxi*&z8W7NW3Ri!98$7%Og&%i7Zdg|u5n|N8N!3O zd|eKQH`vxEm~5|viY;PHv`>HkHKe)6BWDz&YX5?vts)+24gnqBLsvRWbf28r$tr+0 zaZMX{NZE?#mOS6#j~AO2Y43A-ZxIHW%e#pOY|9@0^nIYb8SH^({v=`S9u8^cGM;wf z2{2Kcq0P!M+-A&0Y5e5G=!Tm;Vdj8hQP_`RFIWF38!+y+NQ_0tMv2auESVu@51HB_95Q}jIFeJSrrZPK zWp!5AC1^=>LQOvQ4vMjVI7h;#4rki?mp=Y=y>seKxH`B;RfkvHjvr!|JnWeSVu0bx^0SdDsi2r=2{;Dw=oxK#;*LOvMV>+MEwy zktohLs3VMSMgm2cjL<}9=V54D=lyD8eitE1@|VP{`;Els`q+~7#ma}53A4>MntlTh zvvL(7Y3C57sx}?q5OiU5>2i8gsP6#+8UuV%L_x|7|CMoey=vF&a<{}_Ov&O@sZy!y z{UpgZ#yO6YMnQ==bbF*>kEVV>J zwN)$Z|H}KLiwcWGdYr1IT{4Crtb5%;wE=cT9z+c}OKuVJ7qG|s4FlA;gpNBwTku>R zmk@li=kcK8xp=mV&*f>rwT&1-2$0fqv+WF?{S!*pRQ=E--Zqy#Q0sCKv!&Q~W zLCYY@eJM6vBr*#d|D#;;7sk#)`(p@(z@@A^jYpZX4qDeSEloo>+D6fQBYNOjo&~Lk zVk!K=!H~`KQZ2+W2)bgg{eY@CfFfcO!QthtSLJ!hQ~ujlFZhs9wT zIT>c$aSulPq>XRb@r>?r;|rIl+#Bon|Ld) zzN_v#M>nkE{QfA((R}6^mW=`}D`g^FPOdC;t2)(bF-^iKd&;SCcHKAF9Dk#Y1XsLN zRPaPV7USu2b5Ip}#gA>cZ5$RsJ!9KR!$V2|HbVBRbVY0(A3Yl_- zO;X*_49bDEv5m&M2D$#HX1tK`CINTmi8X45Uc7}TY!rql98O}vvmHoAyRyv2K+?oe zyCYtN!^pw%gKOvfW}fAGx-YwZ9)s*+e(m&e@GCiIp{OFzt z4W*O``JEwXjmIpx>0r+ZVR>TTe7;F`|8#a^X`X`s?p9~rapF*k!wO{%q&k?Py%mQn zI(%0OyJaBK!W%7(K&Q@Tq%3b>d26A9jL^ol2x-}7+pOo}B%W1Yn4#b)zre++;;98` zse;zN^TWM~7?0Xx>>uMGLqg<{)0&MjO)JjIaBjE7+*c|2S%=M}m!53>r1NVwM#N#< zKb8O7I1Vi~dtEIvTP8P2*-Vm`SZl?1^?Q+TJz?Wq-eey(AG_1x#A#U@#7t1WzA9xy zGpzppH{bU^too(MKPIx7xuX`f)lR8@=wtpM5xsxpvW*nlE1=E`zMXga!u>I~HJjPJ z(IG?*u-0XXOXTH99pkaz{s`eJRB*o=(PJ4<(0&O^le}*Rw+C^zZbT4?jKLnqZAa9- z=G2RdH@{Wz{0jf29ov$?QJ{FS-y*acCfqqC_0y7%THf?RQT2%ac*vS6FQFJEfYH(3 z(2}Kce*Qb=8NejiK%^)GQkFn+4PSOc2e`j)Ca8$;i;MrMVj8xqYIkW_Yetq;C84W* zfCujgkj-j>8(%*kesl9SM(J zom7USIxQ7mc0C~kl}o(0vBRon5`6s_8#fopmCDXI{$J{luiG0+iM|#&l|J0#eD=>j zF1}VaiwqTq#wg=zZ{@;2y8Lcv#h0M4`*iiI;E>OG{8OJk;Ar_?bo_CH zNMrjR_CSFK)~-;!W`qY{jPXvSW-tQ;9-r$T9idEqmzRI_CRK*mza>Hky;kKOB^mgt z64v!vBBb#GHJvq5P#5s}({|s9R{_nnj*J5Ly1=2uSW+~}*l_CHetZl+-Vq&20w{h4 z*4EY#K?{5DFnR1;F?}5lLAr5>)) zKs$s-E;3xh6bhIDdCq6o1Mt*{ zgwL)=p4-4TuJ`_c(xfJGYM20@zn{i?xjcsAl$Ot~3$U1|?3`RlE0$Lnny3H$)N+r5~+0UU2efnBfm_m^jJj|>P1T`C6hGT(_!sVzU&}Y zj}F~@8!Rf7Qq&i{uT-?_c-_XGhvz%!#3R56A)+&{ukX7EB3_oErEtn_#Eu^PsOeAm zRZU}gbLm4>%Ko~VRbZe{!a7phQ-urU+1=t*{WW-_>u97)L)+NI!~LIHUql#30KGzaiq?iy{)g1;%}}Q8Epm9Hcb~dv;AI(1=&DH3TnBs( z*o+-kQg-c-72jk4y0VVzY9C<0G&cu&Y6Cda;$neQrH`u`>a+CHxqe~jUrqWst9&F z?zrI+3K^|298m7t?E;}O04AY7*x>fwf-_p#l+U`S-X2n;h5oP|*%U;XM&(Wq_J!i_ z?fSJ#L8~KXnhsTMD&FGWA7%tMIzNzjg)HcXc$~Crk+---CRmk`d%?clOq6_l%WlT@ z3&lK=RCXk+*3sj9ybf2KO>g0wq6IbKaZgsU5j}|(@2AYR2eSMcF)JMM#-}&GZjN2g zRuWgcfuO#TmIYKQuj!=*1y$Vmiv4;%vUZsMl8cO8#?9k1Y=6o!va;UgciG8n-1nNM zJVjC#Pp^Xdt;vV@oc1tXpU-r{%bgx4w-Ett6aEN+mx5P4{kDk>(l&K$7noG$8+WsQ zUm7uFxZ8)0mEg`Yu6HsE{QYVImUlJx+tMWNaLm*nXM!*yD4Hv2(i7 z(@Oxw7h17B;|arPv(}0v;mscB1?-Q!Wq`FoTSLbJ?ry_wd-GAq^(5`Z1P}l;bx-~9 zPq&1hw^YL4G_AbR!4qk6JEIziB%OK5V%`J}e_y_^VAA+cNFm@e0@<@ofK0EURRMfz zlrHm=1El?Bx54|l2 zLcDUanWs6*nwMWrrolhT-+qj^z1d(@^&=|d zQUyaa{Ar-vY5eg0C@L{NulHWS>&Hv@sqm0R9SWBj;-aaTAQhdRBLtn{CdibPr@%JD zw}i*x5=AGz>N$t}L0XgFtZj}%Mn$#zDXm$Tr^vlp^PUl%()58;R+Mrfxv$qYbs)!?J|$>n8&Jr;s# z(I~I8bZ&EFc7@FLn3RG<5@u31ui8K z=y!#eb6hK%VNV z_VfRdbdKS1v=0|=^fZla+i7guwi>6gjmEZZJ85h-Hk!uV*ycO=zu)%So!z--XKtVK zJEch`j7o?3rTD_1)c((prrk~pdm;&*W&(2!I&8w6EaD<0B{tn<8i~}@j_Sf+?LmZ( z{)BOUudyHX8vqHb^R}4LXO8`C^W!!5!|5sJ;t}DDK641yrJQL(V!>t7o!89C$w{JP zzH6gu`|1m@?f%8e9+9r;7Y3+-4ssqBAJADf+W-Rk8`WNYg2S0su!DF=5ME{`W_bM` zi<9`31qN#xWh1w?h_OlKx~Q?Ru#!7@ZGxT>4T5fSjiuD$*l*QK_X0BS2Bgw*o% z)reEl6bt&0Q0)ygDWZ+zHl-khQvW?>c`J2wT1RfU0|#R9MHJ-Z@*7cAx{PEK=8{Vp(p53F0)upuhN$V+mbdQqw!a$; z`!_3z71Yr>#5{)WY(rls6j5NArbYHFfi=L$NRKicYkD*K|I$e#_cyZBmRB~9iXu{N zOc~p%RyL@X5!k@jeXn2mU-`a9S~BsH zC90JV#;YDm1-*m`qj0!WU<<@9X^!P5{Fn&VOi-&ZDkXfXsC4H{fkRZZAPSyNx?ky-Af zy^5 zk+3t`y-kYOVbj>g^t-~`Ppv8XxrGjW?*ta_yLCSK zil6$Dtq{{+t?OuAVfFyX_AVCuS0HTN$&%0l20gSVorp)-gK@{ZeW|PhMiNS{_ zIx?Wm^_6*6obc7`^^CLu3@o^|t^T#IW3>yhfUwLFVBtAkZuL?6(=fPXy#gn)fJ$&sn6NV8 zs!w=>%k_rWdjbIy(gv>lSOmP9evcAeJ^>pV#Ua@vdJbz&z3HnHmwkFP1B%|TT6)u6 z{MDd2EMY{;Ob<7ryhA0JOEy?#R-BJ8pG7g`QF0jxsa?KH;7z8HT;L@s5oD6AI8tgn zuVy0mke7z#5k2p*iF*5RGANy7W+&jg91PAkhC-hj+^Df7vAn%Na?Dr}p=L1p6auE< zk_{Pf#eW`}9P{AhxjOf5Fxu=>;5#S`nP7{qsFzzsBw?hbOF>`xEF>qb8ek!*=?%Y% z$EH!_op2YJSsH9-$2CyTR>eq13DrJuhF&Zxa&m*pq>2{ep=g%>ax3M7-TZpFNw|Vh zQySCiO@)&tZxiK-TB$`T^VHN=3Mr{OyNM98;1#n3eM?iOIKjgfP-h!TCRG)V2^Yt6 zVbbRfK+Qi+#`BQqOLL1*H>PxS|zJvhnxs zvo()@l8ow))o+;+MF-RU1vL84P1amq7eP)Y@4J0$9-}9lWxEvzEq*5-C<3l<9K=5`JYHKi{Ho7H`jAu7Z}}q z3MMr`cpp0kudwN|6j9<$rlt{Bz|`gacq z*8m&H(;Va5oF3)d2x;u)C#Jj>pc!X5Ek!uRVq{b}0>yiJ{3B5FiIk*oS#yN_lp{Z- z=RRTk%tq>y0ffc2BJAIff&la!c)fkP(&)VfL5OtG_5P`>tpVhJ*LzZcT>)X+t$Y?V zBXD0IdxZElgE;yr0_K0eioF>ScpZ42OYK%A@I1`t1I@hPNvWx=0bvJ#M7}mIDd8R1 zaUM8+#AaqJ_^O1ShANhcE?1uoYqBMk9Eb_qxt9S&291>OQlgn#YcEJF0jWSv`j3_( zEOSCe*8;LUa(9(1v4NHx?q6E4WQ?Q=DQz4?tM9! zj;JVly6h9-`#I?H|8xh{h62$zrcr^Qk0>fDP(-G`GbcSdiY@p*oFBB*lA$9*0iBTwifs?tmsdXPwrb35ti=<)>{(?Lp^pW@v z%C;nUmt%||jm0C}91OFH6)Dnq1DKf#zF<3!rP~2b-|^2^TB7cv|FpK5mknoMP z>B2BZ2&eOgat|w7rxBVu{qKXw#SLg&dV-<&F(l&lv-i>Uxm)BBKKJ?@^&}Cr;v&Y)}Y>HpY`QYKpavNH8lCJqay~NWQs*5gmjCRND_e~7n!-TQ2=1U>W8HsN(_1)B{ z^k#q{dDyg~2Qyep!ezpdrTxnG?-JO61?)RB&ekieZ>#)2r?7Dlg_LR^;|oYb_XNjS zbjygLwaI^@=wcK(6d^PN{rzO&H%~+u@Cy$c$&I00R?6Ii`*Kq3_HP z4|#m;p>ga_8lz^fk`R!HU>b!4Z}6oIPn0Wa86g?F0-2gfFw%Wu?5c)c-y2UK?YCHZ z0hS@s#&#N!wse$<8S&4np|URy_+8o62A*= z!Rv1!gCCr6ArE68^FyBFjdEp}%ohG9u?zQzIf(2ij#pI{>|3ZobAe4hROpk}L-hkl z(^VQ~pzaeolaum-ERCnp$tXkVGl#H^jvyhRT?L`j3R!27LJG2|2_^7MLN-|ya?t!O z?{)R_7wU+r#Uqwb4M_gp{zIHp5vqPc>UxWXi>ik0f*p2YN0KF2`rB> z(=aV@wF9*YlW6zFFNS>T-Xbw(cT3%=Jq+&vTNq=;A_mk3L+amhb^G*kis$e);t7m) zk)&=B;x7)CO3*ekri#jkxFY$tP3H*X7ojuu)Ba4D2zYT#o}&0P`fnM_Bfy&10kf-s z?z!B2T|)y2FwMv%J+SM#Q`f&Oz?^ki&oO@JVz8fb*c*x0cfE<;e2fOKYj1C-tZgUk zdNt6}ich3em>M_-=CiwEjGoARbvBGnI58C!oWQ=9+nPh@y_2?{-u@KN#onhSgVS89 z5d@&Gy$@J&w>?^T6&S2_7jjYsMSr%K$xc}lw~YWEc0Ad9gDK;#ERMBM1bvT&)DY;ie zBFS2(R0}Pdr+!G}AtbH2zAJ@XgzU}yMOiwhC|(*OJ&aS~I$&9p;mXnm^%!42A$UP7 z6+mN?%9MAi#u^8WLBXUD6z%ahiAio1Jh0~xZ=3wM)xjyn_T8aIa5Wbo4(R|q; z@zQA>LWjRc&?V6KGw8HWP6l0Aw28i{&*(~nJ1J-!VNot9NV;4JQ#NiGdq?M4+laN$ z68Ve6+Z%R5gtZ3O?Juc1(D79se$#&e+5|d-QFY?^M}<(D?FCZ8n0RUd+Evtw6XAz^ zm7|VLHcId5z5@B^L*YoOMBLo!i&^+=(t>45jwl5`2^*YLi^*h_v=Lp@y~}@G zY6(Jx&UsvtQ(hn^$e6GVsg%!giA3WvCL%XZ(DG^KMC#%FOJ_Z-ynoPOKodbo}IMQ=@?w*UOuBG+PLMES2$oE z`dfGc`y!YZb?x_-h&L2HrF=Bviw!>%i@p}9r(70iglHJ4Q7yWFAHP!Z zpLFi>=CRH7AC2Bs)Zf0TNl{PMi}fOGU723k#nHr)zv^yF2tk=f&u@34`kU#%1F`KEz}U)LLN09io}+EpeI>P<=Alp(`U4=@(dA#x@e?s~qj zObux1{^MXEQie>8^Mx`>Rc3xOBU9Lj~qr^Dz;fn#d|8(WjDgEO>Fb%SE&!Z}k^gj5WBtI!etM>3& zg!@4mK@vWqHzP|i>YUh#G7P#-;V2dmH6prK%}u2t-xYMF;XM_I{7nWIA^so5t6kt;9n$qJ!E?>ojTn}i+ zN2jK(GOeH%rcZ_vSz_mZ#iY~d3=nEW!K(=cvKXOZLVu$HXGj;CSr#%k_b^ooixJ*G znMEsSc9&CC8YwPf{7LtV(W_a@coY^FdqO`OVRM1VSDv%fxy)15a)Gu+ZegdImkG+q zdbCd~StLgri%&~hhHYE9yqPLK=MJ`}qqk=(k2=d>KSe1w&COsjp85t(5CYogWsVM<{g7xDsx_Yc%@o`BD zQL_3Va4h_hru@^*SZ}H@JOgX{pdb^ZiCXT$};pdt+p}7DBzbTsQ2$UhY-I z8pNwkiQnVfiLOCzN~okB&MB<{XtEiFU|jkT<6tg=uvA=@(< zwL^}r#H;a7g30@tQl4E3D{POh=V~Gl*iMmr={J>aQ<whCa&oLwY64*V5@L7w+7PB)Sg8#2Ix%L7xY9HLrLyYqMJ8WG<~mU`ZX%QA?|E3hMfgXl@ZP%BFVntnVm@ zQ~h^Z+}xO#J6PnEqfl|SmVuKYNg}q0_7_E>hR>5CBH~Hiw+0OzE#}^!$V8G#18?kKDaBy8jvCo1O^}WL#mXjceo8A@QXU>B>gqe} z9z$V%KY3`a4d08Dm@|H#hc2bXE;6@rj$x4^a&-hTy>N-Ju;~|J4q-zPrN*D3VUy%N@S;+9al{BgV&hUNyT;4w?{86DB|Y1=`4?2r>MhACC>qG*qG*^+jSi_lL+ABG1e3!uL*n;*&GkAJDgV6GzH(i6C%>wg{i&al(f{bH_cC0I|I7MN{U@d7F0%5@k ztFwW!=0kkuzP}ZY!4u3{q&1dd#8?xLM*Rvyy#{w9VWJ5r3`>%!bk?Vx9{f#liKLwA z5Y4}tYBRr-%k8O-H7kn;l!{>w!JxPk#xDrsezO@gk3~a4@UGJ>O5JB}sAxZZe^&}CQ9K%1nO_ma$KOl$m7tdDhekOqz(>0B0BcB9^7fDK^)lM*W- zE%|SSDGP1N&72^2HkF$cHJo%NXa?oCke(@^-@WJUY(eTw3@K8(x|jB8cMeIKD-p%x z-Kmlxpt?1+SB@`keo^44)jIF<;(yxl_)3H%hCHdNMKpP-yt>x7r50{^*H0{R8?se0 zQ!kE|%ZK|<%a?(M8m4~gv8vTN4I)1v>8K&8vw`tVgr1kI=oMCJrY!NRd9FAqb1c$Fup#N%<%5S;{}&6@jb zuS@pq+OzBgk+Z60JWD5-gdU+<7I)DJol9aeRrWSPU4y6#J-^zGBik3oZw(?kh}j;X zA1gS=7zqiw6r!0e2j6y3|7m*)FPlArpAU+KVOKB>{)>9~2d5AlN=B}R8lQF*{NEy% z`HujTWbi!;GEZT@rqJc9?*;hwrvYxsC^knir%hbJ>r8-l;_9~0d&b@LE7>AA#>oC>0jzs=lY%= zgfn*Znu0dQ*;Ajb5nskH5AH?3cwrBCu|#p-A};U#R3SYsb$q=<9h~a7)jqD`>rU#` zoSMg|T8yZO2p-DXDHhQKCxm$t9RAsFt{AYG81ltBU&y{Gg9i`7=e_#^|rGn>># zyj9Z@?e;mj{Q6T|xFwEZhHm`a;4KK%5TZz&Mv~mAPo;<(7!=M}><(XNv~5>SmmH@t zI+}O`mPD#PuJBcBbfK)O(4EQwQ7N=twWbT|tV&cKZ`S-0o=XDt+(Zz`Tv$-8ydXJ; zt6uvxj$XIBWJCI8(EV79cLn>*>UNlmQ68JC*wrk{SqO(FMsck7t9k{noQlebf^p1O;QiO3?-p3@u(D_z;j)2 zXL>bwx^|K<&3hohp_S5D&`Tam>dC>WZ!(hXZOv+h+HwC5&I;nC%{NbVP|gqT>G~s; zfmbh?H!SMfeG5H;!3N&@9hD{c?&prZ<%;m{)VoO2v6rhEOw!76{+2HHqHK)bsw07ch1u(aN`Z9g7*hoqva%?mS1WiC9Suk8)4af0rVhTOr@xR8N_L8)@C$V7V9 zaFCT;H&>Sd&PiMHhw{}AW~NN$rNhXpc4Zp;&g8(Kauv*dJXZU( z$zm`Kq-e4*4SL~u3?vOeA?XaxpfGv4r=08KDEEV8n&t6xw1ZUpIuE+ASh^dTIY4l# zJBh{nbE=m@_UJQ$_Y`u^s?knrM^8yntWwN4GTF{j!Ps<6|GLooKycBQ>BbVA3PXKs zV@BsK1~M;``P0i=g1!Qa&=>X}`My}9BT;emw|pHb2X^VldkoG?9#-kcSLqm{hCI$pbG*V~bZt*Dq>p+s zZ*+0TM&gcwQ@be9MzLXKq`zBn>maOF1}=44LpdMVGm})-$EPboT#AB_YFnphzIs~` zG&mI(!8AhX?ki4OWNF3N{i8+s0iORoY#%RP{;GcOM?8wQ?7`bt;|@p5y9JWfykfkl zH1VOJb_&>!{W5novbL^Z(PUv3ZQ*ICy?x1I*>cP>b>)57pYT*@v6E5YH^mE9)IJ;m zIj2V7IxZ!rb)Fp+Q=99z$e$~Q} z$rOsqmAH$9q4wTz|Az)`4=qzFaBb#r)z$#C+Lc~!EE{XVa&CoN;d^j$&;|Z~&nTg{ zKNKmU5lV%#C*4o0l;LvmSL|5sxOn*zCJmS8TOL@IO(0(Hrmb3X7ls0)Tjp5pLzt-8 z4pIWuPn;H;)dR}Y$rLaieI8o$Q8>;2Yq&^o`0b|16**HBCj~$hD@BjE#R^EdA*?HK zfZ)^d*#?YWb1sECHzI|q$YO@^Iv2fW-ZQ`fCvlOAZVz3qBF6R)EZFt(EZ8`D>>az zoYGUISR4UOEpiWIOw_^Ug~l?Hzejb_ty|WVF+#GZ+fPQ%N2l|6baK(U|YArKA4G#va=DCZ#3Pc!F~Xx4XUE6p>q7%W{0NI$z;Bq{*!J^ z8)?-dkTiofJkzTTZLL=x?8>A<8scG<`#d_AuH+{t)#=X>ZSAhZ1(#HcdpOPe_)&mNa2Dg8u4`g# z$C+EX2b}4X?^NSv{REh<>)UwD=EpMbuKE;=kRe?RlW<&2RpnlV=YjCKg1PQ71^qbn zsoBpnjv(K*8-po}75WLMqx!#&(_;!o`lfGN7bE$oxyJ3=a!6D7j;f-BF+6T59SCN)r9B&EqxEsR;o+m#O~VgJ&i398=1Y(V79B*(Qmmyk&W zu%c&722Nr9r7bI+7j#yKpkt}Dq1Vub`O|>BElOm5tVm}t8mA-L8N7+COXX6Zir?lw zVacT$@dKCDyWSl1RXiqn|1KwsYS>SXLAvW}Uom(?;esYEQXc0rr=y_(y>!NZ`ALxI z88fG()XS!NSh;SF*s;Xd#YO*_Op#kYsjIOZibe<(AQ(sr;~728@Kci_FCrv6oGj>XMq{DtijogE=hv;p z>-9N?@lcp*n%8A2p8V)&&2(kst0Pr>?$YG+e&Zk06G!orLv5ikCysq;YmRStSUSf@ z_4+uohO=;>szK(K!8NUCNC*z?qa3jP25v_wwY{x~)b90mRt1=0D8RZSJ-n}rGyH{I zZRSGdG`ye9@PuuYU7zZAxPq<=RF6t*o8{6hd{pUMj4AVrqoa2ipEf&#gjr)Ia=n4b zkDgt}?YH%UmixYb@3Gong*8@7$80!PVZ5?g{Ey}$`2!oaTS#fmxyJgoFMOh4InKX^ zd_suA*Qd>;T}zcYE>^=)oJ3C5>CDemzWJF^;dgba<{{TcAj0gM&f{3(%_bw!js}&h zw7)~y!Sfy@6gr}7!)oh~#e@yu=E<=*M+}C^NJsENXh#3YQH?@AN?7DS*n!_I8{{p) z`065ufFT@y*new+W=PWXq}U|OzaNtF z@@GnfC3*U=9SE^*M{?vPjK47%;y~`6YmjDM6%Gh0@@= zW;;Pxq;6(!OE;jR=#4V+RP4+{)K8j~RhxP0zX2{~$w!t0idSj9rS6vhSVHJMlUY7bS zKe27o*-0RDd(BmMAc2}@roeh=WMzTr2H_`jq1l;&L_XsiPa(uk2E% zb1C{)3{3f+Jd8eKks*3zM|*9}o-xopkp@;2eU9gm zQj~unxb!tfH>$Mj5FYsPsibX0B}oELq=*JaK`I)wn2YEp%3yrMKC=$x2sA62dnS>A5hm6<;>U?BNnCS7V;3jE6zS1)Gl7S}Mzjn3uXEe=@;di%k@bZL z@xwXQwE3Rk6$E4%@FH?@q0D`hXT}@Df%^TMZE=gOoL|1067yKG78w0vx$!>pV)PLy zl9gW_!olt$rdl#yslyIIud@s(N|_^;A+D}Z6J&6zdlFYJ7vnhK zR)D!b?G&5~(un1h`9|iju)@2BVT?cVm#_Dy(16ITnLQT>NlsL17<;P#+kOa1Nlv{! zIGKcNbCQ(Zf~cZ;rOL;5U2|LQY{f3IhvCIWJb-iP83IzI*1ULeDJsdDj4%OLn1w{0 zeoCjJDEtj&62pdu;28<8pgV*cr655?!WK#+9XVZ34LTcTgLa4SSUXE|(LYhtmZTv2 zu4x*43UGlnwalqN75yiLzkmRvzodpSN)t}Q>rdq$0#4+DAm<8Jz{W(IjY$D!iC%Du zzu%SXP;POARNO7Q#CWg;`fomu-?z=$*ApN*7#;deQ)l~sWh9~7`6CL7dEP3DQAZus zI+j|Uux`I9Eu48UCO=y)(8dt=s6ezOn8KyH61&dw?bv=0*(;qaeVBl_i7|zMY`KZA zxe42xTU-<{E0&iniK*CwbzLez^rDb7RAgrM? z>variMxYLP7cNV3m==ar+G2*oM&J?aftihqeMsR-@kl!kD+6CsF-kua_!D}ilaCRs z76BhtSxozArKn!Xz3KIh5KTJ^8 z!D?%34P>*lSt(Jm~=bRb3;*Cb^P;p>Yordz6}#_{#6I#u=G;k zIU&s%D`>*QR;H&m8A6r;uzNJ{OMl@iHNGd{R2H8DHnEMw`)Y5#sXJNK(WMNIi)AbE z4va+;WbCi#0XxX_Pfq4~g8{5JC3yU}>vi>~*de`?fuUNe`35%P;wVibj$*DQTmZFU zs)`yykU~2glE_^`?F%hufC*`4-)_Rz@BZ?p+%rcgF66EPw7i^?_|?480~4Bh zk;8Id7A-h|cydG4_BX_iSNY+|96V~Z;n+9`YAX1qVsgiCn=i7KrH@m{JWz7ZeREjy zbi)_FsfV`}hsktpC8v(Bv#lIdZkiWe_G-kPpp@f%B8`4&*z`^QLW3Dm4C^M{r{;%d zxq!}=2$*=HD`MHoDx7I6>CXu(Bt>RrHZ}`U8`~3BEKaWTe8-E`b3wn*)7llaES|y$ z^e7qD7oPLMowt!4pPF^^JbBQ%^$m^rt6~V(wXhZ{?IZ9Jd7!Tizb0gIRDz| zUz`Xmnh7&le(?>x3l6E}k_#FO4$46^WmWU$c{sI9skVia9(51RNjL)8+)XpCo%iM( z%S8MdcJNqGzDjJxOnx5U4@JT1TJNQ$uwdD@Ng-dpkUR;vzcIm_p(fC$a4|7?8%f-n z&%zVECjlwmoUKV^*X%zgf4PVF=3+|m6n3!*=lyyNdk*kAJW`qeGJ2{_JLHHk$wpuY z>Qyo&4#$#Ig+%^S%;yYd{ulNFwVT(VVJ1SXqy2|m0vWYX)ryiHIfO^W{xC~0eoT;g z$CaWwo9P8btMx_-g?T*FWUG8!H+Vq05^JSHeCTj~a$Yu*wDh(xg0E<0OuWj!vG3>} zMiWlTP$ujwH9=+M6$ygT-R#5*+S(jERxyR$6oV;0QCoM%DxU_D)nc~NFhpN5jp!ia z%8zScItWFAdvLptj#Q24WAYnYGGBYvG}R}YNImmboz@Mhl5BJhpJl128Q3A-Nup%} zzWi5+Sgi{%-$ZjR{?o?|F%B`zG#$AUGlf5`>+O6{$XIQFYV?@`w}=nMkqlqGiTqk7 zaWM;F1%i`en1N)I;rq*FKIgBJ@z3#36U5J;dN+4Qq^f0Ss{BI=P5!A$QqjI>AeHqP z;wLK|2wD{s&Wm9#X^~#KQQTR-O|6CQvC!D zKL=U&c>X*2^Y>RiB714Q@aEu*z3OHHmth;C^gOVmpu-?El4C=R>WIFrP>kp4eJxFQ z{)uSj@EAI_Q4M!&VmYy6xYU5g7wW!5C$V#0c^op_FKj$^a_I+g2)kLePDOI)3Am(o z6};tj`%)@eszgBnrqIFmBOA#}#pa=et#PTb1}4QqaUw@6Ch*=$#*>GJKtUli4u+s z=`R3n_$_@9=npijwI%ZYVW)zpQk{HWW;FDh|+Vm8tSU0VK0AuBLJP>q4b#sTLv zr5V%S|4TzWgj}bj#b}{O(hn03d#q{0o1o1OUDec&5#D)#W!{=sm+*t)zXok7dwvsj z`w=?k$?rAE>C=zwicb?_!4NDGc(m1KORRO}>BG1@>%l}-UJ}bRD)rn4vJ(3dGp-S^ zC22~C)XX)s+NG?sv)pBg32*kZ!(NK2>LURFF zJ35QgGT_02)0PEP8KkX2CXslzcL(U-o-xxkAD3UO{v^fRvaAqjMOXHy2c}}TMQjaX zBDj)Q5HV=VtJ5XNi#nj3wptg0d}vs!ejUi3St^Q^#b2+{R~x?G#)#e`jHb8O;c07r zQ5}3PdvJ0b*1K4QEB6Rk?=YGWV$hrL9MBxxNmRDKS86xXUePVU67zPQDCCj+rp9nX z+qsi))g;3XV|>wx-1j?pxT!*hR=H2CQl^ClDYX4tML_DDX&n}i{7_mqiiHbSDk%;l zifJ|y%G{LT(x@*){I8Jz~V z)g7tVqfW(ayA=V-(Cee_DL$s19e4)Tu8%+=dm!ic;!i;D~cK{c{VY~tXLu~)? z_VDS@10a2%>0A&%vjo_;c6l?XcLKLFBA={10I4~EC?G2x4j?5%8qKF00C9D)hW@BC z_x^S$;lV<=iYlPaMCAYbu$;Yl2slQEA~Ct#&hday?;il1eADN_VUlfmh7}%>|B(m) z@Z#}%r%4ca09J(0*zbvSmd}{*ii!i+yo=?R$K6;`5fRy#N zYn#x03#_Jf2k^)2d_sPKEid3m#f*~K_8MZ^QCJ}}XKm7}Iy~;%6VJ2c+N~IcuAtG~ zrHq6`co0E){&H3SI=Sj|KNBWDGx;VrIQlCp%W>+Wu6wHpA`CN3mItu)s^hTj{8cSa zzkJ30H>rWvfX8-KbE}AzwZ3c^rsGIf2ee1iL4Q?Xk|tN%a2qTOmOm@Px^&R4F#}+I zAx8ui_K)GBqY4=*9D=uqMRVy`<h&KMhZ<%^e`Yf_M^)WF zjzy~qLSG(hqC+OEc9s3aK^$noc0FFba;{qm_KJ0q z(Y>>9q;$%vNBO5eA9uPHu^h^6H80G3xy`F$&t8KG5CqqVGlXtD45eWb5$nZir488iw;G6UIYf$rHv9l zybJPuPQeXHRrJ(Y-WW5ZYNQ4+uGbZNIA?rs@w!pgGQRVqYVi;|$Lf@0D9E+aZ@f&>OESTkrF+H2ene+~6`k?W|TEAU=Ha+!Z-3{2$; zOsBJ$1EC%JJx(z|WiXEA6NmN}2}QU9G~F}J^q*Kv0BX*AHHi9Y$S$M#tQCM>(3}-# z()8*1x&8LKr^X6U>5VKcVE}qSAgKY6U;hC_w3>h$6?p7Pf_gu(9{|4wa6*2PW&u|t za8`k3DG|Q=K~#k}S};7V=tUCX9hsZw$(cxFHi$W?5Z422Vr$}aT?RBFzCe5DA-tCQN4m;lAaZ8 z_Tcs!P|6fDV`ER4q2>>Ff4O;Hsd7Y#p0}qP(8}MxTXJ@usDLW$70|OM_|9|qB(8bi zOnQGj_z3_B5k)pSK$eHZvPp_VQSxCu8%aYjgMWdUSv|Gk{n4@;{sLN`C&Hve~Cz-;Lx@Zj`Zz4=`2H~`OadG6Mei3I+M zlAj=anUSV_aqWc0tUaxB`b3YN0u_}!qth9deT z^gNm_UtYFF0&8$rV?FlxJ=fbj?n5^U_CI_kKAvV{Z~Xj@s=bf>L$65IJm($W-~RCb z$<%kikL7jE>B!}r#_c{e+jy#+#(j=={+sT`W8n z!>bNSu8}UCloDl7>po=>&9WYZi%@md1`BF6d;1crzh=}_lU@=d4;iZ*X}%;@qsh|? zV#wJa4wPtf+htu=Hzok^Aq??dNNmim1Q#UpokM+!PVMV^?tBOJM^H#RQ2g zCfozilHw|4kAqKmT*Uq-2nJG^9DgI?rJ3hhO^a=zkIOo918*Z zL>Foh$=ZX5*>eu?CT%y@tzzK>28(+o+CE*|PvR+-H3Fe=ML`LlGRM*i=Lqbord!?E zke`XPaQoHv^3`IUr64?-7+Wrxd9%j`iSZaBaqMQ1G|ij^T$$PIDCIe{w%>|p%nRB9 z+|yyd?NP=sIUvmWZ$aSxvKenM` zSK47GfIG#TSoyI{sCiewV)6Um>n-l)k6l=U!RAWe-kAzDIv!8J{^`x+cYnMYw?e<# zb{4bx7+d=e-~HZX$IQbclVWQ9@3$1tU5KVDEM`IW(vAOZw!xhSgxo(_alG#va~B(@ z`E>{$TK`&3<-6sB(8x~)b;!4|-RUv8Y@mXYT^B4cPj zB*~bh0Ok7eZvx2tr)@;P5nBB#7~rr^W@z^(|9f^;>GPD~DhhnbfB4QFUe7k)tIt;& zCB__3vV87WPX9GU0?n=8Hej_|#v&vHeX3DGOhmOrfvT6grw>ud7br%kc?<>R4_)HE< zVRYn${#Q`!fi;M>%fL`D^zG$g{>b?N=~}$7Z*UND-8L0?M?>@7FfB+P~1qC3J2t_?~Hed|~49YSGKjUTgZpshYm~Q&trT5zuVfN$`V^<8}`D?tc>s`+^590^z`gJ|xh*r-vIKU}a z@_Tff-r*2n_cT)R? zJk9$M><#Mo1Kl>}4zX#x{URSvVd}CY%q|egX|jlgZ}fX$Ab1Hf*olx~0Hsg+Flgwz zBggWcjjp*4!2dOa9V3cHj8*ua#F2g~TW@>jY1_4pv6O4i%kg@stxnJs($&!%A8u%} z!t83jGDfZ7iQ=|dpI%I>q2vtPY_r;*FpJ!Lkx`J0D^5Z2*Adi}<#7Ma`=nA}e}$1Z zc$}dlQ0M)D@-E4|N7~HwOm$MbZzCIeov zN|Zd@{KOwDY&-ELub8xi>{T~oxYHFjv5ssOmKDRv=fz*8Y}|MV8rVj=EjFt{IiS;x zrri1~V$WdXNu|om?xID@?XExQIR%FeULgJ|bhDBYENns{+2Lzb8R2yYT;5fl7xP2T z>Af00lL7b=3Z5$MF38!atNp^Hb#P-y(2wbwb(50O8a^IuoS!U<**kxmP8`HWM-IOi z<)0jjWt`o*ZV=7C~i-uGW}er;WR(#b~9+e9e<(sq$1C43bAb91zU(Jf+i-V#K~SI z5W?r>TJL?wY-Uz+Ngy8br`2Zuj83OHNYCpbpMbr}U;weUEgtjjm`nG5xAO5(qGrLo z=a6$npxPa^fT#{TUCsOG@qF3!SSQ2)~NaCN#FA!Q@fP< zdSUfAXVd{M7-s3hr%i&n%4)J69WB&lNQ_$~vo^W)X z=lr14z^`22YcjgQ=f(BQ%I}|v1b(kqHx=cHT8-;5BhYCd69t}7-vDD3yvJ3fLDhxNg;yJp z_j0+x!ovgGyJDVOtYOWxR{YThz8zAc@idsnyyDS+{+^)S8p1)*(<7vi;IIRN8jI~)2e@G zXBTfd{6b8d<~j72BA7G6=$mp3%u-o0v2+j3zo6wUHd55SRI9=NW9b^B<7(UP)7Z8( zvDG+@&BnHEHjQoDwi`BSY};tkm=iYXck-_9Pnwm^nwfLXjqBcfUwg}y?Wgbh2bUeD ztYNCyinw+&L#EH&R71nJX2|(QMTiZ>k>SZ&-aJ(9=OTkJ{Rt^i#*}c1`z3Wq-n`@! zjVF1wp*`y^N3Bh2R>HKbmSW6iznVe(rd4a=~~R|^Z)^?k7F7|{%OIcXk2p{z@&G;Oh=U{X{dX(Qo8*C zg`=|LX(O#jy-US*CE|36gpRL9!o(;0*}T&@Sl%+rqx}z~O)=V-cPpr&=f+65SW^)B z*iRaPEZ95ODF$>3>GPuPYlU-m3t-u1`^MotouJQxAw&{%;e8ztJ12X(yj9T5nzxep z^fj%fW$e)QzCQC38zA#!qb`S_86qLn)UlkYgkR*8c@KQ41}CEg(Duh;Eix-udsS60k{L?L)PIX~_yd(#rmPAw@%fQ(0c#k%>u5tP@6UP#;LOYS&1OG0n`S&y~4 z!n9(`xI%?Bhu?=uih8yIBp1eaD2eart-)yB*qu6;=6@&@{|qTh)ohf~I^k>98~IhM zt0njmB-Y#Sf+XqsjgAF$*Dv3=9abYbfZ?)M&G2kaCobL+YB>qNY?(t%Zv#bh&6$(3 z?^Y7h27RWMU(-yH!WU$$hFxPCOkuB5GxPM@fNr=tj&CFZ^{8TL%U>&B-8g@Gh;^`n zp49DBc4sNlr?Z@KLY5)N$%(=y7+MYEl4P(N{D`~{?tJTV(5&B>I0G-AJuW+YY-wy; zaUOZC((onLfrIoBpP?5AGCyLXZh=4_&16z72wNrwjhg>8a zcYf+jzk&@R*(D`)CNH}MP4gn%d1zJ zBfFdz(WMdfwBJO(@-OA4k-$m5?!&*oH^AE$s3xrK4lgk9aq3R(W->=H5b)rtx%FcC zFi&fuvf2-7=X~bLp9A9@SPZ#~#n~OZc9&407jU8z&THJ=7^=%t4M({m_W@>k z|9!1WfkDRZMAEUS;KG`oUvL`ju@3uTHl~6t%V-tWY<0`mBKS9L4(TJ zToz3;Fjs6%h)0?OMiM1QvDK}JCD+BUC8X)92y>1;OsN}`g`~Zyt8f|8Wh0l%){L2g zSKJ{N(|D3QmSoLHKxYbpz^NepX_X0NHJLyuyJlRA)P0YWKc zjT6Aq8L)=E5))|k>>nTz?fsU{+l{)Xy3c4sT&0}FJxxP(9E2>u5Zs)uvKU&zZKE%p zNDplVndl(S11(9UfzTJW&s!=XkL$>RkQ*CmL`E5cQ{|O2Y~w~y^}W)=8H2vc`(SKv zA(?$y0(DGwFx9L#d}5%NVIsqMNQ+irI_%u6ESKDT5;2};B(VcVTEtifZgvR*T_4i6IOr zj%`+jVyNJrSRAY#30yk19bJmHmr$vPIxYoo!xkFyoYUbp)Nr&i+UOBkSzm$MA2P2H zl+sU2imNMi;&jS{zy7&gSzC?r@4|zWa>IOQA%^x^OZH|})d&RR*O_~9n-qOgce$=Q z3)93aBhCsXBA3_)`>KPy5a=4F136D*Xv}D1*03aAfAH`1hU_Ghs{I-{=@lu;bh7TO zcNhn6Cx=X`4frK^(H#HrA%zKTd}($U$anI1@t-BFagncPql=tQG3TXO(+Uc?`^q1= zx{^u!UAmLavgEFYI2E;u9?qGaCM)}*&Wdqs?VCB5(AGlss4$2{ z&MfVN=WG!)iD(DoJFZfLY9?wusaPvju7zHdc8C&kyGpv@gu!qm%UgRiakx z>~764RwEmIc^Ku{ySnF&|7RcyxfDVGIIX9&+OEvov>xq_hG=4vjs+rvh&tD+3?@W==N44rTH zWbf@6Gym9y;61GW=!dQd_-t5~F9K6MZ66td;xH(Zs_bt%X)fvVaIq2Y3T?Ei>0rJTl^PMxl^6@Z zR#y|5w@O|Q#F!^yo_hQG^nQWAsWqs*FYUIPCJVtJ_fC*EMMr0$#@LnrGzN7Y2&rir zj8&rD%`%W}FiT$%W>+85z(9DA$zjSsM_s~93`m9g`j`?<+_!Cnsl zGivm^xYdZF8@s}G??K#$|Iu)yl+LGEBxISIf4;^;_IJu1m0k{q z-(HwkI!~bgWlYuGp6&t*bv3UH!97wo$TA$>tA)C(lA`}GGilO9WQ%OBq71)j)?0JE8hFOCMao$#*&W5TajVq`S-hy5BN3(J7==FMVMJqZXo>aLA z)0ICwckwabZtBY4{nD(GZE^YACzjkTr#6>HHe^?$Bo25aJ0418 zSwpgp(&imD4h{(mmo~*qa}zuG3lB^xDcYhazQdcxtaW%1a&k(;NH%4ZF(J!5{kL#|k})5sC02&vTojDE$swvK8Y{TY<}Prq{Uj!UqJ zeB?IZ5fEUgFTM1^;?@ZDJ;S~2zMb$@JXAjNzU5N$Of-4(_PgcIysNM{go(?6-_~!U~1`7 z5{Vc|olIH1;U9)qtT;?Vk>V!N1gt2>sq(vkhvdBQbNyACrSP%l{ayZB2|$X)`b@Ho zL@d2qcvPNiR*{t&+aox0j+(+x6PRLwBnUANLXt`fxY?cAS{N!yf-A|X9wR0-kbJ8! z7KY4F=tUH+oJEQQQBSoC>6a?a08A%(c^1zx;MkV;xn!h*PieVf(`_L3R#@mk*r_L> zVx;^%vqrScn5A@HfyCj z?B6x%d$5JJqzab6^O%yw^X>0j0P1bx+X?=q_>YK)c2u3e-9m$S@fKx1NJNJ%BKUaV zCK$rBC)elotw-Qz{qnBYDaMPRsX!l+?yD|U#b)B+9?47(jH|hHTq(BV=U`+e&dgvv zrCc#!$=3rw@RuOm?(fNpd@?_QK+v&j12Fybxur0}!NI}D7dX1?x)S=LWAH~1JVcVS zN*UTOw6gOvLDr&|muJ=Uj!g*B*#lmc@j&j|8^G{Zv#52OC?w+aNkb?hNZ@IuIZD8S`x_J<()Qdj*uUe6z}Ae1k@FU-`t3a@l9N8!WtVR8}cLc z_%W)b)_BD=)EmL(*O73l`U`K^jdQ*Cxk4-Th_&`_#4@G{cHlS5uMQBOLNVin;}#VC zo8n@S8ckO`fL&_j@E+?)IjL>u@$je0irZ4c_p$*0;m&ysqS+OpBsi|+sZT$VMMsf| zZ!dT&cPwpII^Z0I#B~kRpNCbz3>c3M0k0Tp4uq3#7Q7w@Fy0T%)60#GY}DnCO)kGr z5xhbBnSKbO|B}sqOQ%l!>Jk;fPW|Gypv4f__k)1>#uYEA<1KgNir1Cwt!D|AbVl?3 zcjQk?YRj_+dx4k1WUt>7hJ_3oOqMg-Z#mvC_%HK!20?dDGlGxeVO-|>3@?_mXE~3u zR5hyAX}B5d%A7Q{QhdQ>Gr7|X$9$A4hL5M`YLhNJU6Ic+zHRh{%9KGH?T(f}QT<_B zwZNp?cKwlNe%sdGqj1^tXpnbbLUS@ZJ1gMY%{p0LD7bnIg=p=07UCha8NlPO19vKV zT=3ALh!|%^^v2B?RM~1?6~G1yQWV9vF*UDSKkP-S*tlr=_U6;^df5fQZ$gwG!OuUQ zkFvo@a?Hk$ms|4k^0NT9B7JmM*-8qR5x?QnP}rL}FMunN@av-}e>h(oAbsB>9S49r zlQp0ep~-QA0KhfgzLp#5?;?uaB5CGse8d)jW5@vEcHikzb!Jx9FFe=pRh83Tf~%~g zmH|IzfDifaI^f^cztjaq{@AaIyrBSmD9D*T1?eYAGXC~t^s8XRS=39$rwhm!{onh9 zArn?{e46BCK_uUS#W*tkg$jElwhWlaPz($V`2A)uOdJt@zwrZH;gf-K?R34>7WMj6 zQD^4$e(yme~R^ng(Hmp@X%I()e(JqGpb ze-UM#P(R~|4+g(YHN^lizPTUCu>z+m@$>nA4{2)jI+B3GudKZMc#}VW;1F`_3Q9^= z;8ZW5c&>m^Zn;)>1b`(~oTqO-EYM_o*;e0KAWB@*&vPTS%(aIBNRj8PrrGQ70cm|U zx4jAYbO~U9oB+z}1IRpm7D3(*W($8D@x>5(0XVyiY==&n58_EY#Gqc2S7ND-}s0&H!|ryOmW$eZbpUOk5m5cam^I zJv4tw>O5T$aUJ_eY_=}X9cD0mmhHJ{+c*UH&!&p#nNNuc2f1hp zl;5q*T$f1QDc|?nH}XsTUmrf6vBbY!)c5ZNt5&&Hc(wOvgi$2|8wKks z0=uJ4qXQ6~rU%2Zz#VFG+!0dy-y8#>v8q>tu@G`Z)kkRi*~ha0!0|C)o>+e%W@zx*G@cjh zT;)dd{TX$zO*_J8zTzPO84P>}U`{15>UX9T(Prf29R4_nJGfs^ItE4nU!O6z;Hz}t z^UM1X%Quz$e}9kfJ|H_5fWh|u_qtPG**jFQa0h%@Odb*$G$PBdR*7gtTAu_!<_(3_ zHcXtwGsMfnqzGza=X>vOufTly_rCKl8Mr5BK=!x7^ZH6}86me_MN)0aE2(@&JijmN zE35P$j89nb&{k~@TjK!T@zVuhTb%%IbPOOjih(kCfkE_9IzbI4(kE!JI~Sn4bDW3q z*X_8U0mo+l_l1BLB|agc=?`no9)R98TdC6zzhHC?c;xT*Pn^Z<|2WD0xc-Is!B@hdZ)lF^w)clk>K!0jiu&L@R8g0zgddIW9a+0C5>a8` z(BaTBalmGGmG5zfXdrBzB-QtiF&Rtv`SioWe*7a6{1jLp0Jes|YO&!Y6ZWet$srjj zLKp@5UFT^JErKHd1?R26F%XPiwsPhSIAh<*{k)f}(P|6=xO-C8uy76}cDZ1T$D^ri zd_uwlK-0Ep(JYg9Tw~Ca1wc=Ui_Rw<;Z^@0e{V&y>2`TA0c1=HpfE@}?l|9Qk9TP2 zm+Il+5f;7zN=Vs%VPRpbo$d_FI@}JM72bKt=J+48W2@6$UQw~iRq>R(rLsqAygL#O z5FwZY3uF%Pm;sGOWMU%v^KJUi$&cwlO-+5|{$DNsnBZn%Zy7+~04wNilxBC(hpGJw zup25H+$N=4 z$lU@WHQoI8`|^nbse7@dW6Dq}?;P)=%p5@4_5yfh$_N1^E4Mxiu!4C{*%}lpdHuqqnz;*4 zX?Ux#WMaFb$@gQg{|2rGf2zelQ;5wJr8(g;6j+uD#3l&ggInrxqI;^3+p zO6ve)E-m4-Y{;zWn`Soh$cUt*Z)TasRux0-KmGAXNCN~My(e_%DCrud773m^@ZT;8@008A zg-5PRa%hCw-{pnNc!oy98&&niRP(N%jdQ^^vem; ze2)(xM1GyhKsxS}pP6Rm7aRb>R;q!^&#~L;jn&_11N7d#KYogFxA(Wf?it0UxZa{< zbew6nGQ91a`ED-+7!q3|v#)|5!^Gu{^M0*y>Aek*UZ<1`gj(kM$8EbzFx43Nr);~< zSO81IJCoUea(4tX7EY=pM*F5owN65Q8ua*~-g!O%TlGfI=^IjG?I7>!xfP$?9SUf?j=iIxQ>+iJ>(TlD&j7Y&{ zjbfP~+<8${_MC8b&&kKP#ND!Is~M4<%xQd*#C4r63}Z+6FIZ0Q%wcvOZ5%XU0~=Rz z=Eisbrc4}({>BlK3C*7`>K~&{_Ee7#3MdJPe9s&I0IU*p87Xr@eg4J;!d;qqX`qEcEhg3Iu%Ss%h*~1)#yD3=1V7@f8q8H-n(f6=A9CJ z0s_-N-#3IOj)h*KYf|yn(eF5XG2uI+2~b1^$>(Q~g*DCfO*vacB(#=PXLzW%$?6iu zH}>r+w3*A$5xTst*M*;i$OVzL2Sgt@gKuJ$zfe$js~85YCYhJ2Hk(Jzm@-~3$w zGq4uLO6OM}IQIC{7uze*Qs~W^kBzPWvF<6)@3uMLVx%oM zh7o_{8G=n)9@hAn71Eey2_sD0Oz?P@#)Kd-KY4J}yBmRR?MlG3{^D%??hT5Gtx`#x z?~$gedgW-2}c5|ZKSYv7!k`1W<7`;ldv zCiLuPK0S}d)1i}r>}mZ4#h}{z?l;8o>V2jhxdrjz_lmrGZ$+Jxbpk}!DRcM1fG^N2 zJNa@~f`oC}Gd+)+Q@%U@NS-fNdh$%;cqIewi>nXi3|}x-3|>vS?Ka_L#%~M11tI*< zA$`SeFZ#_%osOXWOQ4Fv75_eCsZJ5ECmKnW_AJ^$Jt-pvx=!|UjW<{;)m_cWKgpU65hn6DDz0u79B)&2c;62N&y`^{ z`UoKR8wnzCPFZze#`t>|2$e2hz3X4;WJF5CF{)aiMX~ROs;Ln~+W$bqd@4M)_;Y?b z)YLS)3_~YqnebpMJ{-PJtHWc*fu<2T$bHBa54U&^!VBJoixCf9H6hTBur?l2vu2iP zmUWSF&0=pxW^ZtP$P3|Z)v#_Q00i8BIe-oFU$gVjWM&O>+)nStC$K-es~?+}SngDI^`@MlCr;QAD4Ijs7U)Wk@qCjo-K~vcr0Z~sh$CytvNuw^{pa@XVz{gy8}17clRTB#o4lt#omR`g z8ma2_|E#w?E;py}Eo;mkw*|Yw7gt6;E>RZS^T;E_47STPzn3>YhvvK(j1hR(wBh1` z%1JDLdEZmBO6wm`h?zIeN4+YkQ>FLyrEtl#W3x*^igKh3FNrR~$yV)R-!wXZax^c% zQfEu+i%2cFuu&NNAyfw=Z-MSYh1;n=(srS1BGKJ6Pu8|!4@`CZ{E3FLctSD3DE7aY zwexW0ZZq&Wx5XJsrm8=$lxL8S%t-(GlXteGA@vq3cEb`+A1|OGA>{e@+d16FCz5wx zLXuIAM3LWZo8BQYp~Rpong2c|nk{h4hB@geUjouF1pNBU7L%*~bFErlgiXIZ!z>SW z6#`qLrlEQzRIpHGu8gqrRMOQR=wW;GP#J1WMYA=!mBQ@P70K*GjG`&;avkPr9Dkg< z1my|JArH8+LmPojK1?djFHVc*Oi*5FcY20jARAHLF(CNRb`V&;w)@g{=*lG>wJf9|XH41nC~nPy#)*wdKT_&ex*l645G+|_tq|u48ipwAx2le8#5J{HvxDt@?~|ph7#rfc7}V zLLI87jYiCR8KdzckK^@Q9y{uE=QVYZ^~}qrCiJl_?4WrfZbcJ{CeB zRjaF3IawxE+4z3Tpat924|M4&ugqvnRTMb%NK1AbCcRN&VH&0evo!A9)Su8jzJe2c>zc9h>*L`)pg0_SjWTgZ5k z$<%(|tw)|ZeL)o0L&KpkaN^5dWZyHOL(ih;1hG9(V9;NA_nQT!Nw5m?L{2)+%j00z z-3Zyhx4;cLcM69Ybzwr}Yh`iM-ahNcd2;!alKItbEyvOR)8MR%XqBBYo zhIAq!HxcuDY9eA2WauXQHE4|&3<|BD04IlVvX$zT(xJ+9;#yY)@GDAq#>y43n(-oy zm2xXP9yc3l@VxL}Z3(2M5gTnTcxJqQQ3V%uXgbrzlnMm&V9owioK$MrcKYc(&Q)o# zMnbf)KVXZ|CP{OlxZmie-a`dt3cS-jD9qo&*hJVLx6vk|_WWmwsMNK|=#kYByW?-7 zGm*J%$BIJ?$SSjRC%3i-7Mv;vi)o@Mz7{t6m2-+sS?80NNznJud5t3JqN_vfZuNXoM|rrf({3vcQC-noP?f7JK6QN`V631Zz(+M@9&YKURlzXem>s1Iu|g9z zqlqL_Ai})*XK}CCEJB*G%C+DaT)^=aG-ktCdpdsFjBqcX^3WsdK-WWw$vQR}Snfum zQd*u+z26+OxzX{1y2@58Q!D=IO9GN0JG)-xYK2C77UeWW@A8h89gWPQPRi_bKbVWW zGRAQW38MwSk_E+-K!pJ#LVupR5(3q~V2c)uLZO7AT z@aG62DHz^Y(;qbz4}y_*_JV^?Ige&IhJk91|DV(Y|yS`1> zi=85zok`#6y@}ApX+fHMLc)yHDlAvW9sD;LRs|JYa4#~(-peESPL+jU2ye-$Ky6we zKbcqJ3v?k6OVfW(QHHx(50Xopb!e{+4-X527%`xntQrS^Re&b|Fdhw-bB`_nI{ieK z1c_55#0KU*{>oAEcRM&{z;8A^>h&sHDc2z;lcNwUw2TfWqk2t{CPARV11&zpVbn_! z680z$PN0UWhHJ&hlp=MtgbPZ_#aN5S2}v`k{eF+K!bSaeYtbMGTC#F5J&_|tq25-n za)QI28oY-{#85|%4cRi~2!9M?L^LOeCC3Sb)BCwZ9ePDp_UW{NqGBvjt4dW~Y*s#- zz9RL@^{6ujiVO4iL$4h~sD4_IPUR3X4DL$bGg3Bu3Jk+1ktjFTQ|B_7vYs!agCRcW zNl9qhHB1LwtSntTczIJ=;=8LMamP7e7Wr<=nnug-Q;R%e<%w zYeHuFq6+PPZ=)UN6n*HYef#WJ(;&@S?M2|+98%>~bkWBnN8d;Yb2u(9%2h{HkGu${ zNEt{rSsRbkj`;;qcI>*e;LtcWv0+B-r8JXr&z%`@{WE)xYh#^%*k|71AAPIQh)A6H z6vnl`RLu$>eEWI>uVtc*R+M@_nU~_r*tVlhmh;x-&plJ^iUbMd9~wCFw;E1hletrO zeGAY((+FtGvn^1(I<WzvvW168ZXD1pyln%6-7)zK@r=Vn%`V zD14Rp0FSW^Ni8eB+GHM0;yTHe#K*(acmg12$4vQr6)4q)sN7@Vwlh{O3&(50yGlKj zR_7pMT~>^opBN_CU$D3^1OgP6W2fb!%=)=)n_+4-Qfl-g_ZzkGTf*uHGf9FU^_w=6 z%*nI{?}O~e2ilm!}Ew`ywg_b=oA%La3`ydB%Mv6Xaz}PP^b= zuu_Z+Fo`)ZvMh19(AHWyy@AcWO}2!P+EkFkAUX_oQzs}GO@9`vkem_mtBY=now{qB z$jsQPzoSO{$D=W|CEjcZ%tE-;I8i~dZT3`Ii-WMW!JP&6l&l$Ir8H7>NOE+o^6E*QEbCdk7g|9d!wbk81-qlA=h-u=C2w_c z7o<1uFLy=*>D;|)i#FG;Y$P`#{wzfmgw)m``oJ+cEEfXjZ%8@_26BwN+sCUFZ~fj_ z;pDlF(b7f`PN04E8jD0h1bLGvk4ns+e7z)C(=a~?2XBJyCy#fz4GDvPS#;Ci6H6ZX zUKNz+TPkNyS-8*m0ZwXmGeM$h&`h?~skXP7*D3vm5hU9GFZ_1c>a3dXXifWIqk$9~ z)0o07hdY4CQc7dik7{j8a$AsVcifo<#=D%L1Ls@bB9?caE6_6fhwQW3u@*p)p`%mN z&~yMC6hqvR(dOFuIpgv=uZ-5w*Pkn{4xU|s5z~{CYRQ`IcPp7mMug%1ubx5NYwi5^ zrGJpwIJU&0JA29^?5j*xg|`Y8W8_#S!7A|%bKtOxM+=`veWr1C)3TNS0;}sL@Be_2 zEiA^39-HP~v8*^5h2I)~H!4|uO=1&VFqyz9E_HN%W$IA$6ldojv~M>%wQBO$m0KZQ zNc+{=aTKm_k=EhnW8dcZ`Q9&?)WE#uEKGv-#}TXo(^7+@VRj~de`>Mc*}lAgIjH$n z*X>b+A9K96eU7m(ko?~Xml@V!4}J{WVG3t}Un;j6`UN{@Yj_xV)&FspmMAmju-&jW zGq5HH*<(5Pk)pTnM^YK?h4vtyQgPzh;-Qdre9Rm@wYB_Zk-{gAubi!9XXoW>ZCJV1 zf856O6$iYU*64J8BY9XPS**cp?tt?1s0ckWib~X!w#P>YlOFjvqqGTn@S83RTc?2SFlIUn1{9=NR9!U{J^JE}!4v&=S_w7W#abb)^dIEq*8!}d;$&!>?( zFm=w@nLH4rX>D!o!dS4oS_0qd3I!L--a^{2nxSI_Ws7->9GVjJ2sm|+*Qt3(O8wWf z?2>im+E^&hEI2J(btsu?S~({gESo5DI!?x@JGkVprbZEi!N}{T_76xGL6gNACpdzg zZfofmloL!}*RZxl9T+bpZBZyP?d13M`t$5qrn<*caw(_+tjS_~Pq%qT-u+mH@gB^4 z=?Fl%E@_R!cslglh)DxT&1Mi?gd0GbR0W4a*v%1|;L0bRPmUSP9gc~%!|}c=w;2z` zqpeK;aae>cP0@LbJCx}rpHF)XO)FQ|;!@vSnrc82CSQ^JO>Sa03z;vL z+fyONq#0(xdzyql#Drt?_MImRgw0 zmgudH_^pr5tbWfD^+pz!m1nwJw_Gmod59Fdw;#pEA+=j9RggWWu3HHs>}+6$klKs; zZ4Q|6db<)WC0R7fZL6svx3S4|Sr>Sx+VsYIbjcLsx5U+IR2R{C=PlTAqr=Y$UwCAz zAyT|CQK%2Kf0rWUBnVssT^Z+EL-Wv{ms2gHUM^jU9p{c%)6|!e&JE-;>ewi zt)lq2m@jh`dGU#rlT$p+yo5>@pl_@G=oUo+YHE7E)s_CyBJZ;4JOb#=20j{X#%S^xeIL%} zQW-RFJ6C!XO}KbI*inF`DW*l(exCLmXB#0J*zWP={h*zHVdjwm0Q`bR^^mRM>$l)Ip=}OZWO@fc^Hk zSexG1dGXNhuwfck;(gsDybl4q+QFp)zbjWx4Zp#~=>&9ei$C27Ybw5#+Pa^3P45I) z;Cc%UWc^ORCamv`Jr?*ma0^NY+9g*`V1@oWTR!h=u|!*8Nz}=2?vs~qa(l`acyAP* zi@fR5YCWgeeu=Bi@%zhVBkuhg&=C$Az;)i1b2I}diKfSS3*A%-))=~m#&Ned+pQ7T zX#3z_y-n2G@w{MgW!>EcRli}nU9jR_T{K*E zd;VEkqdg!V`nMMT^dKLc9sezcfT5*Rp==vWUiSyzf!LthQvl4X*WEmDRNgHeAkdp- zZyr6iQqOSZ=zAOe2kTG`hnq=&(c9C25b@ggNPtwzOO> z$RsT5g7zJeT%$N}OJlTLI7n}Pt3)6(>Ia^|P6<1C(^R<6>SF1bx_fhy}a`y9XYUR7=2E{@32*bV)hrC^7*$8|ZI1gyM^NGBF2InBaO*60Yl70Y!SOxpPVB+EKJb#m1!DAKMK+HCt?g&h2H)M;xc__uGorJgZARAi4EY76L`1f?J+ z#<~>uTF%|rvlIE{GF#9I1ig##ZC%!|Mz(z!^*2;%_W8%3h%t$N{-avncEhrLs4;&o z!xpfZG06CfqVpGxPKW2f>S}c?uU1!^f6?qDH=Fk%e8+UDHXCpIXZ+=%Tj_g>Kt;5a zhu!g0?S_`lgO=|rb=reZj@P3gXV`j555$#Vao6K)ZeC%BIb!eeHFZtDY5Nt~PAle? zu$Z+(_~Ye~v*+f(3#neEoMTsPxjV6`r(@YUFFQ5@=Fw-~h$);fu%@4q6iwK(R=Lr$ zx#o35*FRm0DX)OvEi5qe01I7 zQd$lmCAF}5q*-ApKaOioLRQ|1-Zot^S?ML_@5Yh|{K;lJJK0vAEbTfoI0f@)| z5z_vCGz*}6>ws4O{qYFF%kU_pwk1!0cD7}kB<(}Ogp^t^WDAUD9iT04{?w=5QzTYhYrbf zp-chd4|v6J=5y~2a>qd&KhP}Z=zpf`0({aZCxEgWz1-@$*}kI*xTSeH4tPIKb({v8 zxZAgV7?ixnzaD<5tQLJ|09IjQ>hsPd;CnIx^cIS&rm(jEcB@q2I&2Ec^#6*-kkSjM zvk=YFtCa@`x-_b;pBGVrJskd23juNf$trJM&G5wwEceQug%dK0mJRz0k1ka#Xh1b} znv-l$j|&QvFE1}IowJ&?;|63d8r_db$jH|18>xd%URRm9x#nJ8cxV^ zdSX?LdHaUBo10p6QG!FeT=mi~ysv`E$KI(yEkI9X{YtXI9-b|-b-8#P-|3DUV7x79 z&O3Y+^Dtbx>nU|{WFvkB0{thQ*B6P5fwlOZ@smgJLv9pQ^a=*0b_`w29drS=Pcj6x z7anX+Jtd>eJ)dL|$tK!2S{25i0xKt~DgiKZ)_DYl%GT)*o2xVLaisuMd&Wqy4-3UxjKB?qL1>-Th_EEn@m=lOqU^umpRurH_n3WT}127A3FD_sU28nX~*y&$PP z8i7DVBMex1#Xk27SKWOC3r(?n8P$y#c(7G>Q=iL7r5oGh7{Si#`%{R_|)xAer>lVRa;y+s*QzcL|RjFByUk zMB$wGc5!)q0_RgS=)B3)f^7BxYx1Htfw27XbQ-egO)Z{j^M5Qb%Cu zF1rITdx_6~fK>Jzk%jNS)1Rf5;-YbY%&79T_oeIfO}o>LuFDg4{Pj+Pe?61so{^cEILPG5M=Z0$e@r`}nshfhIT=&(I5j=3@d)x~osOyY_e=8J1p;iTvB@_qgHfM0{4U3Pn>h ztm_IlWnw9FdR7QNsB;N3xFrm12{)`6gBZrhRXbsWG~*1BQHbC^tUu0P9lWcx_QOsp z4Js*d5466}G#N(acRcKLv_2pjyx_Z_6>W{(KhtogWm09xQQM3(R!-w*ZhT1AFq6B* zZYJ`QYZp+145G#5M7Hd-L=0#M7i`AA^bmJxAu;HXapJG0VDe{*j567*w43w<%vpSH zhs&uB@m5EaZ5rb2OOXif1AnA6-#B~=raXxsTT~+&BoNhNdoW$u@90=G_$@A zXU3AltN@zmYd3)lrI&ya`f(p0l$;{u5=`m+ueEz>EFr8&C?P7KTq`CoVm-GF6ipT2 z!{b(%B&blhmNoV{bO>QTYn*2}aA;0N7baQu8zdjV(S-V@*uCF8I{%CZv(l3I3LA|) zs0vLfq$(_;U?!?lHXI{q9}=EoG;E7dFDKopBo18=6O#SKYXDz z`aa8`6W&G1yH>a){6TNduvYa<$O()_oJGn~(Sf4UP>V1oB8fRre9LC!wDt&w1kvi; zO~QupKL<0D9G5b&vZ%P@lve_?0oLlEd<`vf#tqFTP4?kzRxWEid@z=Nug#%uQP>q);l&udmpIkJYW@?6kkbMmeqWI1vZ^+^EDc&UY-WxKj6w zCoc7)xbhm|ja>*VEXy)7c%l?7Kl6KDngJR$00soK!c!K>fa>w#61gl7kYEGyw9L#* zW5AdMOQIjxtnl#h$tTzW1&>_uT*YZ?G#s#t%PS}}0wP90f_1&EIiqE}*6v6OybB=D zQr)WnP;pk^Xabmt;ggbQ& z^c>0&7|FVnr;tnNT$g#g-qeu|a;4*8io#ttS6D+WmO@iBKc$3@rD##4+kH~F&4e)_ zX{N7)5k))d5qF2-9LWGWKK;v2%elxyB>i#O;=V@)n<^*ZSrnTs!N<`-NR}yI1u{#A zRqU4rgT&ahc0gyydxUB8-_v(%g86bZ?Uskrp+i3vwcE`MHhi8sQK6XL<>tJdC14wrFQPKmi%h27Sx4$dWERA5>W;RyCdR?+4h%ZL4D{lIvi<6_DNUqD z1%pKflggZjHm?@3Pc^dpCknRJtu%O`NJotzNt+vml6Ae12fhieapR*sZthu%61E!Y;>sBwG0 zZItDn7Eo3P*wVs?8*#s`gluU{T$;8u-IA%C zXq$MwRZLWaU_7o!jFg;&UZh)!DxwS2F*Z$J7)+%gN05(_Yy#K&F z*dYmn2(~Aaq80*Cswuzj)!;EUiOXrY@`NL0#)DdRE<8@$f)*T9uu`c|fT{C!t^k;U zQOs^#Te1a=6HT%{IM71l>@9+Vg8HrMEEuV>Thx4PTwLmNz{@hD^4z)r9hLz&ufJ_! z=j+$6*!DnKZSwE^&DHK0{8$~9v|ugo9@$$o!7Wx2tx4_~CSQc?PYxlp+9#Tzb|F(# z7oGz$(PKgWkuYe$@-tJY{CyGLH!H*3Ik!@8ChHcR4L?VzEqiO8Mwr#tyZ8vD_#!VXAb{gC7=JWksZ?2R74t95DXJ?*yp8Ni#;?JS5M4%=N zNeO?6;9#RtDCvo6@HMbd=Ak{>X;Y-O0RD^VCcN!5NB@qg!M=}Dc|o3zw+eeI87{`J z6-^gm-aPybGztxzxy+;E#E_6UQyg#oEEY&h(T;9U?}@}mi9;AX)sehYL( zrWNvwIBwKT(kZzw#RVKRNkR8*pk)3{osC%v zkx^rFI-3qy_k+4YF;g-oN;1&7wO+zXtzTY$3#z3FWg4d}-gt@>uKTjze3;9xap?Kl z=#00r?b%$p$reaFdMJIqaN3=jW1tg^M0SYbQ zZeNR)LqVc)Sc*tgk`zqnT58LSJi7AunyT5?-=735!cOAOAnEq=s>P9=CKr}HiKjD4 z`IY>-=%0r}kG0GXRtW=|-dCH9 zC)ndlWn17zW8`Ak@d(`6_MV_?6r zC9UaCxKJgbjvsY@G{bMMzEJT-T-IW4al_9_K3CB^L<>-kM5y2klLcWJr6$%uh%to4 zK9f|XMYWy;xx)uApVVk-^rGTpI)+kGF;_UiO?M5xs&{_AlQ5E3vXY_8LW?hZ8hO)_ zi4bMtSPo(D8|tTB$AJAgBZRo~pycg}8yhG4myV|3Cz%kJLgRN{hHSVVprX}bYS02He*uBpPo!eyWfh>Bsa#H}aEMBBKH{89yECZeJ8 z2WEBwm&XM+7()HWcSB;GUfN4ulp^mtqPPcL=EezlIdWmbNm+b(z0dJ%XbCXT->sRL z`xl*eL=Q`E4H(N)rijizZ&Rb=78su+j~e4rZ|^(@?b;Tg$jQhQo4efgO_v6We>r>p z<2-wMIH>Z5PRFq+xC7^K5-^2yWYX6u0f9VyxFf{21Dy13;OW366vmIuiXARoHMQKo zPa8p9IxZRYlTJa;E?pZachyfW0!Wwoxe07JCQBU+$Nt_po_V3#?7{k4x5m>&H=)u8 zOX?t}X*B7bTVX_j#PA(&n>iSDc|pwsm3=3Ua(T7aoF@>)wyvN2>srul+Sc#MrrBI! z$*#QdCU*kIPnnkY7^>kWs3=MhNgA^S=`TLf2tc+|e8R>Ic zc|v6H1qXFt5+(N6eIZqaTmu>-ZI`%S)gb31mVBl!RQ=(t#ZW9}hB!uaN*G#9@B;h( zt`rvYFrY7JQ<;A>wjt4@k8}a^ZCZgEk{Z4S#y)c7CTb~ZV(OeONjiSGzZzmHNkV+| ziXjBEO98B=0;H8LX_v3?5BgJMp$l|aqfs?IG@Amrk~<`=zw;>^x_eSEX=#*nsLwQb z8p>!a1aLAI$`#*)8*s*Ep{o=GUCFj`TDC7K9rTubnW?wE&4Nh*oFi$YM8hm$ia#;W&(_pf{POR2aQK|iGmBzz&mK0Gma`Xq&@ z%fuWkBHsk~_n0;|F7X{TaXaMkgfw+l$Rk7jTz2UF5qy2?s{XS;uV%;TnvszVxIXaHI;jee#E($Eg{Y z+O;0IDwHw-@LM-xdbn>-z6zbpx#{U zxZ}2fgHe$Yv9&p<2*l7#h9%qemb}(?18{|9j_F}pF#?t@&zfyOf$&svFk6*UN?MW? z-(Fv6TAHLibQQ`(NiZ4uh8bMH3{=NY>13_%GY#&#flI&DKcf@&K|~-hRF?Ns;w1Vg zFl)q1qsJ;q&{9OpCs+jI=93K1c-!k>;IgGCkyhLKA>0&S-o$1k2@vDMr7Yff{d2={D=)nlA;Axs#iBHN?wT}DCMEhrg5 zeaFtst6GEc6z#%dwzRxGHik^2swj+;_#h^$XaTyQeIh*zrs2-4_z}zrEwsI!%v0T{ zXo$ikq56CxY^+V87AE$DSe))9*Sp@(ct1Ndi<82}eikJ0jm?sgJ(`4KFGAW|DcFAk z@-2%vn=$;L(ttR46B+&A<1!yEIn%~H0yE?hkmVm$P^NNuJyjz7&0_#j@d=W+ zaXV#w8hxgfXl_kn&_Ym;_w=Q{S6JXj*2xM|$Yd zLlI2iMRi*TLxItNqvfLx^GU(0l>6(I^V2_=8=RDjSosHau zUENQ1h|@`fmtPsCEer_ONC;!F<;M(GS&7%u57r~g%R&t;7ldfg^P9HG60y@XASr&T zk<13!C>qHi^S_Ju3C?@5Ebe=QXndM4te#|(e_9v?Mo?r=Kj8<7#fThaj`FA|J?Yo< z0eXq~_EPCr^m7Dd%>$9+(|Lgjixr{=+iy0v8$TWFLQzP6QM)hPM2!;C5`O7y5nRUa zIM9#oT|M7F>lS6kE20kMA!o`j-sWff&hSeuNUlcx2m;3d4f}tuEpQMT=|m776ZgfZ zo41E(DdRWtAj`P4PZK*0VhG|otx}V~n|;wpq+C^88avbV3bcd8HG*M~=~zM6&oG4c zez(!%kJYuCPNY$9TH;%%rBNUsH0-T|Pju)1#0|S(qb^W|1wG5@5tKNlM0@gpp8tWs07NsjS~C#Tz&v%=dKav(1h!QN-~fi?Nt1!Fg8Qem$(!88yQ;wsYk7VDX36FuEkAgL@Xe z{|*^niG@AZi3Fj4krCw7yW~&U7dL#lnc7mz;|-iUSuj+f^DuO)!1Jf+|D za^@1j|N0ec=@?H{IX~^@JH+{G7K902q+;x8Ki7>XT5=CdG3}{kr@nOH&38Ipcqh1b zeV-ekN&ffJ!^D+6t*H2@d@>!&pv!H)&jErZOYLqoUv%0U&_fq$^caCWsyUaF`Qe9$ zmU+r26(!Y(;$NMUMHCol%cmR_uNhV4J#9~XXiqm@5NJRQ#9dA?@yYK0U!CqU1x{%c z6cko~D*Ur~o#%iEO;L3ZK-W%E#J}%S3o4>*g$ceGNacO61i>@0ut({;!8$L_rS| zk9p}?{LFU36c=t^%Ta&GuIYNp+tAWXd1YyqxPXw*Fu@(Fe)EWz0~N(`wd7E z!aa#G^Djb`Fb`hi)xTE`&At|p4gf7DN%tT&IzU2?ZJ`+~?!FrYgJ~71U)dZcJ&BXB z?58-J5@r2&g7~H2pAKyQib6peO*v!5%ExzFFm=CZht_h@+E?@O1$MLxq;xnP?VdlXYpWh*Zp{eZu8MHMFqrP|9$Rd z)gR1#AP=|Z{zu+>0cjs7O6uYO#&;@BQ4eW>K4nk`I69DsxSN%;6muCYD8cuE?p{y3 z#+6tl>9#J4L@Sr4IAibl8OEY|-$(CXEYbK?s8=97HA02PWwO>hAyf2o3fp1+3U8=q zF26tiDrBvIQfJNEBkIIxyTrYnVF}xPU~XpKHmSfk3a2+2S((DC7ncxLP4p_Yge)@O z??&R+-fn(Wk}l8E@X<%@H8SWw$mbV)u(fdM}|e&;h_=4io$vJ^yVKjh%IuXG?x`e-!0OB4(Ij2BwvW)&CzPP)ITw%?*>+v zhC&~S!l5QDwrX@iLOD3$SF0~@^eSY3y&3)F6h`%Di|7RMQVLOIKk@#q#~>~*Elpv7 zButzHnY~}29I;3UAkNv4pYdZ}$4SJYYPYrZdHb!_BdnI!B}SBOY=}(}8oCR9o{2*> zvwul{&LkAj;RT6xLwFnKv+16U&WY4sAS3>KvF4YpG9Wu9gKqHSzq#fZAz9vta%%8I zVLRF07D)Pk~%;>w4Nop_<+Bpg7kpyBqci<4SE1#wS50MF73( zV<=PauiqE<LR|X%EfhwJJsY<5{*`rp!p^6=16-ULLbpfCcwHRQLvWY( zg>cwneB)m79t>m13Qa|{s;&Z|{|QxLs{u0n@pY*$NZ8mK;}Tok=NsKZSF6&tuR32G zR06w@84T3dqO4_c)<~xddNmg!lXVGY+^4 z)~q2*)IGo_t6oRL9es2kzIlaQwmPxC}qX2=re~ z!1Ms=Xy+jmgysMG_NR&}e}VE=RAdZAh)wFyJ)N~W7fNKNPPxE;KwZgotl6;Vz^2~~ znIcSs|48cKA?{U$U%v~*?2Xt`D!3yi@l5a5D-PtdN+3|N=JV~P+}&O@&&cQViz)U(7IY#zRtC;QRi#<%nTqOka$cG~)#57A zK$V{9Ulz-wugXWQ+KNfYLIlmD;D69yeW}Dr({G_OLx$|9rlNIX|(X^%S>xGBS_8aP*gNsn0%8j9L4Ck@)i2Yh3ggbYhqS3 zqP$YjJltN-LY{3U(wPvy%z2OdtWzGw@AyEe$#rU0O%rGZF)04PJJmh^vsLxd7I?u= zf#hGUE5ufWwd-nQ&rrlARyC5lt=7eUA=VHDjsC3VVw~k6$x|%M^Jn~2vhcdpCYdl9 zpdGJWt7{QXs*Y^*3SHd8lrxRzxduZd2=z^5X~R#~(iG`yp$Rh;JkqgnLPQN#c5Bcm zx0G2z$wN=VAs&EqE%2SEvpKt;uHh+Ho6R1Q+W`BUeKwnUQ=X5JftI(@JW`;Hgfg=lRNGl|| z3YGYi88V-qToOqsK(Ym>y^vK{qu}hoh4YX|&XR!ShqVK-{;J*mZi5y={;qdIN7n0; zfj}x}mf)KJ&+jY1(%w}twb7GNKVO6k!MO6=htUsgcI#iyg1 zbrZ<&sz79haMQRBN&E`+)USPBpP@s+6E!wK4HI$>CrAkfR^=iMJV2l}ZdnPVo>iZI zjDb9c^a=3TzWa~Ytq`kyM_l|Gh3+d6m~#;;5ooIw;4?vs+v`}g$0gRQdonIEb*jGM zl_xYuKA5LVw-H?Y7l|VlzenVv%W$Q4^^uT~8(R6u07x+S{K5xJy}Nv#i6(s~VCJ>7 ze$Erly>AIs$YmUOtdeO>r5R7_9<~NG!l90oOOyy9YSu3qdwO~z!!#HTA%g+zrJxG6 zfmye+Rc^aJpfVl+XpAW?5W~%?;sdZe`oE};n&OE;#NMh{XjA=3{SY@03YbtPcuuV+Xwnb{DNFLgmk||(8lj&Yx_R60#&p`AqxD-#tL=oc@qd9b$$`R3R z4=svAUgB@#2Fb_6B~SMgRCElmHXPE@p|u-N$`ypg439~&NJ%RDNhj)-nv9%@&#)vx zMk}Ih(bLzY{Q0OwMrM0)AWiTSh`k)g(4Y0JWsCuYWCwnS!uWBz|MX%Upt{oQs@7mL zu$dLL8gJ&-pyj0%E}l2JLL%NK!5#52ON@H*hKVeez*@j=(qiGV2;bEdOH4Ywh z3|6Qiomj@*o1CJH?~?j!ht1Kj;heP$mgw1`GLDcjmSK<*^a*B6>_6nb5p#4in=_&) znJB~~oVPo7!^&Z``}dnE)EXR&jnmkwwXT1TW6r)CGw{b1_t~JQ5lYeQ z2y|k<);77$DgEJbVewF%Wi*rPlnd)Vv0NCOZ(!i4wdh%M(k)6@CW%;v6a0k^xpGD0 zkBh7r*ICPs{dgVF(7?ucY}zpI^s}UdL>jQe1|TxRUPm|EtGnMkvCx2GVeYUv;$`Z7 z^^*KU72)~OBpMNtl+BKx#()XMtz+lYRm%{6OaYC5o>m%=z||LuWUM&|;P@XGhXknN zC)GGyVEgR=#F+JBt*}9Le|sE84|vn;Aq~)17IeLSu6myQjL78vCtY)2InF<~0c2+{ zhZN8lo0^6e&;7RiS@lH1aj1J3N%x0U*XJ|1#}HxLzz4f0m21qs@|RNo%|UY zE0IxCpshGPB2rDC6~@l}wllFVLKSP%Iadzt;Nmg=x>d)7x@GYj7vV8vUDI>{ zjoBpPxc7E$o#B{7mTM3XUTnl%Bl181wMh+HejM^p!@_0x538x(I1|wThwv@A_0lYm z%Xe$PH^>FNIMHpHX6ucfp>8icH)kIDzfPV)`>zH=@m^TV^N#Axe>MMxJ~GM+WMg3z zVu~}`Uxf8{l!)+DKr>THING*f+rDj|H0SM1s+pt^F(8tqr0j;&@@At>Sn2S`Qu_gaI1cGNgyyH<&>l; z7?papSvbRVU(C}Fe+;VQaKDtNy&^+lgmQt7?sG?&_eZH3$yaF_L`aLv-`qdn7?tn# z{`37Z$7LH?i+p~f9Q_yDK63LlO&A99SMk^Ht;Fmm{w{sNTaRT z@wNMRiM+6pbj(I2ZU~u$BXZ-rc$_IDPyIvA&hYt@(R`LWnX~C{dct3t{KPdb&#j_$ z9^*RAcH1!TxANvB=Bz=s!j5^@%$2FivER5V=XK}an3zs!2kc+kljL(q9&EGVb|6HZ zP+=h?HSTu_%m_x1IBoV`abCb+8p8BlsVp|g>Iqw!lou|J0$#82Rrc4tqCv_bbZkz= zOM#SM@r}7xU03KQNwqxC(oN<@9DrnTvwH6()HNDTnpol8bBf>z{HoyA*299R2Y~EU zYT>sA2y?&yTSScDoBIdUWp)-5KtwyuWAB%eN^UPU0eGVi6rIOGHuXV{*B6f*<&-^x zuZJ$u-atPEXhe=XKR5wi8@6;eIM;!BqgIyh)6sF~0l*SVDyndkR#2v#2qCEMjCa7B z!(%vfMUXjCIhN_%P564TURhz;Hos-|cxK<@Rb!%g942s+-{OM&u9vibKGXS;o9Vth zlQZ~1I&uW$7*12-I1a$C%@1xrWjpJtBHWrhaIr7VeP!%UiTcu(VCTE@+_7hKZgTWX zPqnXA`a%_i6{E_<11g4>sRf&Op1^gwsFQw>(@<`_Upv>WT=#O36W!>jaP!pI^m)g- zt&sB}EL9e?*K0}HYG#9Th{Q;1(Zbbix^XPS|71fJU zecmm5@$Y(dxmPKpVTu&=USD5{-$-(9WlbK%=4zmxnKxSX+F4#04rBeClPj)|8~6%N zi?xD{^2A+SyPnq&D%}|MXdAoIAQ$Yq{3Ln>qa(w$FmkXa8?Am;YlSos9CZ}RdqmFR zye}8ttoc1je0jg?zI;8`s}k(mS31RFoqJPausj$XOMmA*YZ2|f&OW~=Bz~0wOki&T z>RngKFUK`+bS^I~h6C{Y`)Nkb*4F?0J+u!!x!wW0As5sQB=G|n_{17#+05dHl?h?G z6=418*a3#ZWj6^Mrld+ZiAn$FQA?e9jh}_GhmqE;UlJ9^{3BNs<{&BTb%Z|Imz_Hc zLFh?|)c66YBcRSisr=tY_j@f@8HLM4>6yAJ09KM{UipwI3aN76&MNOwGJ3!PWUSq2 z*6~T#_OXi#`@IVVm{NVHS&x3`X2?1sm(Z>F=+gHqBev)P$9=}!QR4T5>-1H@vF2{4 zVgSV+`>+WkST^zrH&{~8v=dVt$QQd;O1H*R>CqC^^b~tK7C|eJwDKE|Hcm1b?r$-g z0oF{)SA5GynEfe2u8VZC$q=$JQudhjG`fg`(&+EoS{8^)_19%mNK}@)$(Sg_XAD@L z)PmTpXVec!1p2?CWN;b+POQLiK+2vY$zN&gx&p={*^|ssLu7*L2&b_tB`%PwZr#n8 z2DkdR4X*^X0>7U+d=h2+TL;U(F%UtWx@oQPkf6z9ToQ2)b?mu#NoebTMTnJJ?#Z_e zm1`kn)4>zM@nyM!(9w_d@!YpQF+MEiUf?{e&C4oG_mWxtaA56Q@mUz?5V3 zv?j~v>$5pug8baAoJBk4Wa5x>>}XfQ@I7YaCGxU?FsJXs*9bwd#!A!RXHfVi`LN*= z<(fUn(_mAxXwdN%?xiBmObnV8y}=l5voAPo3X{7bMGg$n3QGf6xD2eek=Zm^P=!)# zki|}}=6?#FWh5@hnlZ({2U;EB_F5+SI50s0>IHO>r+Jh+DzO0{jDiQ&Ho9o%qtbD5&E%ExL*{Lblf9iswdTB$5veM3{y|7qsflKQ1qgZ0vD50))Q@fU#_^2tzIb z2oP5+=6z~ZOV2)2JUdpr3mz5e!PNL8CX(lcznQn(c1EG54!!`x=We$lngt2Xvf*~* zVI(`33e+wEutt|fj>p;Vo8}52G~IJ}NCS|zFu?vZdje)mb&Y1*>>T@EI3Oa+otX2a zsP4AHW||n+K(K4^4Wc_?&Jpe6gPmwi)wu=;oYry+wXii z3=>pTHQ5QpXgOG4IdlbOh8VN=#6ZzD=waliHh}YsAS*$m*o5eE5}p<{tIdd7dF2Wp+WFHVi6iw{2Mxs`+?`Le43j$FFiV|a zL@|xtoukZ1@X)ofh5Op?@U>BdVPz+q4%Qv%Pt1ceOYq&U%r3hXjV%h zhN^~7Bt2G4lwyvWW8@EXVhk;`8e*Sl50!KRmg!kc!g?GY4hzZi#z8|4XiSvb_kn*= zn}Re{SRoYUTard575!9A$T%t0w5kM6*#WB*D_KmUJfHa6U*gVWq@f%DVJax2{!>es z|B8I`2AAT5)f##|Agy)+wEs{*VVH3k$s7u#m(~JEx%q0H$_dZ;diN~^y*~hyaPNb2 z_;CLI+I>>;EmNb@lN~U+2pHTCuy&1uQ7p%q^yqEc6YfACNjvh%pPm7BL_q2E@W53e z0{}S9*8z}TyBzX6PIUZ=GS6DlDZf%_bXlhX zU+Ol#sI#=70hY9`iq4BUw;}fxHFgHku7H0?vmYJyeQUh=w%^#TEQDCwrE%5y@7T+} zAD`YepDoGJgs}a3X8T};ljw`mx>r1E?_R}Cb7RYeGT7*in`^b{PQy zOwc=!H?d5~1X)=!%9kk@6+-l7opLCr6OWiR5)782wrcn=-tJ73jKaf;+qFVvT!Gd^Ebexq$!>FfD@g z|DZg6P}z?F`f}y-iISgIQphN{p{1l6UtmQ4>JU>+mnYVxv8HomL4Ui@Y*nN6YD+wD zGc6o1nF%J=_?!}4r8IYl8!!SVs}Jx}r#dEymEX^$ga%YCtA#o;=K?UsAbuo)e>ygx zK!&sQj^Y%qP!HJfeLZn8HrLSgT>r{uH+V^5x(^2OAW5f&BK9t9sjmDS73xk3v zfRebTdF>x{6|*=+-9Z?i6Q=yGl<+<6URUj#n zQAz*COJI?OxP?t;i|j$PsgMEo1%X-_ten?+!DrUTTfKL7{-dHqHrR0NgPkl5RE)IN z20eBg{%^e^Ojt_9$_m1Sd3Zz=2@xbzq{km(Qh_6B`E6jX8R5MNNxZqT@zzwWQ3qj! z2;fw+gEnF@W5m`3-1T0Ugq@t$D3~O#5oDk4% zL!ccfPUpGP`o4Yse*PD*`YqNO!BmWVbYhPkf{i~P8^_;XfqLfU@W&^<+*bd{zw#A5 znAJuFH(0)jGEcHLCIO3fFQ4qwft?QMrrT5Y}0NOM3Ar9X&Sk%-tZ^v^dHRdk=L)GPiV%a#Jw(U6%t7j);U zbPJv#lp?*K!g4X*y(Zsi7t^TFe}{{*2b=#fSVZmf3hYm^lCTKsr=yCxdvlH@zpp2q z{fkg6R07T{ZTm+ih!skWf1F0Z61JbWHe3XAqE*km)-teoD2Po~<^S13Oi(D%N$xsg=xR@-UsU9^^+CF44ESd1@c znP@D4CH*9S0e2(pm`t+t$|0!nnIcGOE7kB*T^>J!vKK z>T*T2eR!dd%BpZ!02NGKsa~_YcK+t%vGi9re`lv~3k~W3%ZSgA;>=>(7`~j(>GyXF zFg#kmN~LHOk^Yxp=2w22MgpUcVdFn1WLaU%Hskvh;~s+ELmEj zrg7q^y-nf;%urrdjHnZJ|plC zsw&S`LZ|Fu;^jJpEgb;t35zYZyD`dpEzj%%>P}AG`?iInhX->Ky7d5XE6OfAf%+H& z-y;*;bpL?c5s0r0f1rtV zS{?VmZB}z-|M&#Vh8YuZ-z^$>On9vqet?aO7f$NTCWzE(bbS`_BcF}<0=~$YTu0~d z@R^R)U2UvC<2-Q!;_P2Q293Q?f)))~Vwfw(IdvH~YlH?%Vq!XBp~6#w5-9C*oU~kYiiF+}S94QJo}?lL#?=-_JJtQ=N7e3CX7^*}vSlNk zU+#0CI<2tI8t;P-U=keXR;$wJ868CgesRz4{myO=7{96n0HIXYjKo^-qrz#naD>m% zba4kDBUk|Z0b+o~n{Ww$RRfPdE}>KYjSUn6sr~eTnz=<(24fc49R3%$r#y8-pK0-6 zBmxf5!ds%;cIUZIjleZvkWHex&{uj3f(vR&G*-Jb|e3;!q|DEBrT?QlB0`K&+n%vL{T0937R;% z=$wKk6T!|Nnr?%O=YD}AaYf zMdBNymhRt?<%IK|Xd5?i_*7rhz7}wWUR zVl=Pc*C^~`T!g4-{rIxeK~n3%kwh_AJ+o_V}t!?f(KCQ4Uylyxlpe_2)BZ- z*#ga447!ga>c<~xT>mS$t0y~l35%E=_Zd#_MpX$@NbbEm-SMKjuXmlZ6aX}p$GJKe z2frTA#(YY1o{jsw9)$U$eyYmT>Ud6z4kSKzTzvw{eATtfeaJT$!4&;kvI-^X!lg_& z<(HX+ahs4rDgV_Zb-Gv&gUEVi-BM716TRA<$t^hQpjN#DqDq3ziE18(36*#2qR&h-l;3Pz^Do z)f5>}yaIjm64M+3_=#%&OYZw)?p{^>xDu@HzjC&QLbrHnLxswhRoKH~;i z?2dn(EId3Ti<7V`ZfBePH~>T6@9mL?nZs9MYxhk%^O3Ix$nmn^$ovPa1n;}&%4vbfx*JqeR98N&Zh-H*8I!ue4RTzmS`#twpGGT2?M^83sc)Z z0fQ2guyQ*nsO{$9&qXaP3Q520fH9r%Ad6hgur*CPLe=-qjK)eer~4GIhaI;!oX1nn zml)u0dv?NWkeFs8kH^85BNM<4e2hHA@0Y}X3Dz&5KjGn(tNcT8*7L;HeeEfnLI1@@iUd?vT-)#GPQM$~2~sN+zC{Rb zXwDTA&k2F~8lAsh>(4~5fNp^fC=R`+P2TQ9qPwH?Y>is<{b7eeajDIsl|U+Baxu*v zJwZm_n@>W;Q+yPn%^+YZ^xb0?zsY)Su5>J(-7tS6!4oINpv)5g8 z5orSA1|Q-Gf{h0WkD1K%BEZ=IGy9NM!-zty&6JBTfOdPc@}Z>$OtbG_z&_*yKpE^@ zC(367@qO(IpDiyaF8LDd&r`IvT?4H(QON)kPTS49-Ff@YPDS-g_rW*2Ip7TH zGYw)w!X1%@jD=DL#2KeHeET(Q3lEz1|3;DtMjIR_PxDv@M}R?si}TyxA+>##0Y_Sm zamH-?Ja)>X%jwpXjhtZH_}-gd-s`-}2~Zt10HK425%sR#ci^C8Y;0`GDk^0Du6lo_ zHym#GW+GeCWmJTW42Oh7ij7#<_6q`d7`_hzz#lun`cab1BCC^|^Ec$Ubcgj2kTBAwh&MvdQz0F-JpM2Ym%v8;M+)Pun9ch}}wV%S338V=S<3rTb1e27{ z0wo5tpS>Xa0AZcY>1ibjZWq)f4_~p+m(03t(*dhvD-RFu<>h62Ajmlbm>wwA+}1Q0 zNKLimMzLz@q0*mBZnGDYZiD5bbKtfWFI;0UP)QP^v~j?u<9L) z+()osrtFonWZCA(@P2swf;;i%HMsTRBa-CvecB9bYvak{^FRk!`z0S5q|zvQ^A*b3 zd$p5sHFqt|9A<6K>IlGHM{q$dOiWEDm({%ENPE)a1ZV-?#Qv_k;0I)Y!(+S*_8S0u1?KD)ZLvc6DzPEhhoqh3|IPGu$6zW>ZeA9TdCB*A~VFY0h&!{ z`kMM>FVn6o-P6lUQCXR_0Hy7cB?COs#@ca;nEWjDg!|TP(Ym&szZR{})QKd;xZi)K zSnpO?nE@Bi`j%5CO%tj5Y3W!MwkXh8E+r);Vd7v%gC4qjBeqa776Pp6@`qg>&}2Ix zrOSFAXbueD-$#l2&Zbi<_)#ud5xR896_RxKa|;VQTVcc;^4&Rb>HE&@gwE=D9uyR?3v`@hp@FWbMI4y z89UVX2y4^Rw{w}--cCRiYZgq+laX&VSG`J|W2dNy#!VQL86OVqz4n`dSC5ID+o|tgbJtnRe%qSmQ|FA891QzZZRRE!b5^DcNy_g z!#%8F#46A9A}lKD(#~a8J#cQcB4xR*?>06xU}9lmu`x0IW)WauU;w-*UBbY9!C{e) z|I@RbM^Mw_{80jjWlZxEOJnl4bB&`qArgNAyACvYzDv*@U^E>Zl)%Tu)i>qXOZ60r zwwyFA2*7%+ANIY)_lZ#TvBQN&8dQL=O{2EsV%Jsk!(}X_8sT!kWakX_X?Xx{W~PZQq`PX-?K8O-TEk4 zt=&c!+GsXHLsJt=oYoHh-?kJU*91GX9}-gw3ou}d?ag*+7edFskBpC%k6CwX;T7oc zkVmQbfh&_bSo#S6orFK16prW;t4WMP_eXc6%^{~?q=audi&YWVybA*M962}G4Io+I1c6OWXMmh^S*_DP zIta*x2EmxPxYDNF<2hKxTW`DmnzgS9kS&|xLL1iM62{Dyr*mW?g&Gu1t(q>PMoIKY zLmK1OawBpPzRg~iAOGNCsbHD;$%|DLqcF!nmL2{?^2dUErc}OkIFXthJF4f@b!ReP zG>z2^`3tZvlH*-g0oSoG{gw9Xn-ZSJEzj$+Y)Z)p?vtI`=^mLvh%nIB06u?QUB?AW zoD5BhvO}K<*Z@Jklu`LAzdWA^gAOcW~+Jn z#nU@9=!(@;1Y1i#czAdPRn?Hk>w`pkm|gfQ`pmIVVBfBu!gC*?ZM$~Kn&$K8zF;^E zCZ~PbNQpx3866?Sj<+{ocRr$-@8hZlT8%J8?%n0(5#B_MgkPvvU15MbMWl|V>#5jp zVES*0BL$&cc9@O|i2W~1Vo%M;wDz-w>QL{=?Nxzz)SsQp-#eFSBbUDeAYTBZ(F(nJ z;kw=Jl8o^2!}m2aN=WCR#Ssl9;oa|4Cgr@!YFr~`%oUj#84n+;yARJ>Sxt>34QAlN z$pEnK9Ch4Q3k|E@ncb)|C%p$jfV+3N-s?Pv(Pp%pLU0z`p;V-8*_xI$uh$WgTS6W(H z86`|Eg(NtnJj>@sxA-?bGe5;&wOlS#vt|z}MJVxq6tuUmpxl&%l{ve&u_l;rFPnh=vRR1KR#- zQ24;YRKm`V5gG<&sADFCCb`t0en~+^1yr-(<>|GJJE)K(N`omPFOQO--$E$o^mCmF zF-cwRHy9}z@9U?fXy$7Lf#0LBAtHz8=ftFfiNh2S0bnn_@367BxU|GSch|oMIDKKt zs4w}f{pv9>8!)uCIH4wZYfR5&Hv;j{sqzyH>yIIx(L+YJ?a>BDH)d zg=*lVb9fSXAb9hZ#P5?J20Td?d=ZNA@?5*_S654GYc^g|2)pSb>6W`?tH=Hb4ELv_ z(%~cL7*zai;b9_i)s{bA;Ge<2SXo_l0MP1dV30WkLeQ`v2wXx!F&&*xob2qH$XZlT zDN+=*j(alj&_NgOd`yUtTlTmnCMJjf{)t&w&_Y8)Z`v1Cup=QM_1HG|F(HbPp{b~- zYy#hu#^pfP+|;CGVnPYbO}6a0WO*X)3E@K&bai8ZiH(Y?>elXXA`Km#h@YQemRu>` z-HkgRu&WAOE%zbb^`0d%vNl6bM96#v*pG?Q?cH7P&ZWM-KA6+N_}=lcWe1={!i*4; z-C{5@Zsg0GmP_Nq|IrJK6hMjBW=sq+8z*Pt&<;3hM3GWSf;BWcpEoMx=T`W;BrpPa zDCVvbY#fHJv^BX@O4drF*%K)geL1VHGM{CTsId1jp1{%v#l`(WO&R zkdseboa~-oF8&jn{``3bSe7gQZG#o_5#9qNu=2b(wGV19UwgrNiP_CLPU8B#sbZ!q-K3q@6PrVgGH{Vj-%(YmrbwxDe+UuP(+|;rU zjr`7i;-fy7LuaAmda&Q|qz5`45Y@%?1tEL$@zI)dZZ#v)hssWWd`{hd*oO4R} zjRNcYkf?{cp8`kbBt&x!JFu{*s0${rRe2mfMXqM!#ZJj~z*Ra4A0N@_>FFviMgDF4 z?M2w2xs4dwRNmjihiSlFk@pwmAlS{;*;CBJL2k7fXWH4`UUuNqCe8PXgj@@R-Wj*w zIj+%*Ub0f7jNn0#SBB@LzPK5yps!e<(V$nMUvU(|>!b^nR#jC+tD*^*LjMKm|IXdJ z=tQW)KgGpt@N)UHFwms3ZsaScDLlo(KYl8u`t{o%*s#J-#}NVi004BgFB;hzTnGWEN|(dh_QQ|>jagPo%1t2}g2s7A z0eHznD1O;H^A6pPXQeOUxpkfT9{4o2(dh%`Enz%zAh}DOYP-zf!<3=T(o+#;DDciF zZQ*)S&9+Z^B$NEyM_Z1@sNAo!Km6ERfAZ())QiMb`P+#&eyzuc9imqi6crC);qluk z&ZkF0;Vg0om0$OSJPtDNx*%rsh^@dgi?Jm|^B-J)-Lvcu|IBbh0@xN2y6E## z0O0S>Ip;1+$)2^q!r1??+upY(63DQW_Z?|y2tt}%a@*VKmYqBVR^q;h@xRY=b?ebh zinPVoUAjpq`}ToW1)m#j*GPT504~6c-Ch2Fxi{Z8mv;_rZEy3=&|O|$LbU`9#JBGc*D1qU1j4v#V3=a?A`lViCesYRwJyGE8!8r43r1#+0;=?TwWobn8M$(Ol<2NCI z4lW~Wx9G28V=JBKj?`8CE|M>j7)ajU+Uk5mVf+?QO3kNtN(3Qn^e4V0M|}z#I~}{H z`gPW~AM*XlA8IzLC98AwymZ>3J*7WQ%1qMIg&W1fKzUPF_vQl#g+!^WJFj|LH%eHVSz__H-T= z29H97cG(z`KOWJ_D<{DeYKu1Ny`@95Mzzy6cg^qOhA{wGg{9O%(xl;+dABBAGwc(oEGlH$J|CRF^iNO*^WrxPPYVZM^IMOsZBG^;dy_dv zRo8XPl{3Pk@3@lPpUxb+rFWF}Hwohk=0h3*!(Un=u61T5sulirbwn{u6;~EM z1jqAh<|n?MR_5lnOq}~~v!oMuPueE4*KD5^CKhwa1iMTTz5|As z1&(*o`{Vfv@Nl%uG1dL74{S*!U|dV&}}F2c1`}$bT&@EnP#g?aRK;z$nh1rY#)bXqZ0MY+MOrS5;Hv zEmmXepcisNLs)AWNK3bD;v%E$=sz|K&hFF{PxTE1X1i*k ze*KB6u?y9eKW-d6&fa)8%+7>1G4X_C?9vSrGQA?{s#5QTDscf9IPnm9BIUaGfg)ulDo&&O-c_vZ0lZ zrbvpf8qX%rwbUl(=*@LuZnUOhmnNNOIti1G4PSp7ou<2)bE1@1ATGgbofV4IXNsi+ zJAazZgtb*I-_n&0k-0un{1SUxF1jxeVlUb@8Esjyi&cuaw($OtMB1bpb4(Dgce;rT zfByiJ7EUI?+nx2IcO_)`)HwYX#Np(e3L0C&1kRjlb|#Amrn*W%<8SfR;Dpt{q^z{% zTPU}Jp^xap^fJnD0;;>-Cts`h5B2Xek=2zW5S&rYIkjnfVY(nbA0+8 zzM_AR#s9@YHNlr1tu3|o97hw^@P6~u{>i79AH3UYw-VYf+AdnF_&#Vo|1v;HA1SW+ z&2|5A$4If?&c4?xzi=qAE)=DO+`m)Oc^^o|a%%XysV9ERk(uO+Ok)c#-75DRxz|zV zaTlfZ6yzPK0zX`wWWUvHx`tNK@mF{rR&3&RmP#duid~#GtB*=ADrHXg?YvAVZ}R6l zbu+SR_zF;Lg$T5-bhg6dhcmCLO~Ze-zPutYO|;4URIXpu#V=|=Wap)So&WG-+YPql zUZ&V6LVWz?qd{(2GqdHl)_QltVL8oHP}Mv(A1=xR(tEJ_e!Jl}j-P&L3Ec&XaOsou zoo$y#kFJn&8>c2ZJC`fi(naH=e283t7fm|mZj$n#d{ABOWTXLPtEb2Fna^Q)6y7#wyQzAfdqVv3OfiaRVU zOlB#sokFp+tPGz}^yiH~lj46!fC&hmG4T`KyHH}}OgNm{oyrySjB2$aEMo3VmEe28 z_ex7_mvPE_tMGKuswS(x-d%d{tSZ8s1wAI3teBNy=M26gFgu9E0sFjraJgGMK@;3zBE5^&}~wtr9OEH^&`N@)d@xJUcni|o>B3C zB5i=o0hiHK_4XFip43D<20{jLfmE7NdK`3J?cY7g=d>>h71QoE_)_t~$(bzI!0{QTglz=op)6_ka!FT?87 zXV*x06tE)sJ!@r@7}8?gvOno-@KAn$BtJGbX6l?U?GtRqKo`BbDgN)l^wiW+ihcRA zES+bNzh)P@8FA7eR>7pL7v<5WbJNhGbm72Zs=9=P@t*rbfR>&aJ246`u7F3jKFc>T zTjX_k3TYy@s1~jX0Wl(`ZBK6;J=3A+JSsJuX(|>m|nHnuCJ>N9czy^@7E;W}UuiW)Pq zq`iGf=-1B9&ca%?gyye%AK?|A@0$D*)=QK8I}_|MU?8ZJOa;Ygr|9XoM@WeYlEr&w zVkWFo*LB8cq22vjs4eVCU5gN5?0$<0j-(yuCMQV#k$1l2jXt#c2)9}0bkR@T*!SEr zN+xF(TobLv-2U+nBy;z@b>q+VEGj}B5Y3^!-ZkfviGL&Q-k7ucmUeotjVW2WY8>y+ z{X7}rcf<-gXSIS#$jrZsa*m2>0MGl3d= zv|B&+BwxLHsGKHjsr_kpdmD-uY_%QI8lca9o}4TLsRxJov4H_Kl-NV>3YR!f(?MFD zpP%1wD^O08dj7mZk4zaa@$cWi0{eG#E=qoq3JcRj5uqy2*@WjiRc$#vtgm#*s@$2H znbd0iFp&lf$Ab6qlW%_i{``bZ@^5x`cPB4OSK}fn{-(eObLzsPNS-D>!K`xV96Io7 zOBS#twm9$FKQ$kSzYB$tjGCI-=XV4)4{D_UCBQ}~hBGN+??Adm87P#39dR7$}+@PjhEhA5 z!3>mi!85N)Zi-4L%;2e^%t}>a8J4a9G^ET-oSW!Bg3abffRCRRhx&nvcwEOb0Rkj+ zdxwJE>0cIwi8oO(pAHH3n~ujZ29_ne9|Mcuo_vsE5M@-)j%M2mu4|r@$_Rh0bBFY% zT9(Fh5;VXyudUNPgHcrCe-EQ2rIn-`e>Cr&LFX0;87b+~X~#cN#cf9`8D?T&Jf!cC zDm=_osP_%M!a6A7-a>2lp`ftv8o~`V8E(uiNy#>;6x?#zx#I*J3NmF2?Z4$Ii9}*` zemMa{&jr`SzWn0Xvg#Kq>_`(mPoNojuG0M^YUsLAhST{qPclsz($|GUTzmcg57I_z z&DEz>tA%d>LxPkC6_ETL?oqSG$$LRx7?g#tVV$?1EHDCtYSGZ^c?~PDXjLqShzC99 zZ=vPCpfP7>Jslki=$9u*5q)*9{bITT>5>0_=rMH57>iY{R2@sM)dL;w7SNddM9W|7 zD+GHk&;5B`kBtJ!k(tTxWIXzf+J~A++tiXu?Kd(^F>(*fH6-wuc!M!F<(OYXx_f%O zF^q&lSmmdH$ZxvDZd3Lr^3Gg$+)Db>qOmF(coW~HY6(ct%Bm@Zou<$HqSJaH_w{_t z_(W3+%Z#M=4qu~|s0czh+ZM(*V#P*KBChP;*2H{5hZRtR zH9ud(%>akwBCnbka+8*UNhw7D2SJHY%r(tBE`Sr)2S%{1W&HVWUF)*;d{cuT4Gs#gTJ8{Er#ZTAtU5GL^N_#LJvZ|om9p#6q{JPRopNr zWh*&Q27Tk?Bu|+!RuYZpDTAg|I(Wc^_FIqFvRd8u^o708c*r^RKMX|#1fcWqLw+}# zefAdb&C)^RyNHiwF0+$6?In9G#5knnT=2 zgo~8)X5H-I7~z!K=H{DF%XEVRXanj^=>*FR?^w^hlamu9+`e>mP1YNKinG}wt9knO zn0Mi4nZ9K#48&R`DKS8X^B${_$@ibr0hunf`Nrij>dNQc>~jya3Dz_U z7p^Kp{UcEf?dnwnW*!~rLz3qZ^$TrHH?OHFn*D)r4(tUApQR%j;eKEi9 z8&Z(&6_y021N2$AB~~^@Iy>5}nG*{N+r6#D1ce42Tfj25HDH&f} zj0WCt2vR#Cy_k}85r%%Xy}Y~bmU4hX3Dnj+5q+zY00VE{ygA{nP$V!I^8DF7YOa>= zeAuRRU1_#7e03EYMrm#D9-_^fbp|u5E6h8d94MK(rE#_L-E}jyIK*jE!F*TBUvf!Z z_BjL775yaYWosaJ$8YBv?P}&i;DGS(LDuRG3C2?65XLY`cmNLrVfnH(*M$W|ZsU~( zx7?=I6LArJsP_XWq-R6${F=1U!OdW^Un+##>ov+brzZE?ozdOAPb#w<8}&S`i042% zLpCsNL)tzAGolW(9SgdiOzW7BXFo2*9d(f}y0R@r^2rBJ)67xuzGmf44OgM`T(Nz# zc9_WGn$r9d3Ej}TSY5Q8!RF1vL7D$#mr}@$#vaR9Am}1Lj(Jm0&Sq}llje3YoBr~P zPJQK_0mHP1IgwiRo>>Dr(`T9_?kCmb+6(R8T-@A!DT4N3UJKI_dv`1<{VQ|`7(}bD zNAKC_r}u-}nKUdP7JgD&>#FUw)I`lKyBwjCPDQ{iq>!&OC6k z)dFu{rD2eQZOaUusuCzhVw1FZ=s!^!8wj?^KM10cXVZR9$22ZkuJ)B5?+UO_0^))O&A1vzXES$y>m|8}QwsJTL@o9GR*fMP@Cx8t9 zcGF`%sS}402ZlkK*j{ctnroPH?zn4ga%-N!W|`m*%Kh8{;9_)>AOA+lR>017B7_47 z?O^yOzW00~0_o&+1&@6d^V>B0fp0u`ll*8hR?;Pz)SXNWK;}NVZ4U~swzf?5ujKyl z`V@946JD9nYoM@wGm9BUYv`VVnA+Hw{GkQDNpEj2%kwSsic>Wo*u=AZdMRt<_uJmT zIO6Knt5s$_*ca!ceo+vW&3C4XHh!Q_FIVm~Sm+PN-Xwym>2t(}-ndMk_NOhgK5mLunXjW1Rw2%c4UKiV{liIo13 z{V2EE_{9s}@dGcPeBW&eX#izYK)FsRvs`R+2k!iiZPHa&mttb)ud8^?Mo2?&bzpFC z+kqw~)r;j8nUMUnRMnLtOw9gY_x`g0u&SghvFlyS9eMOpIL&sKLOKY5=8xT7#YeE@ zRz0(_@*U!Dh1ATu{9JLmKaV_9OebtDs`UNi<>=s$+tnpGD4!{&;RIL!OcXhs2K5J^#3{w9)$l5RZ0Qy^u1IqKKHCL>hcvqH#qYBht!FFYB4D~e+)r-5r!MojrJ288-v&MT zH{9bZ_ZPkS|6F$bdk7Ut-rWb&Le<35&#yvB>IIW48gp`b@NBL4^j(2d0}&`o^wlo} z{6WBi?28CHm)>Ha@0pi8dguCX5kyW9Vxs6o?Yx5QKbI~;SUUUjx3=!nxJ1MZBJcqb z`uNa;#>dA8z?)S_k0fxHx6YLMG-#u{yQS3rjSnknna=ty9_Am(mFK60ybRV&^KfYf;6T2@Ukjr_v=KMVWoUrp}5WJxG z?)x?jGj-sk;$MaT@U2V^09|x6hxukV>02l~#$g^yw2d!q{&bJ2@HHRFexawK`2ak? z?6#{qU|M4(C27!yYVBvZ2(Sa0_jWG7PFmDTO2l?yQpz((;>>;uwovz&!LoXdZosz2 zrSxrP=6?4IDGT}Sub)3#g>E7K^k$GZ&?3rMnuo1=mX;WvqzLqXGf(EuQo_IsG^0#@ z_>#*Jd<#eC-W#Xd%gk;}MbC(d0LZGEm=FR6gTg$nxupfJ*b~L60g@VZSI9EJx4wDv z21FU}dt^~iDuOom7V(^~IXAtNW0sS0$Ps-cas+Zt00b5IAH39am>H2N!GMPLfU)S8 zr^jB*&d$!#iPwv1EgiET- zaB%?YuDn>rs9`bXlS~|6sekt6O~F-Pw_G*Of2HkvvpxY)5ybh($jC<6m%x z@3DY3bx_fNQrcL(4yY^0n5?msM2@d<;#G2D!kQ&+Gi%C1ov6euw@%krm+|LRUAiSD6 zIgK&rHh(Pob)}LZZlDC@(64(kCMG6d5(RxKe%rj(VX*Qh?gU60fR(?dw*l$Y9_8u+moz8r(Dd=?eFHmUdhf=R`WT= z_2*LU;A00S_S*6tc1#QMjUFE+@&zczDP>IWzY&wRC?8fJrg*2m!`883N3-jyx*7Y! zbNd0yj&H!_BuoeyH}sb0_FV?@U)>IUIXL2pQ7ogF5DIl+qp% z7Z;Zm&~jj5ff-t6L8pvq2=t|U^_};~L*y1VN74ue&){og(4fB`pUW`Ytzk$BpbC+M z`)sxn-M2&B{@J?H>qQ>*JT_?Gj{>vD-@WI4ig3Q*>e}kHvrSs^nPL|FueUXZot^!7 z#~}YNF%d*Hr0$KC!kr@xAz(6nlyw6xae2!RVaI3Bdf6*lrK<6Aeo6YZYl)$$+0j-P z72$xVag)N>BLG6h6Pf8YY0u|*@o%aigqMSJ{RfoxVBkmY4;0Ezo;*ng2f|ehbe`k4 zqFr;XqlQ?nEMFA=0pN@vW7mE+v-<=WwL>+gjX~?*BofE|g0q!bkI zUDR~02}d;_;9&=PoVF|u2b(Ry;$X&t$p_s_sy!Xe{+b>8`}=*l@^Q!l%!6QjY8zf-DMn`Tzf%8FXJquz-Y-f4O|ovWK0YV~Q&9Gp@J-7mmO@G*U!hi7`>XMei9A z@~p+bjpmQ>A>^WyR9flt7&SHZB=Nw{5wJ&_0Z~YWGci5=28IZwm6Vj2QGUD@uK8Rg z1l&%D>jzO^wqq5!0 zQ*|yS?kD$zwIf+O}77w~=nl_nFE$v4gplu&(rB5YY3#wF*-ROD_W zEh{Vg37mQi@OIHQ0x26YdQWWXMCEThbXxM)OFTu4O6k5Aw!pzvH;Lu{T2zR+QiMSa zQofMW657W|>JVJ6Ml@&``B_)t6A4avQ%Y>;nw0#{!QnQoh4^nvLT!szrrNr^{oJv1-)3yMchPEXCjF&9q39Sms~X}1HX2IS0*Wwvb76JAyO$2|NWqv=(?Hay=% zcx-`}KMawd2OF$sJmAE;g2CxCKH48pH9Sr9^}~EFf1!>I@yDac_^2E(npXfpA+!PX zaXt20ena5|&jRve?op65x~5@V|I~O7WJr*|I{#hz{fh?q;Ou3`C9QV3 zVLw#UJy09Sh{mWNMD?wL8?HCt%2ibzoe=0R#RT7mCugMo1J0p{MKRf401&b@E<7}i=`6x>p=&CDLZe#$uszEc2*9U zLEDc~`LCue)0b0k!!#=KVSkPN7Gz~EnsYHN4-FY@`L(_t!TCY_``@_4iOsNQ12D17 zD4t*Ux75`n94y+`QuKrNR{_UwjRF{J-5f(js;{)P$Z|#UyfcO$Rx_pV;P4du3cu0` z(u8fJI(hWxdNWEuyK~#Fv2fp9T=*eU%{tsOgfLqRcFc4r?(qo-s)wz>#oTovJ@JOd zg^ke?!bU4}WAWjcYl=9dYpEiZGC@H>OC=#i{&_-??=Ta7;|{mOFt)!K%9ek z1<^?wQO}13zrI-g7;_NALG&a;j8?fI4%|Ub&_)?o8>h$>2=`>FNJC9a3#*3*kP~9< zPI>sUTn{zDW~QE(C7j+vgyr&b2>y&&!&f-r&eDg)4h+t}ejks902mou=c7$C+z4p? znRBX_ZN8b|Ck9+7GjS+U+N5P#@d4DkHZY?SIp&}g0;d~@LaaxS3=_fH8AJH01JEmC zUgUx_@nRGuWoE_*V|ZkdACx_AWBh>mbEVy`&5Zki_&1JdsVJwkc{%*+CSkw+YcJnF9wL3}`*OD6v=W;}j0 zZQ1}l*R6M;0wW$L=O|-FrBOM<0MykQhWh#nQj>;wdee8lbohz)J>`9B)o-;C9E=@n z7(D)kj8IB}0FZDMB>@y>fH-pKa*AJx9=%-ofCEtjGxrEfIaR0&v>c{3ucQPiEwZ+V zv`=qq1PRw3NZm3DhI)(;cp<(vNc>PUNdoK0qLl1!!53=Ea}8h-yo!|HWCS7OlaqnK zX!0k02y@i-aTB5e8@0eo5@7ege`|^>c=BGx4y8r=+=o+l3#9JxiHWDdFMF1GkhU#V z)pv=TvA8~Pc%fLBVB*Oc=Z_;QU0`>1hx(6$&#)lQH~Qi$WH7z8ApJ*KWo41DkzUp? z1OGYG3K-6As2O9cu&ADE)^pK`dc_wgp`_FeZE7#I{>Cs_fEC#0*)u^~19kQ8p@@YA zV-0Ou_(SMm(a!lc?s=WTpHl7*|I$lx#u&NRB)n+yvoulhA?3vExLJJ0w}Sm!uM_d< zWnZ*Smzd2B#(Bmz;v{3;QLG;MQjD6LGM!!_h~a+=%OIdKxQZ^eQ|YjRNH? z@F+rx1y=*$E(oLlXKO2Z`^~YTiEw_;9YT&YpDEEb%(3zD0C<8oh>&Lj$p#S2r!RTB zPgm;F?|#ZGan-R!;qI*qPruXHPf{%^CdJa+SJYBEN^3TdY@eKAlzopUY%H7+I!+$l zH;kDZc$M%e1S{PdD}19@dv%Dvr7=4j1KJipm~+TKo^!%Pdp|6LYNcgo%$*2&JH*Vp zUGbev+g{t6c)`oyHDNHma({{H*PCVHJ0%At-mF)(3K#X}+4rZb2C+iCCA405+NOgC z8?hTBP!y79#`w2y5%4;|ZJC*w*(oqlwh_HT^?uJXr|rU+_R9Il=5gdf&~z%+iT<>$ z`oGe2GeK7s;EdBF*o@f>XA<>1VusQV zsyAL99)I{P&^I9!b+EU)`v|e&U%g5M&U#HvX(Y$;Xfst(aQs125#nb?V48duTg6(>%bgST34^T)mdDmt=3fx$muPA9zKB`G z3L?1ISJ{IvWj7jwPCX}i$UN)B5Q<6pLOS*^KIE@f@a8weGlDeu507nRWLa)=1s{WGd#MKU%6Zn51 z_~Gen)F6OGKpofy7$y>c;`ROQK_leUiN_swaE6d8kda|RB&`@c@YTPU`+a#y#$iPo z1uHPKh2tN1T5{naWrF5Cn8e-J(0;<{z0PhT2~AGE|1K9{Hq{w6yvIeTj>r+%26R~H zzq9CnozTV*Y9kPa_@x@=-KqWH&wxoJ7}+a-2D}wa=~yPD6!#SY(dGaoC0LmD_Y_uA z3q#M!#`gq+Cm81=+phU<>a_@sY+TE)RWw|m>Mxtn{LGGvi_2?>iN&s6+6iM_wnXocFk+8`a-ql=4StDk>^e*b@u@e%PIl zjoUC_C}P^k%i~Z5cS#3<&#!U*M{RDSVTAa`)DhCCB)_c=pBUgF%4fJQ$GyqW3LpD~ z>OvXwd=#lc2VF3JuAadJ^k9f_;5H*bT$WCYKBtITt`FjA6*S<~d}~PINUPw}PEzW#BTdE8 z-A1D(a;RrsA;nrkqwyb3SU;O;zzsr8eb*JNxhY4HTYM0Evky|Z2k6!|!zqGiaQi>O zlBGS$Hu*S~s>$tK07KbTRWT6vw-J*#U;|)~C%|{m)X|X<+s_P<@gp?DApZwqLoTGA zp%lSzsOoKXAJmL)=0Kr-4CwF<>Gdr5F`{&Yz>r3wLqozCeC8%7Cjs2^dLCK)@@f|CA$o+1ZmD2~-FGTad_-ym_1191n_WR=o#vta^SoS@VP)y)G8S|XsZvdH9 zx@;ACWf$Viu6#kPoq-=^x_7A-tVW z;Ktb)LV?G?24WY;4^nVxkhgaG_H9iQljwl~1-ga3ZZN%MLRL{q6Hb7RNkIXDOlaGM zRycmkSIDnZ$rvfdn@S>eMr9P^ji2^(7XX)t+U7XwzHBxtljQjG9d z@a*Fd9)f8Hj954R8$00dBotxFWAj;s4p0v;xMO4QJhR`Wc9KWRtkczqJ%?urr0zNN zEz3bysT)^IY+Wk5M&ZI6W^?8^d548o6m)+MSx7bga(ISwVSwRQk~{4f0Z-OR2(O2S z2M*wI!&Q3kcNyT}g#NEo_@+@!E}O{Fhgdb7dsFKUTEVFnZd#i$w`1FhHX9q;%__Q# zaEkA)!Z4)4+>sg#m%U(GkOl|7B1vPjRD=2fF{&Bh9=I8+%_z?RmMB)=R-Cskrxw@V zdq_h728mD><!PaC@n!#ux^s4Em;U^@3w+ zP7qdGV$C{m>ijy^f}yzJH9Akvzt|Ht4B0PK?HN-;kN;4iO(^GTJdyx|lanRx7;?9+`4a6|8b9fyy_LZ);Z2!JMG3QdJL@B1;Z|0 zj+ckI*|q7$IWYS`AK+`~vM+ct>n)|=r@|nhnn3QP F-^2PIC}Tt%U@vcc4Ry|o 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 z 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 h8 - Android Cast
Send screen (tablet / phone) Receive on TV / projector - Same Wi‑Fi required. Default PIN: 1234. Match transport (TCP/UDP) on both devices. + Same Wi‑Fi required. Default PIN: 1234. UDP is default; pick TCP in settings if needed. Match transport on both devices. Cast to receiver Waiting for cast PIN - Refresh + REFRESH + REFRESH (%1$d) Start cast Searching for receivers… Waiting for sender… @@ -25,8 +26,16 @@ Video only This device runs Android 9 or lower. Playback audio capture needs Android 10 or newer. You can continue with video only. Continue (video only) - TCP (reliable) - UDP (best effort) + UDP (default, best effort) + TCP (reliable fallback) + QUIC experimental (Cronet) + WebRTC (stub, future) + Calibration test pattern + Camera (rear Camera2) + Camera source (requires Android 10+) + Camera capture is not implemented yet. Use screen or calibration test. + Calibration cast %1$dx%2$d + Camera cast %1$dx%2$d Low (~1.5 Mbps, 24 fps) Medium (~5 Mbps, 30 fps) — recommended High (~10 Mbps, 30 fps) diff --git a/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java new file mode 100644 index 0000000..7f54b08 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/CastResolutionTest.java @@ -0,0 +1,17 @@ +package com.foxx.androidcast; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CastResolutionTest { + + @Test + public void calibrationTestSizeIsLandscape16x9() { + CastResolution.Size size = CastResolution.calibrationTestSize(); + assertEquals(960, size.width); + assertEquals(540, size.height); + assertTrue(size.width > size.height); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java b/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java new file mode 100644 index 0000000..f2d9a9b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/EncodedStreamProbeTest.java @@ -0,0 +1,16 @@ +package com.foxx.androidcast.media; + +import static org.junit.Assert.assertTrue; + +import android.media.MediaFormat; + +import org.junit.Test; + +public class EncodedStreamProbeTest { + @Test + public void hevcKeyframeReportsNalType() { + byte[] vps = {0, 0, 0, 1, 0x40, 0x01, 0x0c, 0x01}; + String desc = EncodedStreamProbe.describeKeyframe(MediaFormat.MIMETYPE_VIDEO_HEVC, vps); + assertTrue(desc.contains("HEVC")); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java b/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java new file mode 100644 index 0000000..88bcb03 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/H264BitstreamHevcTest.java @@ -0,0 +1,16 @@ +package com.foxx.androidcast.media; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class H264BitstreamHevcTest { + @Test + public void avccHevcNalGetsStartCode() { + byte[] vps = {0, 0, 0, 4, 0x40, 0x01, 0x0c, 0x01}; + byte[] annexB = H264Bitstream.toAnnexB(vps); + assertTrue(annexB[0] == 0 && annexB[1] == 0 && annexB[2] == 0 && annexB[3] == 1); + assertEquals(0x40, annexB[4] & 0xff); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java b/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java new file mode 100644 index 0000000..a70c014 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/RgbToNv21Test.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.media; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class RgbToNv21Test { + + @Test + public void whitePixelProducesHighLuma() { + int[] argb = {0xFFFFFFFF, 0, 0, 0xFFFFFFFF}; + byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2); + int y = nv21[0] & 0xff; + assertTrue(y > 220); + } + + @Test + public void blackPixelProducesLowLuma() { + int[] argb = {0xFF000000, 0, 0, 0xFF000000}; + byte[] nv21 = RgbToNv21.fromArgb(argb, 2, 2); + int y = nv21[0] & 0xff; + assertEquals(16, y); + } + + @Test + public void bufferSizeMatchesNv21Layout() { + int w = 16; + int h = 16; + int[] argb = new int[w * h]; + byte[] nv21 = RgbToNv21.fromArgb(argb, w, h); + assertEquals(w * h + w * h / 2, nv21.length); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java b/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java new file mode 100644 index 0000000..7d50095 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/media/RgbToYuv420Test.java @@ -0,0 +1,25 @@ +package com.foxx.androidcast.media; + +import org.junit.Test; + +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertEquals; + +public class RgbToYuv420Test { + + @Test + public void redPixelDiffersBetweenYAndChromaPlanes() { + int[] argb = {0xFFFF0000, 0, 0, 0xFFFF0000}; + byte[] i420 = RgbToYuv420.fromArgb(argb, 2, 2, RgbToYuv420.Layout.I420); + int y = i420[0] & 0xff; + int u = i420[4] & 0xff; + int v = i420[5] & 0xff; + assertNotEquals(y, u); + assertNotEquals(y, v); + } + + @Test + public void bufferSizeMatchesI420Layout() { + assertEquals(960 * 540 + (960 * 540) / 2, RgbToYuv420.bufferSize(960, 540)); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java b/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java new file mode 100644 index 0000000..0481fd4 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/CastProtoHeaderTest.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.network; + +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class CastProtoHeaderTest { + + @Test + public void encodeDecodeRoundTrip() throws Exception { + CastProtoHeader original = CastProtoHeader.forCast(0x12345678, 0xABCDEF01, 0x0F, 0xF0); + byte[] wire = original.encode(); + CastProtoHeader decoded = CastProtoHeader.decode(wire, 0, wire.length); + assertEquals(original.sessionId, decoded.sessionId); + assertEquals(original.placeholderId, decoded.placeholderId); + assertEquals(original.videoCodecs, decoded.videoCodecs); + assertEquals(original.audioCodecs, decoded.audioCodecs); + assertTrue(decoded.verifyChecksum()); + } + + @Test + public void frameUnframePreservesPayload() throws Exception { + CastProtoHeader hdr = CastProtoHeader.forCast(99, 1, 2, 3); + byte[] payload = new byte[] {1, 2, 3, 4}; + byte[] wire = CastPacketFramer.frame(hdr, (byte) 7, payload); + CastProtoHeader fromWire = CastPacketFramer.readHeader(wire); + assertEquals(99, fromWire.sessionId); + CastProtocol.Message msg = CastPacketFramer.unframe(wire); + assertEquals(7, msg.type); + assertArrayEquals(payload, msg.payload); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java b/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java new file mode 100644 index 0000000..9a0dca5 --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/network/CastSessionGateTest.java @@ -0,0 +1,49 @@ +package com.foxx.androidcast.network; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CastSessionGateTest { + + @Test + public void bindsFirstNonZeroSession() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader first = new CastProtoHeader(); + first.sessionId = 42; + assertTrue(gate.accept(first)); + assertEquals(42, gate.getBoundSessionId()); + + CastProtoHeader stale = new CastProtoHeader(); + stale.sessionId = 99; + assertFalse(gate.accept(stale)); + + CastProtoHeader same = new CastProtoHeader(); + same.sessionId = 42; + assertTrue(gate.accept(same)); + } + + @Test + public void zeroSessionAlwaysAccepted() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader zero = new CastProtoHeader(); + zero.sessionId = 0; + assertTrue(gate.accept(zero)); + assertFalse(gate.isBound()); + } + + @Test + public void resetAllowsRebind() { + CastSessionGate gate = new CastSessionGate(); + CastProtoHeader a = new CastProtoHeader(); + a.sessionId = 1; + gate.accept(a); + gate.reset(); + CastProtoHeader b = new CastProtoHeader(); + b.sessionId = 2; + assertTrue(gate.accept(b)); + assertEquals(2, gate.getBoundSessionId()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java new file mode 100644 index 0000000..fd79f1d --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/stats/SessionStatsStoreTest.java @@ -0,0 +1,42 @@ +package com.foxx.androidcast.stats; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.foxx.androidcast.CastConfig; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; + +public class SessionStatsStoreTest { + + @Test + public void pruneDirectory_keepsAtMostMaxMinusOne() throws IOException { + File dir = Files.createTempDirectory("session_stats_test").toFile(); + int max = CastConfig.SESSION_STATS_MAX_FILES; + for (int i = 0; i < max + 5; i++) { + File f = new File(dir, String.format("session_20260101_%06d.json", i)); + assertTrue(f.createNewFile()); + Files.write(f.toPath(), "{}".getBytes(StandardCharsets.UTF_8)); + } + assertEquals(max + 5, dir.list((d, n) -> n.endsWith(".json")).length); + + SessionStatsStore.pruneDirectory(dir, max); + + String[] remaining = dir.list((d, n) -> n.endsWith(".json")); + Arrays.sort(remaining); + assertEquals(max - 1, remaining.length); + assertEquals("session_20260101_000006.json", remaining[0]); + assertEquals("session_20260101_000024.json", remaining[remaining.length - 1]); + + for (File f : dir.listFiles()) { + assertTrue(f.delete()); + } + assertTrue(dir.delete()); + } +} diff --git a/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java new file mode 100644 index 0000000..d03b14b --- /dev/null +++ b/app/src/test/java/com/foxx/androidcast/ui/PlaybackTextureTransformTest.java @@ -0,0 +1,34 @@ +package com.foxx.androidcast.ui; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PlaybackTextureTransformTest { + @Test + public void wideVideoOnWideViewIsUnityScale() { + float[] scales = scalesFor(1920, 1080, 960, 540); + assertEquals(1f, scales[0], 0.01f); + assertEquals(1f, scales[1], 0.01f); + } + + @Test + public void tallVideoOnWideViewLetterboxesHorizontally() { + float[] scales = scalesFor(1920, 1080, 540, 960); + assertEquals(0.316f, scales[0], 0.02f); + assertEquals(1f, scales[1], 0.01f); + } + + private static float[] scalesFor(int viewW, int viewH, int videoW, int videoH) { + float videoAspect = (float) videoW / videoH; + float viewAspect = (float) viewW / viewH; + float scaleX = 1f; + float scaleY = 1f; + if (videoAspect > viewAspect) { + scaleY = viewAspect / videoAspect; + } else { + scaleX = videoAspect / viewAspect; + } + return new float[] {scaleX, scaleY}; + } +}