From 31156b94e68e18b226a63d3dbcc173bd9ae79d64 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:25:13 +0000
Subject: [PATCH] feat: Add SHAREit-style Home screen and H264 sender
Add unified HomeActivity with Send and Receive buttons.
Implement SenderActivity, ScreenCaptureService with MediaProjection and
H.264 MediaCodec encoder (720p 3 Mbps 30fps), and LibreCastClient.
Integrate ZXing QR scanning for target address input.
Co-authored-by: ClausValcaTD <193546948+ClausValcaTD@users.noreply.github.com>
---
app/build.gradle | 3 +
app/src/main/AndroidManifest.xml | 24 +-
.../com/m5dev/librecast/HomeActivity.java | 35 ++
.../com/m5dev/librecast/LibreCastClient.java | 96 +++++
.../m5dev/librecast/ScreenCaptureService.java | 352 ++++++++++++++++++
.../com/m5dev/librecast/SenderActivity.java | 220 +++++++++++
app/src/main/res/drawable/button_focused.xml | 7 +
app/src/main/res/drawable/button_normal.xml | 7 +
app/src/main/res/drawable/button_selector.xml | 6 +
app/src/main/res/drawable/input_field_bg.xml | 7 +
app/src/main/res/layout/activity_home.xml | 71 ++++
app/src/main/res/layout/activity_sender.xml | 80 ++++
app/src/main/res/values/colors.xml | 6 +
app/src/main/res/values/strings.xml | 27 +-
.../m5dev/librecast/LibreCastClientTest.java | 58 +++
15 files changed, 996 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/com/m5dev/librecast/HomeActivity.java
create mode 100644 app/src/main/java/com/m5dev/librecast/LibreCastClient.java
create mode 100644 app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java
create mode 100644 app/src/main/java/com/m5dev/librecast/SenderActivity.java
create mode 100644 app/src/main/res/drawable/button_focused.xml
create mode 100644 app/src/main/res/drawable/button_normal.xml
create mode 100644 app/src/main/res/drawable/button_selector.xml
create mode 100644 app/src/main/res/drawable/input_field_bg.xml
create mode 100644 app/src/main/res/layout/activity_home.xml
create mode 100644 app/src/main/res/layout/activity_sender.xml
create mode 100644 app/src/test/java/com/m5dev/librecast/LibreCastClientTest.java
diff --git a/app/build.gradle b/app/build.gradle
index c7583c6..332c5fa 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -33,6 +33,9 @@ dependencies {
implementation 'androidx.core:core:1.6.0'
implementation 'org.java-websocket:Java-WebSocket:1.5.4'
implementation 'com.google.zxing:core:3.3.3'
+ implementation('com.journeyapps:zxing-android-embedded:3.6.0') {
+ exclude group: 'com.android.support'
+ }
testImplementation 'junit:junit:4.13.2'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f2ff5f5..1224a98 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
+
+
@@ -29,9 +38,22 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/com/m5dev/librecast/HomeActivity.java b/app/src/main/java/com/m5dev/librecast/HomeActivity.java
new file mode 100644
index 0000000..8ed7c8d
--- /dev/null
+++ b/app/src/main/java/com/m5dev/librecast/HomeActivity.java
@@ -0,0 +1,35 @@
+package com.m5dev.librecast;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import androidx.appcompat.app.AppCompatActivity;
+
+public class HomeActivity extends AppCompatActivity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_home);
+
+ Button btnReceive = findViewById(R.id.btn_receive);
+ Button btnSend = findViewById(R.id.btn_send);
+
+ btnReceive.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(HomeActivity.this, MainActivity.class);
+ startActivity(intent);
+ }
+ });
+
+ btnSend.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ Intent intent = new Intent(HomeActivity.this, SenderActivity.class);
+ startActivity(intent);
+ }
+ });
+ }
+}
diff --git a/app/src/main/java/com/m5dev/librecast/LibreCastClient.java b/app/src/main/java/com/m5dev/librecast/LibreCastClient.java
new file mode 100644
index 0000000..dc0e1c3
--- /dev/null
+++ b/app/src/main/java/com/m5dev/librecast/LibreCastClient.java
@@ -0,0 +1,96 @@
+package com.m5dev.librecast;
+
+import android.util.Log;
+import org.java_websocket.client.WebSocketClient;
+import org.java_websocket.handshake.ServerHandshake;
+
+import java.net.URI;
+
+public class LibreCastClient extends WebSocketClient {
+
+ private static final String TAG = "LibreCastClient";
+
+ public interface ClientListener {
+ void onConnected();
+ void onDisconnected();
+ void onError(Exception ex);
+ }
+
+ private ClientListener listener;
+
+ public LibreCastClient(URI serverUri) {
+ super(serverUri);
+ }
+
+ public LibreCastClient(URI serverUri, ClientListener listener) {
+ super(serverUri);
+ this.listener = listener;
+ }
+
+ public void setListener(ClientListener listener) {
+ this.listener = listener;
+ }
+
+ @Override
+ public void onOpen(ServerHandshake handshakedata) {
+ logI(TAG, "WebSocket connected to " + getURI());
+ if (listener != null) {
+ listener.onConnected();
+ }
+ }
+
+ @Override
+ public void onMessage(String message) {
+ logD(TAG, "Received message: " + message);
+ }
+
+ @Override
+ public void onClose(int code, String reason, boolean remote) {
+ logI(TAG, "WebSocket closed: " + reason + " (code: " + code + ")");
+ if (listener != null) {
+ listener.onDisconnected();
+ }
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ logE(TAG, "WebSocket error", ex);
+ if (listener != null) {
+ listener.onError(ex);
+ }
+ }
+
+ public void sendFrame(byte[] data) {
+ if (isOpen() && data != null && data.length > 0) {
+ try {
+ send(data);
+ } catch (Exception e) {
+ logE(TAG, "Error sending frame over WebSocket", e);
+ }
+ }
+ }
+
+ private static void logI(String tag, String msg) {
+ try {
+ Log.i(tag, msg);
+ } catch (Throwable t) {
+ System.out.println("[" + tag + "] " + msg);
+ }
+ }
+
+ private static void logD(String tag, String msg) {
+ try {
+ Log.d(tag, msg);
+ } catch (Throwable t) {
+ System.out.println("[" + tag + "] " + msg);
+ }
+ }
+
+ private static void logE(String tag, String msg, Throwable t) {
+ try {
+ Log.e(tag, msg, t);
+ } catch (Throwable ignored) {
+ System.err.println("[" + tag + "] " + msg);
+ }
+ }
+}
diff --git a/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java b/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java
new file mode 100644
index 0000000..921e9cb
--- /dev/null
+++ b/app/src/main/java/com/m5dev/librecast/ScreenCaptureService.java
@@ -0,0 +1,352 @@
+package com.m5dev.librecast;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.hardware.display.DisplayManager;
+import android.hardware.display.VirtualDisplay;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.media.projection.MediaProjection;
+import android.media.projection.MediaProjectionManager;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Surface;
+import androidx.core.app.NotificationCompat;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+
+public class ScreenCaptureService extends Service implements LibreCastClient.ClientListener {
+
+ private static final String TAG = "ScreenCaptureService";
+
+ public static final String ACTION_START = "com.m5dev.librecast.action.START_CAST";
+ public static final String ACTION_STOP = "com.m5dev.librecast.action.STOP_CAST";
+ public static final String ACTION_STATUS_CHANGED = "com.m5dev.librecast.action.CAST_STATUS_CHANGED";
+
+ public static final String EXTRA_RESULT_CODE = "extra_result_code";
+ public static final String EXTRA_RESULT_DATA = "extra_result_data";
+ public static final String EXTRA_TARGET_URI = "extra_target_uri";
+
+ public static final String EXTRA_IS_CASTING = "extra_is_casting";
+ public static final String EXTRA_STATUS_MESSAGE = "extra_status_message";
+
+ private static final String CHANNEL_ID = "LibreCastCaptureChannel";
+ private static final int NOTIFICATION_ID = 1001;
+
+ private static boolean isRunning = false;
+
+ private MediaProjection mediaProjection;
+ private MediaCodec encoder;
+ private VirtualDisplay virtualDisplay;
+ private LibreCastClient client;
+ private Thread encoderThread;
+
+ private volatile boolean isEncoding = false;
+
+ public static boolean isCasting() {
+ return isRunning;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ createNotificationChannel();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null && intent.getAction() != null) {
+ String action = intent.getAction();
+ if (ACTION_START.equals(action)) {
+ int resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0);
+ Intent resultData = intent.getParcelableExtra(EXTRA_RESULT_DATA);
+ String targetUri = intent.getStringExtra(EXTRA_TARGET_URI);
+
+ if (resultCode != 0 && resultData != null && targetUri != null) {
+ startForegroundWithNotification();
+ startCasting(resultCode, resultData, targetUri);
+ } else {
+ Log.e(TAG, "Invalid parameters for starting cast");
+ stopSelf();
+ }
+ } else if (ACTION_STOP.equals(action)) {
+ stopCasting();
+ }
+ }
+ return START_NOT_STICKY;
+ }
+
+ private void startForegroundWithNotification() {
+ Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("LibreCast")
+ .setContentText("Screen casting in progress...")
+ .setSmallIcon(android.R.drawable.ic_menu_camera)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .build();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION);
+ } else {
+ startForeground(NOTIFICATION_ID, notification);
+ }
+ }
+
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID,
+ "LibreCast Screen Capture",
+ NotificationManager.IMPORTANCE_LOW
+ );
+ NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (manager != null) {
+ manager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ private synchronized void startCasting(int resultCode, Intent resultData, String targetUriStr) {
+ if (isRunning) {
+ return;
+ }
+ isRunning = true;
+ notifyStatus(true, "Connecting to target TV...");
+
+ try {
+ URI uri = new URI(targetUriStr);
+ client = new LibreCastClient(uri, this);
+ client.connect();
+
+ MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+ if (projectionManager != null) {
+ mediaProjection = projectionManager.getMediaProjection(resultCode, resultData);
+ }
+
+ if (mediaProjection == null) {
+ Log.e(TAG, "Failed to obtain MediaProjection");
+ stopCasting();
+ return;
+ }
+
+ setupEncoderAndVirtualDisplay();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Error starting cast", e);
+ stopCasting();
+ }
+ }
+
+ private void setupEncoderAndVirtualDisplay() {
+ try {
+ DisplayMetrics metrics = getResources().getDisplayMetrics();
+ int width = 1280;
+ int height = 720;
+ if (metrics.widthPixels < metrics.heightPixels) {
+ width = 720;
+ height = 1280;
+ }
+ int densityDpi = metrics.densityDpi > 0 ? metrics.densityDpi : DisplayMetrics.DENSITY_HIGH;
+
+ MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
+ format.setInteger(MediaFormat.KEY_BIT_RATE, 3000000); // 3 Mbps
+ format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); // 30 fps
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); // 1 sec
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline);
+ format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31);
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ format.setInteger(MediaFormat.KEY_LATENCY, 0);
+ }
+
+ encoder = MediaCodec.createEncoderByType("video/avc");
+ encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ Surface inputSurface = encoder.createInputSurface();
+ encoder.start();
+
+ virtualDisplay = mediaProjection.createVirtualDisplay(
+ "LibreCastCapture",
+ width, height, densityDpi,
+ DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
+ inputSurface,
+ null, null
+ );
+
+ isEncoding = true;
+ encoderThread = new Thread(new Runnable() {
+ @Override
+ public void run() {
+ encoderLoop();
+ }
+ }, "ScreenCaptureEncoderThread");
+ encoderThread.start();
+
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to setup MediaCodec encoder or VirtualDisplay", e);
+ stopCasting();
+ }
+ }
+
+ private void encoderLoop() {
+ ByteBuffer[] outputBuffers = null;
+ try {
+ outputBuffers = encoder.getOutputBuffers();
+ } catch (Exception e) {
+ Log.e(TAG, "Error getting output buffers", e);
+ return;
+ }
+
+ MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+
+ while (isEncoding && !Thread.currentThread().isInterrupted()) {
+ try {
+ int outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000); // 10ms
+ if (outputBufferIndex >= 0) {
+ if (bufferInfo.size > 0) {
+ ByteBuffer outBuffer = outputBuffers[outputBufferIndex];
+ outBuffer.position(bufferInfo.offset);
+ outBuffer.limit(bufferInfo.offset + bufferInfo.size);
+
+ byte[] data = new byte[bufferInfo.size];
+ outBuffer.get(data);
+
+ if (client != null && client.isOpen()) {
+ client.sendFrame(data);
+ }
+ }
+ encoder.releaseOutputBuffer(outputBufferIndex, false);
+ } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ outputBuffers = encoder.getOutputBuffers();
+ } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ MediaFormat newFormat = encoder.getOutputFormat();
+ Log.d(TAG, "Encoder format changed: " + newFormat);
+ if (newFormat.containsKey("csd-0")) {
+ ByteBuffer sps = newFormat.getByteBuffer("csd-0");
+ if (sps != null && client != null && client.isOpen()) {
+ byte[] spsBytes = new byte[sps.remaining()];
+ sps.get(spsBytes);
+ sps.rewind();
+ client.sendFrame(spsBytes);
+ }
+ }
+ if (newFormat.containsKey("csd-1")) {
+ ByteBuffer pps = newFormat.getByteBuffer("csd-1");
+ if (pps != null && client != null && client.isOpen()) {
+ byte[] ppsBytes = new byte[pps.remaining()];
+ pps.get(ppsBytes);
+ pps.rewind();
+ client.sendFrame(ppsBytes);
+ }
+ }
+ }
+ } catch (Exception e) {
+ if (isEncoding) {
+ Log.e(TAG, "Error during encoding loop", e);
+ }
+ break;
+ }
+ }
+ }
+
+ private synchronized void stopCasting() {
+ isEncoding = false;
+ if (encoderThread != null) {
+ encoderThread.interrupt();
+ try {
+ encoderThread.join(500);
+ } catch (InterruptedException ignored) {
+ }
+ encoderThread = null;
+ }
+
+ if (virtualDisplay != null) {
+ try {
+ virtualDisplay.release();
+ } catch (Exception ignored) {
+ }
+ virtualDisplay = null;
+ }
+
+ if (encoder != null) {
+ try {
+ encoder.stop();
+ } catch (Exception ignored) {
+ }
+ try {
+ encoder.release();
+ } catch (Exception ignored) {
+ }
+ encoder = null;
+ }
+
+ if (mediaProjection != null) {
+ try {
+ mediaProjection.stop();
+ } catch (Exception ignored) {
+ }
+ mediaProjection = null;
+ }
+
+ if (client != null) {
+ try {
+ client.close();
+ } catch (Exception ignored) {
+ }
+ client = null;
+ }
+
+ isRunning = false;
+ notifyStatus(false, "Cast stopped");
+ stopForeground(true);
+ stopSelf();
+ }
+
+ private void notifyStatus(boolean isCasting, String message) {
+ Intent intent = new Intent(ACTION_STATUS_CHANGED);
+ intent.putExtra(EXTRA_IS_CASTING, isCasting);
+ intent.putExtra(EXTRA_STATUS_MESSAGE, message);
+ sendBroadcast(intent);
+ }
+
+ // LibreCastClient.ClientListener
+
+ @Override
+ public void onConnected() {
+ Log.i(TAG, "Connected to receiver WebSocket");
+ notifyStatus(true, "Connected! Streaming screen...");
+ }
+
+ @Override
+ public void onDisconnected() {
+ Log.i(TAG, "Disconnected from receiver WebSocket");
+ stopCasting();
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ Log.e(TAG, "LibreCastClient error", ex);
+ notifyStatus(isRunning, "WebSocket error: " + ex.getMessage());
+ }
+
+ @Override
+ public void onDestroy() {
+ stopCasting();
+ super.onDestroy();
+ }
+}
diff --git a/app/src/main/java/com/m5dev/librecast/SenderActivity.java b/app/src/main/java/com/m5dev/librecast/SenderActivity.java
new file mode 100644
index 0000000..5de94ad
--- /dev/null
+++ b/app/src/main/java/com/m5dev/librecast/SenderActivity.java
@@ -0,0 +1,220 @@
+package com.m5dev.librecast;
+
+import android.Manifest;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.media.projection.MediaProjectionManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+import android.widget.Toast;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
+
+import com.google.zxing.integration.android.IntentIntegrator;
+import com.google.zxing.integration.android.IntentResult;
+
+public class SenderActivity extends AppCompatActivity {
+
+ private static final int REQUEST_CODE_SCREEN_CAPTURE = 2001;
+ private static final int REQUEST_CODE_CAMERA_PERMISSION = 2002;
+
+ private EditText editTargetAddress;
+ private Button btnScanQr;
+ private Button btnToggleCast;
+ private TextView textCastStatus;
+
+ private final BroadcastReceiver statusReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent != null && ScreenCaptureService.ACTION_STATUS_CHANGED.equals(intent.getAction())) {
+ boolean isCasting = intent.getBooleanExtra(ScreenCaptureService.EXTRA_IS_CASTING, false);
+ String statusMsg = intent.getStringExtra(ScreenCaptureService.EXTRA_STATUS_MESSAGE);
+ updateUIState(isCasting, statusMsg);
+ }
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_sender);
+
+ editTargetAddress = findViewById(R.id.edit_target_address);
+ btnScanQr = findViewById(R.id.btn_scan_qr);
+ btnToggleCast = findViewById(R.id.btn_toggle_cast);
+ textCastStatus = findViewById(R.id.text_cast_status);
+
+ // Check compatibility for screen capture (< Android 5.0)
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ showUnsupportedVersionDialog();
+ }
+
+ btnScanQr.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ checkCameraPermissionAndScan();
+ }
+ });
+
+ btnToggleCast.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (ScreenCaptureService.isCasting()) {
+ stopCastService();
+ } else {
+ startCastService();
+ }
+ }
+ });
+
+ updateUIState(ScreenCaptureService.isCasting(), null);
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ IntentFilter filter = new IntentFilter(ScreenCaptureService.ACTION_STATUS_CHANGED);
+ registerReceiver(statusReceiver, filter);
+ updateUIState(ScreenCaptureService.isCasting(), null);
+ }
+
+ @Override
+ protected void onPause() {
+ super.onPause();
+ try {
+ unregisterReceiver(statusReceiver);
+ } catch (Exception ignored) {
+ }
+ }
+
+ private void showUnsupportedVersionDialog() {
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.unsupported_version_title)
+ .setMessage(R.string.unsupported_version_message)
+ .setPositiveButton(R.string.btn_ok, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ finish();
+ }
+ })
+ .setCancelable(false)
+ .show();
+ }
+
+ private void checkCameraPermissionAndScan() {
+ if (!getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
+ Toast.makeText(this, "Camera not available on this device", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
+ ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQUEST_CODE_CAMERA_PERMISSION);
+ return;
+ }
+ }
+ startQRScanner();
+ }
+
+ private void startQRScanner() {
+ IntentIntegrator integrator = new IntentIntegrator(this);
+ integrator.setPrompt("Scan TV QR Code");
+ integrator.setOrientationLocked(false);
+ integrator.setBeepEnabled(true);
+ integrator.initiateScan();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == REQUEST_CODE_CAMERA_PERMISSION) {
+ if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+ startQRScanner();
+ } else {
+ Toast.makeText(this, R.string.camera_permission_required, Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+
+ private void startCastService() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ showUnsupportedVersionDialog();
+ return;
+ }
+
+ String targetAddress = editTargetAddress.getText().toString().trim();
+ if (targetAddress.isEmpty() || (!targetAddress.startsWith("ws://") && !targetAddress.startsWith("wss://"))) {
+ Toast.makeText(this, R.string.invalid_address, Toast.LENGTH_LONG).show();
+ return;
+ }
+
+ MediaProjectionManager projectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+ if (projectionManager != null) {
+ startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE);
+ }
+ }
+
+ private void stopCastService() {
+ Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
+ serviceIntent.setAction(ScreenCaptureService.ACTION_STOP);
+ startService(serviceIntent);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) {
+ if (resultCode == RESULT_OK && data != null) {
+ String targetAddress = editTargetAddress.getText().toString().trim();
+ Intent serviceIntent = new Intent(this, ScreenCaptureService.class);
+ serviceIntent.setAction(ScreenCaptureService.ACTION_START);
+ serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, resultCode);
+ serviceIntent.putExtra(ScreenCaptureService.EXTRA_RESULT_DATA, data);
+ serviceIntent.putExtra(ScreenCaptureService.EXTRA_TARGET_URI, targetAddress);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(serviceIntent);
+ } else {
+ startService(serviceIntent);
+ }
+ } else {
+ Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show();
+ }
+ } else {
+ IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
+ if (result != null) {
+ if (result.getContents() != null) {
+ editTargetAddress.setText(result.getContents());
+ }
+ } else {
+ super.onActivityResult(requestCode, resultCode, data);
+ }
+ }
+ }
+
+ private void updateUIState(boolean isCasting, String statusMsg) {
+ if (isCasting) {
+ btnToggleCast.setText(R.string.btn_stop_cast);
+ btnToggleCast.setTextColor(getResources().getColor(R.color.accent_red));
+ editTargetAddress.setEnabled(false);
+ btnScanQr.setEnabled(false);
+ textCastStatus.setText(statusMsg != null ? statusMsg : getString(R.string.casting_active));
+ } else {
+ btnToggleCast.setText(R.string.btn_start_cast);
+ btnToggleCast.setTextColor(getResources().getColor(R.color.accent_green));
+ editTargetAddress.setEnabled(true);
+ btnScanQr.setEnabled(true);
+ textCastStatus.setText(statusMsg != null ? statusMsg : getString(R.string.casting_stopped));
+ }
+ }
+}
diff --git a/app/src/main/res/drawable/button_focused.xml b/app/src/main/res/drawable/button_focused.xml
new file mode 100644
index 0000000..a52846d
--- /dev/null
+++ b/app/src/main/res/drawable/button_focused.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/button_normal.xml b/app/src/main/res/drawable/button_normal.xml
new file mode 100644
index 0000000..cb6324e
--- /dev/null
+++ b/app/src/main/res/drawable/button_normal.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/button_selector.xml b/app/src/main/res/drawable/button_selector.xml
new file mode 100644
index 0000000..90ec8e0
--- /dev/null
+++ b/app/src/main/res/drawable/button_selector.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/input_field_bg.xml b/app/src/main/res/drawable/input_field_bg.xml
new file mode 100644
index 0000000..6f215e5
--- /dev/null
+++ b/app/src/main/res/drawable/input_field_bg.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml
new file mode 100644
index 0000000..eabb42b
--- /dev/null
+++ b/app/src/main/res/layout/activity_home.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_sender.xml b/app/src/main/res/layout/activity_sender.xml
new file mode 100644
index 0000000..1802efb
--- /dev/null
+++ b/app/src/main/res/layout/activity_sender.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 98b8c93..82b3009 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -1,5 +1,11 @@
+
#121212
+ #1E1E2C
+ #2A2A3D
+ #3D5AFE
+ #00E676
+ #FF5252
#FFFFFF
#B0BEC5
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 519dec6..ddc3072 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,10 +1,33 @@
- LibreCast Receiver
- LibreCast Receiver
+ LibreCast
+ LibreCast
IP: 0.0.0.0
Starting server...
Server stopped
Ready: ws://%1$s:%2$d
Waiting for connection...
Client Connected: %1$s
+
+
+ LibreCast
+ Unified Wireless Screen Sharing
+ استقبال\nReceive
+ إرسال\nSend
+
+
+ LibreCast Sender
+ Target TV Address (e.g. ws://192.168.1. :8080)
+ ws://192.168.1. :8080
+ Scan TV QR
+ Start Cast
+ Stop Cast
+
+
+ Android Version Unsupported
+ Screen casting requires Android 5.0 (Lollipop) or higher.
+ OK
+ Please enter a valid target WebSocket address (e.g. ws://192.168.1.10:8080)
+ Camera permission is required to scan QR code.
+ Casting in progress...
+ Cast stopped.
diff --git a/app/src/test/java/com/m5dev/librecast/LibreCastClientTest.java b/app/src/test/java/com/m5dev/librecast/LibreCastClientTest.java
new file mode 100644
index 0000000..ba4d1eb
--- /dev/null
+++ b/app/src/test/java/com/m5dev/librecast/LibreCastClientTest.java
@@ -0,0 +1,58 @@
+package com.m5dev.librecast;
+
+import org.junit.Test;
+
+import java.net.URI;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.Assert.*;
+
+public class LibreCastClientTest {
+
+ @Test
+ public void testClientInitializationAndCallbacks() throws Exception {
+ URI testUri = new URI("ws://127.0.0.1:8080");
+ AtomicBoolean connected = new AtomicBoolean(false);
+ AtomicBoolean disconnected = new AtomicBoolean(false);
+ AtomicBoolean errorOccurred = new AtomicBoolean(false);
+
+ LibreCastClient client = new LibreCastClient(testUri, new LibreCastClient.ClientListener() {
+ @Override
+ public void onConnected() {
+ connected.set(true);
+ }
+
+ @Override
+ public void onDisconnected() {
+ disconnected.set(true);
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ errorOccurred.set(true);
+ }
+ });
+
+ assertEquals(testUri, client.getURI());
+
+ // Simulate callbacks
+ client.onOpen(null);
+ assertTrue("onConnected should be invoked", connected.get());
+
+ client.onClose(1000, "Normal closure", false);
+ assertTrue("onDisconnected should be invoked", disconnected.get());
+
+ client.onError(new Exception("Test error"));
+ assertTrue("onError should be invoked", errorOccurred.get());
+ }
+
+ @Test
+ public void testSendFrameWhenClosedDoesNotThrow() throws Exception {
+ URI testUri = new URI("ws://127.0.0.1:8080");
+ LibreCastClient client = new LibreCastClient(testUri);
+
+ byte[] frameData = new byte[]{0x00, 0x00, 0x00, 0x01, 0x67};
+ // Should handle safely without throwing WebsocketNotConnectedException
+ client.sendFrame(frameData);
+ }
+}