diff --git a/app/build.gradle b/app/build.gradle index 26d5f84c1e..a67c804797 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,53 +5,31 @@ android { compileSdk 34 - namespace 'com.limelight' + namespace 'com.winhanced.wincaster' defaultConfig { minSdk 21 targetSdk 34 - versionName "12.1" - versionCode = 314 + applicationId "com.winhanced.wincaster" + + versionName "1.0.0" + versionCode = 1 // Generate native debug symbols to allow Google Play to symbolicate our native crashes ndk.debugSymbolLevel = 'FULL' - } - - flavorDimensions.add("root") - - buildFeatures { - buildConfig = true - } - productFlavors { - root { - // Android O has native mouse capture, so don't show the rooted - // version to devices running O on the Play Store. - maxSdk 25 - - externalNativeBuild { - ndkBuild { - arguments "PRODUCT_FLAVOR=root" - } + externalNativeBuild { + ndkBuild { + arguments "PRODUCT_FLAVOR=nonRoot" } - - applicationId "com.limelight.root" - dimension "root" - buildConfigField "boolean", "ROOT_BUILD", "true" } - nonRoot { - externalNativeBuild { - ndkBuild { - arguments "PRODUCT_FLAVOR=nonRoot" - } - } + buildConfigField "boolean", "ROOT_BUILD", "false" + } - applicationId "com.limelight" - dimension "root" - buildConfigField "boolean", "ROOT_BUILD", "false" - } + buildFeatures { + buildConfig = true } compileOptions { @@ -82,46 +60,13 @@ android { buildTypes { debug { applicationIdSuffix ".debug" - resValue "string", "app_label", "Moonlight (Debug)" - resValue "string", "app_label_root", "Moonlight (Root Debug)" + resValue "string", "app_label", "WinCaster (Debug)" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } release { - // To whomever is releasing/using an APK in release mode with - // Moonlight's official application ID, please stop. I see every - // single one of your crashes in my Play Console and it makes - // Moonlight's reliability look worse and makes it more difficult - // to distinguish real crashes from your crashy VR app. Seriously, - // 44 of the *same* native crash in 72 hours and a few each of - // several other crashes. - // - // This is technically not your fault. I would have hoped Google - // would validate the signature of the APK before attributing - // the crash to it. I asked their Play Store support about this - // and they said they don't and don't have plans to, so that sucks. - // - // In any case, it's bad form to release an APK using someone - // else's application ID. There is no legitimate reason, that - // anyone would need to comment out the following line, except me - // when I release an official signed Moonlight build. If you feel - // like doing so would solve something, I can tell you it will not. - // You can't upgrade an app while retaining data without having the - // same signature as the official version. Nor can you post it on - // the Play Store, since that application ID is already taken. - // Reputable APK hosting websites similarly validate the signature - // is consistent with the Play Store and won't allow an APK that - // isn't signed the same as the original. - // - // I wish any and all people using Moonlight as the basis of other - // cool projects the best of luck with their efforts. All I ask - // is to please change the applicationId before you publish. - // - // TL;DR: Leave the following line alone! - applicationIdSuffix ".unofficial" - resValue "string", "app_label", "Moonlight" - resValue "string", "app_label_root", "Moonlight (Root)" + resValue "string", "app_label", "WinCaster" minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7be9a20bf1..2131681ef3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,11 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + = Build.VERSION_CODES.O) { + startForegroundService(winCasterServiceIntent); + } else { + startService(winCasterServiceIntent); + } + + // Start WinCaster mDNS advertiser + winCasterAdvertiser = new WinCasterAdvertiser(this); + winCasterAdvertiser.start(); } private void startComputerUpdates() { @@ -297,6 +312,11 @@ public void onDestroy() { if (managerBinder != null) { unbindService(serviceConnection); } + + // Stop WinCaster mDNS advertiser + if (winCasterAdvertiser != null) { + winCasterAdvertiser.stop(); + } } @Override diff --git a/app/src/main/java/com/limelight/computers/ComputerManagerService.java b/app/src/main/java/com/limelight/computers/ComputerManagerService.java index 990a8953f2..93056f1b4c 100644 --- a/app/src/main/java/com/limelight/computers/ComputerManagerService.java +++ b/app/src/main/java/com/limelight/computers/ComputerManagerService.java @@ -290,6 +290,16 @@ public ComputerDetails getComputer(String uuid) { return null; } + public java.util.List getComputers() { + java.util.List computers = new java.util.ArrayList<>(); + synchronized (pollingTuples) { + for (PollingTuple tuple : pollingTuples) { + computers.add(tuple.computer); + } + } + return computers; + } + public void invalidateStateForComputer(String uuid) { synchronized (pollingTuples) { for (PollingTuple tuple : pollingTuples) { diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java b/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java new file mode 100644 index 0000000000..c07b49f957 --- /dev/null +++ b/app/src/main/java/com/limelight/wincaster/WinCasterAdvertiser.java @@ -0,0 +1,245 @@ +package com.limelight.wincaster; + +import android.content.Context; +import android.net.nsd.NsdManager; +import android.net.nsd.NsdServiceInfo; +import android.net.wifi.WifiManager; +import android.os.Build; + +import com.limelight.LimeLog; + +import java.io.IOException; +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceInfo; + +public class WinCasterAdvertiser { + private static final String TAG = "WinCasterAdvertiser"; + private static final String SERVICE_TYPE = "_wincaster._tcp."; + private static final String SERVICE_NAME = "WinCaster"; + private static final int SERVICE_PORT = 47990; + private static final String VERSION = "1.0.0"; + + private final Context context; + private final AtomicBoolean isAdvertising = new AtomicBoolean(false); + + // NsdManager implementation (Android 14+) + private NsdManager nsdManager; + private NsdManager.RegistrationListener registrationListener; + + // JmDNS implementation (older Android versions) + private JmDNS jmdns; + private WifiManager.MulticastLock multicastLock; + private Thread jmdnsThread; + + public WinCasterAdvertiser(Context context) { + this.context = context.getApplicationContext(); + } + + public void start() { + if (isAdvertising.getAndSet(true)) { + LimeLog.info(TAG + ": Already advertising"); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // Use NsdManager on Android 14+ (API 34) + startNsdAdvertising(); + } else { + // Use JmDNS on older versions + startJmdnsAdvertising(); + } + } + + public void stop() { + if (!isAdvertising.getAndSet(false)) { + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + stopNsdAdvertising(); + } else { + stopJmdnsAdvertising(); + } + } + + private void startNsdAdvertising() { + LimeLog.info(TAG + ": Starting NsdManager advertising"); + + nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); + if (nsdManager == null) { + LimeLog.warning(TAG + ": NsdManager not available"); + isAdvertising.set(false); + return; + } + + NsdServiceInfo serviceInfo = new NsdServiceInfo(); + serviceInfo.setServiceName(getServiceName()); + serviceInfo.setServiceType(SERVICE_TYPE); + serviceInfo.setPort(SERVICE_PORT); + + // Add TXT records + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + serviceInfo.setAttribute("name", Build.MODEL); + serviceInfo.setAttribute("version", VERSION); + serviceInfo.setAttribute("platform", "android"); + } + + registrationListener = new NsdManager.RegistrationListener() { + @Override + public void onServiceRegistered(NsdServiceInfo serviceInfo) { + LimeLog.info(TAG + ": Service registered: " + serviceInfo.getServiceName()); + } + + @Override + public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + LimeLog.warning(TAG + ": Registration failed: " + errorCode); + isAdvertising.set(false); + } + + @Override + public void onServiceUnregistered(NsdServiceInfo serviceInfo) { + LimeLog.info(TAG + ": Service unregistered"); + } + + @Override + public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) { + LimeLog.warning(TAG + ": Unregistration failed: " + errorCode); + } + }; + + try { + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener); + } catch (Exception e) { + LimeLog.warning(TAG + ": Failed to register service: " + e.getMessage()); + isAdvertising.set(false); + } + } + + private void stopNsdAdvertising() { + LimeLog.info(TAG + ": Stopping NsdManager advertising"); + + if (nsdManager != null && registrationListener != null) { + try { + nsdManager.unregisterService(registrationListener); + } catch (Exception e) { + LimeLog.warning(TAG + ": Failed to unregister service: " + e.getMessage()); + } + } + nsdManager = null; + registrationListener = null; + } + + private void startJmdnsAdvertising() { + LimeLog.info(TAG + ": Starting JmDNS advertising"); + + jmdnsThread = new Thread(() -> { + try { + // Acquire multicast lock + WifiManager wifi = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + if (wifi != null) { + multicastLock = wifi.createMulticastLock("WinCasterAdvertiser"); + multicastLock.setReferenceCounted(true); + multicastLock.acquire(); + } + + // Get local IP address + InetAddress localAddress = getLocalIpAddress(); + if (localAddress == null) { + LimeLog.warning(TAG + ": Could not determine local IP address"); + isAdvertising.set(false); + return; + } + + // Create JmDNS instance + jmdns = JmDNS.create(localAddress, getServiceName()); + + // Create TXT record map + Map txtRecords = new HashMap<>(); + txtRecords.put("name", Build.MODEL); + txtRecords.put("version", VERSION); + txtRecords.put("platform", "android"); + + // Register service + ServiceInfo serviceInfo = ServiceInfo.create( + SERVICE_TYPE + "local.", + getServiceName(), + SERVICE_PORT, + 0, // weight + 0, // priority + txtRecords + ); + + jmdns.registerService(serviceInfo); + LimeLog.info(TAG + ": JmDNS service registered"); + + } catch (IOException e) { + LimeLog.warning(TAG + ": JmDNS error: " + e.getMessage()); + isAdvertising.set(false); + } + }); + jmdnsThread.start(); + } + + private void stopJmdnsAdvertising() { + LimeLog.info(TAG + ": Stopping JmDNS advertising"); + + if (jmdns != null) { + try { + jmdns.unregisterAllServices(); + jmdns.close(); + } catch (IOException e) { + LimeLog.warning(TAG + ": Error closing JmDNS: " + e.getMessage()); + } + jmdns = null; + } + + if (multicastLock != null && multicastLock.isHeld()) { + multicastLock.release(); + multicastLock = null; + } + + if (jmdnsThread != null) { + jmdnsThread.interrupt(); + jmdnsThread = null; + } + } + + private String getServiceName() { + // Use device model as service name, sanitized for DNS + String name = Build.MODEL.replaceAll("[^a-zA-Z0-9\\-]", "-"); + if (name.length() > 63) { + name = name.substring(0, 63); + } + return SERVICE_NAME + "-" + name; + } + + private InetAddress getLocalIpAddress() { + try { + WifiManager wifiManager = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + if (wifiManager != null) { + int ipAddress = wifiManager.getConnectionInfo().getIpAddress(); + if (ipAddress != 0) { + byte[] bytes = new byte[]{ + (byte) (ipAddress & 0xff), + (byte) (ipAddress >> 8 & 0xff), + (byte) (ipAddress >> 16 & 0xff), + (byte) (ipAddress >> 24 & 0xff) + }; + return InetAddress.getByAddress(bytes); + } + } + } catch (Exception e) { + LimeLog.warning(TAG + ": Error getting local IP: " + e.getMessage()); + } + return null; + } + + public boolean isAdvertising() { + return isAdvertising.get(); + } +} diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java b/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java new file mode 100644 index 0000000000..3502968860 --- /dev/null +++ b/app/src/main/java/com/limelight/wincaster/WinCasterBootReceiver.java @@ -0,0 +1,51 @@ +package com.limelight.wincaster; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +import com.limelight.LimeLog; + +public class WinCasterBootReceiver extends BroadcastReceiver { + private static final String TAG = "WinCasterBootReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + + String action = intent.getAction(); + LimeLog.info(TAG + ": Received action: " + action); + + switch (action) { + case Intent.ACTION_BOOT_COMPLETED: + case Intent.ACTION_LOCKED_BOOT_COMPLETED: + case "android.intent.action.QUICKBOOT_POWERON": + case "com.htc.intent.action.QUICKBOOT_POWERON": + startWinCasterService(context); + break; + default: + LimeLog.info(TAG + ": Ignoring unknown action: " + action); + break; + } + } + + private void startWinCasterService(Context context) { + LimeLog.info(TAG + ": Starting WinCasterCommandService on boot"); + + Intent serviceIntent = new Intent(context, WinCasterCommandService.class); + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent); + } else { + context.startService(serviceIntent); + } + LimeLog.info(TAG + ": WinCasterCommandService started successfully"); + } catch (Exception e) { + LimeLog.warning(TAG + ": Failed to start WinCasterCommandService: " + e.getMessage()); + } + } +} diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java b/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java new file mode 100644 index 0000000000..ebe676a046 --- /dev/null +++ b/app/src/main/java/com/limelight/wincaster/WinCasterCommandService.java @@ -0,0 +1,374 @@ +package com.limelight.wincaster; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; + +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.PcView; +import com.limelight.R; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.security.cert.CertificateEncodingException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class WinCasterCommandService extends Service { + private static final String TAG = "WinCasterCommandService"; + private static final int TCP_PORT = 47990; + private static final String NOTIFICATION_CHANNEL_ID = "wincaster_service"; + private static final int NOTIFICATION_ID = 1; + + private ServerSocket serverSocket; + private ExecutorService executorService; + private volatile boolean isRunning = false; + private ComputerManagerService.ComputerManagerBinder managerBinder; + private boolean isBound = false; + + private final IBinder binder = new LocalBinder(); + + public class LocalBinder extends Binder { + public WinCasterCommandService getService() { + return WinCasterCommandService.this; + } + } + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + managerBinder = (ComputerManagerService.ComputerManagerBinder) service; + isBound = true; + LimeLog.info(TAG + ": Connected to ComputerManagerService"); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + managerBinder = null; + isBound = false; + LimeLog.info(TAG + ": Disconnected from ComputerManagerService"); + } + }; + + @Override + public void onCreate() { + super.onCreate(); + LimeLog.info(TAG + ": Service created"); + + createNotificationChannel(); + startForeground(NOTIFICATION_ID, createNotification()); + + // Bind to ComputerManagerService + Intent intent = new Intent(this, ComputerManagerService.class); + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); + + executorService = Executors.newCachedThreadPool(); + startServer(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + LimeLog.info(TAG + ": Service started"); + return START_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onDestroy() { + super.onDestroy(); + LimeLog.info(TAG + ": Service destroyed"); + + stopServer(); + + if (isBound) { + unbindService(serviceConnection); + isBound = false; + } + + if (executorService != null) { + executorService.shutdownNow(); + } + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "WinCaster Remote Control", + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription("WinCaster remote control service"); + NotificationManager manager = getSystemService(NotificationManager.class); + if (manager != null) { + manager.createNotificationChannel(channel); + } + } + } + + private Notification createNotification() { + Intent notificationIntent = new Intent(this, PcView.class); + PendingIntent pendingIntent = PendingIntent.getActivity( + this, 0, notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(this, NOTIFICATION_CHANNEL_ID); + } else { + builder = new Notification.Builder(this); + } + + return builder + .setContentTitle("WinCaster") + .setContentText("Remote control service running") + .setSmallIcon(R.drawable.ic_pc) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build(); + } + + private void startServer() { + isRunning = true; + executorService.submit(() -> { + try { + serverSocket = new ServerSocket(TCP_PORT); + LimeLog.info(TAG + ": TCP server listening on port " + TCP_PORT); + + while (isRunning) { + try { + Socket clientSocket = serverSocket.accept(); + LimeLog.info(TAG + ": Client connected from " + clientSocket.getInetAddress()); + executorService.submit(() -> handleClient(clientSocket)); + } catch (SocketException e) { + if (isRunning) { + LimeLog.warning(TAG + ": Socket exception: " + e.getMessage()); + } + } + } + } catch (IOException e) { + LimeLog.warning(TAG + ": Failed to start server: " + e.getMessage()); + } + }); + } + + private void stopServer() { + isRunning = false; + if (serverSocket != null && !serverSocket.isClosed()) { + try { + serverSocket.close(); + } catch (IOException e) { + LimeLog.warning(TAG + ": Error closing server socket: " + e.getMessage()); + } + } + } + + private void handleClient(Socket clientSocket) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + PrintWriter writer = new PrintWriter(new OutputStreamWriter(clientSocket.getOutputStream()), true)) { + + clientSocket.setSoTimeout(30000); // 30 second timeout + + String line; + while ((line = reader.readLine()) != null) { + LimeLog.info(TAG + ": Received: " + line); + String response = processCommand(line); + writer.println(response); + LimeLog.info(TAG + ": Sent: " + response); + } + } catch (IOException e) { + LimeLog.warning(TAG + ": Client handling error: " + e.getMessage()); + } finally { + try { + clientSocket.close(); + } catch (IOException e) { + LimeLog.warning(TAG + ": Error closing client socket: " + e.getMessage()); + } + } + } + + private String processCommand(String jsonCommand) { + try { + JSONObject request = new JSONObject(jsonCommand); + String command = request.optString("command", ""); + + switch (command) { + case "ping": + return createResponse("pong", null); + + case "status": + return getStatus(); + + case "stream": + return startStream(request); + + case "stop": + return stopStream(); + + default: + return createErrorResponse("Unknown command: " + command); + } + } catch (JSONException e) { + return createErrorResponse("Invalid JSON: " + e.getMessage()); + } + } + + private String getStatus() { + try { + JSONObject response = new JSONObject(); + response.put("status", "ready"); + response.put("clientName", Build.MODEL); + response.put("version", "1.0.0"); + response.put("platform", "android"); + return response.toString(); + } catch (JSONException e) { + return createErrorResponse("Failed to create status response"); + } + } + + private String startStream(JSONObject request) { + if (!isBound || managerBinder == null) { + return createErrorResponse("ComputerManagerService not available"); + } + + String host = request.optString("host", ""); + String hostUUID = request.optString("hostUUID", ""); + String hostName = request.optString("hostName", ""); + String appId = request.optString("appId", ""); + + if (host.isEmpty()) { + return createErrorResponse("Missing 'host' parameter"); + } + + // Wait for the manager to be ready + managerBinder.waitForReady(); + + // Find the computer by UUID or add a new one + ComputerDetails computer = null; + + if (!hostUUID.isEmpty()) { + computer = managerBinder.getComputer(hostUUID); + } + + if (computer == null) { + // Computer not found, we need to add it + return createErrorResponse("Host not found. Please pair the host first using the app."); + } + + if (computer.pairState != com.limelight.nvstream.http.PairingManager.PairState.PAIRED) { + return createErrorResponse("Host is not paired. Please pair the host first using the app."); + } + + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + return createErrorResponse("Host is offline"); + } + + // Create an NvApp for the stream + int appIdInt = 0; + if (!appId.isEmpty()) { + try { + appIdInt = Integer.parseInt(appId); + } catch (NumberFormatException e) { + // If appId is "Desktop" or non-numeric, use the running game or default to Desktop + if (computer.runningGameId != 0) { + appIdInt = computer.runningGameId; + } + } + } else if (computer.runningGameId != 0) { + appIdInt = computer.runningGameId; + } + + NvApp app = new NvApp(appId.isEmpty() ? "Desktop" : appId, appIdInt, false); + + // Create intent to start streaming + Intent intent = new Intent(this, Game.class); + intent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); + intent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); + intent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); + intent.putExtra(Game.EXTRA_APP_NAME, app.getAppName()); + intent.putExtra(Game.EXTRA_APP_ID, app.getAppId()); + intent.putExtra(Game.EXTRA_APP_HDR, app.isHdrSupported()); + intent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); + intent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); + intent.putExtra(Game.EXTRA_PC_NAME, computer.name); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + try { + if (computer.serverCert != null) { + intent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); + } + } catch (CertificateEncodingException e) { + LimeLog.warning(TAG + ": Failed to encode server certificate: " + e.getMessage()); + } + + startActivity(intent); + + try { + JSONObject response = new JSONObject(); + response.put("status", "streaming"); + response.put("clientName", Build.MODEL); + response.put("host", computer.name); + return response.toString(); + } catch (JSONException e) { + return createErrorResponse("Stream started but failed to create response"); + } + } + + private String stopStream() { + // Send broadcast to stop the stream + Intent intent = new Intent("com.limelight.STOP_STREAM"); + sendBroadcast(intent); + + return createResponse("stopped", null); + } + + private String createResponse(String status, String message) { + try { + JSONObject response = new JSONObject(); + response.put("status", status); + if (message != null) { + response.put("message", message); + } + return response.toString(); + } catch (JSONException e) { + return "{\"status\":\"error\",\"message\":\"Failed to create response\"}"; + } + } + + private String createErrorResponse(String message) { + try { + JSONObject response = new JSONObject(); + response.put("status", "error"); + response.put("message", message); + return response.toString(); + } catch (JSONException e) { + return "{\"status\":\"error\",\"message\":\"" + message + "\"}"; + } + } +} diff --git a/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java b/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java new file mode 100644 index 0000000000..a8d839b4bd --- /dev/null +++ b/app/src/main/java/com/limelight/wincaster/WinCasterDeepLinkActivity.java @@ -0,0 +1,188 @@ +package com.limelight.wincaster; + +import android.app.Activity; +import android.app.Service; +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.widget.Toast; + +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.computers.ComputerManagerService; +import com.limelight.nvstream.http.ComputerDetails; +import com.limelight.nvstream.http.NvApp; +import com.limelight.nvstream.http.PairingManager; + +import java.security.cert.CertificateEncodingException; + +public class WinCasterDeepLinkActivity extends Activity { + private static final String TAG = "WinCasterDeepLink"; + + private ComputerManagerService.ComputerManagerBinder managerBinder; + private String host; + private String uuid; + private String name; + private String app; + + private final ServiceConnection serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName className, IBinder binder) { + final ComputerManagerService.ComputerManagerBinder localBinder = + (ComputerManagerService.ComputerManagerBinder) binder; + + // Wait in a separate thread to avoid stalling the UI + new Thread(() -> { + localBinder.waitForReady(); + managerBinder = localBinder; + runOnUiThread(() -> processDeepLink()); + }).start(); + } + + @Override + public void onServiceDisconnected(ComponentName className) { + managerBinder = null; + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + Uri data = intent.getData(); + + if (data == null) { + LimeLog.warning(TAG + ": No URI data provided"); + Toast.makeText(this, "Invalid deep link", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + LimeLog.info(TAG + ": Processing deep link: " + data.toString()); + + // Parse query parameters + host = data.getQueryParameter("host"); + uuid = data.getQueryParameter("uuid"); + name = data.getQueryParameter("name"); + app = data.getQueryParameter("app"); + + if (host == null && uuid == null) { + LimeLog.warning(TAG + ": Missing host or uuid parameter"); + Toast.makeText(this, "Missing host or uuid parameter", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + // Bind to ComputerManagerService + bindService(new Intent(this, ComputerManagerService.class), + serviceConnection, Service.BIND_AUTO_CREATE); + } + + private void processDeepLink() { + if (managerBinder == null) { + Toast.makeText(this, getString(R.string.error_manager_not_running), Toast.LENGTH_LONG).show(); + finish(); + return; + } + + // Find the computer + ComputerDetails computer = null; + + if (uuid != null && !uuid.isEmpty()) { + computer = managerBinder.getComputer(uuid); + } + + if (computer == null && host != null && !host.isEmpty()) { + // Try to find by host address + for (ComputerDetails details : managerBinder.getComputers()) { + if (details.activeAddress != null && host.equals(details.activeAddress.address)) { + computer = details; + break; + } + if (details.localAddress != null && host.equals(details.localAddress.address)) { + computer = details; + break; + } + if (details.remoteAddress != null && host.equals(details.remoteAddress.address)) { + computer = details; + break; + } + } + } + + if (computer == null) { + LimeLog.warning(TAG + ": Host not found in paired computers"); + Toast.makeText(this, getString(R.string.scut_pc_not_found), Toast.LENGTH_LONG).show(); + finish(); + return; + } + + if (computer.pairState != PairingManager.PairState.PAIRED) { + LimeLog.warning(TAG + ": Host is not paired"); + Toast.makeText(this, getString(R.string.scut_not_paired), Toast.LENGTH_LONG).show(); + finish(); + return; + } + + if (computer.state == ComputerDetails.State.OFFLINE || computer.activeAddress == null) { + LimeLog.warning(TAG + ": Host is offline"); + Toast.makeText(this, getString(R.string.error_pc_offline), Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + // Determine app ID + int appId = 0; + if (app != null && !app.isEmpty()) { + try { + appId = Integer.parseInt(app); + } catch (NumberFormatException e) { + // Non-numeric app ID, use running game or 0 + if (computer.runningGameId != 0) { + appId = computer.runningGameId; + } + } + } else if (computer.runningGameId != 0) { + appId = computer.runningGameId; + } + + NvApp nvApp = new NvApp(app != null ? app : "Desktop", appId, false); + + // Start streaming + Intent streamIntent = new Intent(this, Game.class); + streamIntent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); + streamIntent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); + streamIntent.putExtra(Game.EXTRA_HTTPS_PORT, computer.httpsPort); + streamIntent.putExtra(Game.EXTRA_APP_NAME, nvApp.getAppName()); + streamIntent.putExtra(Game.EXTRA_APP_ID, nvApp.getAppId()); + streamIntent.putExtra(Game.EXTRA_APP_HDR, nvApp.isHdrSupported()); + streamIntent.putExtra(Game.EXTRA_UNIQUEID, managerBinder.getUniqueId()); + streamIntent.putExtra(Game.EXTRA_PC_UUID, computer.uuid); + streamIntent.putExtra(Game.EXTRA_PC_NAME, computer.name); + + try { + if (computer.serverCert != null) { + streamIntent.putExtra(Game.EXTRA_SERVER_CERT, computer.serverCert.getEncoded()); + } + } catch (CertificateEncodingException e) { + LimeLog.warning(TAG + ": Failed to encode server certificate: " + e.getMessage()); + } + + LimeLog.info(TAG + ": Starting stream to " + computer.name); + startActivity(streamIntent); + finish(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (managerBinder != null) { + unbindService(serviceConnection); + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9cc2215aaa..bb5ba92404 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,8 @@ + + WinCaster + PC deleted PC not paired