package com.limelight.nvstream.http; import android.os.Build; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.io.StringReader; import java.net.Inet4Address; import java.net.InetAddress; import java.net.Proxy; import java.net.Socket; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.Principal; import java.security.PrivateKey; import java.security.SecureRandom; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.LinkedList; import java.util.ListIterator; import java.util.Stack; import java.util.UUID; import java.util.concurrent.TimeUnit; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509KeyManager; import javax.net.ssl.X509TrustManager; import org.xmlpull.v1.XmlPullParser; import org.xmlpull.v1.XmlPullParserException; import org.xmlpull.v1.XmlPullParserFactory; import com.limelight.BuildConfig; import com.limelight.LimeLog; import com.limelight.nvstream.ConnectionContext; import com.limelight.nvstream.http.PairingManager.PairState; import com.limelight.nvstream.jni.MoonBridge; import okhttp3.ConnectionPool; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; public class NvHTTP { private String uniqueId; private PairingManager pm; private static final int DEFAULT_HTTPS_PORT = 47984; public static final int DEFAULT_HTTP_PORT = 47989; public static final int SHORT_CONNECTION_TIMEOUT = 3000; public static final int LONG_CONNECTION_TIMEOUT = 5000; public static final int READ_TIMEOUT = 7000; // Print URL and content to logcat on debug builds private static boolean verbose = BuildConfig.DEBUG; private HttpUrl baseUrlHttp; private int httpsPort; private OkHttpClient httpClientLongConnectTimeout; private OkHttpClient httpClientLongConnectNoReadTimeout; private OkHttpClient httpClientShortConnectTimeout; private X509TrustManager defaultTrustManager; private X509TrustManager trustManager; private X509KeyManager keyManager; private X509Certificate serverCert; void setServerCert(X509Certificate serverCert) { this.serverCert = serverCert; } private static X509TrustManager getDefaultTrustManager() { try { TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init((KeyStore) null); for (TrustManager tm : tmf.getTrustManagers()) { if (tm instanceof X509TrustManager) { return (X509TrustManager) tm; } } } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } catch (KeyStoreException e) { throw new RuntimeException(e); } throw new IllegalStateException("No X509 trust manager found"); } private void initializeHttpState(final LimelightCryptoProvider cryptoProvider) { keyManager = new X509KeyManager() { public String chooseClientAlias(String[] keyTypes, Principal[] issuers, Socket socket) { return "Limelight-RSA"; } public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) { return null; } public X509Certificate[] getCertificateChain(String alias) { return new X509Certificate[] {cryptoProvider.getClientCertificate()}; } public String[] getClientAliases(String keyType, Principal[] issuers) { return null; } public PrivateKey getPrivateKey(String alias) { return cryptoProvider.getClientPrivateKey(); } public String[] getServerAliases(String keyType, Principal[] issuers) { return null; } }; defaultTrustManager = getDefaultTrustManager(); trustManager = new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } public void checkClientTrusted(X509Certificate[] certs, String authType) { throw new IllegalStateException("Should never be called"); } public void checkServerTrusted(X509Certificate[] certs, String authType) throws CertificateException { try { // Try the default trust manager first to allow pairing with certificates // that chain up to a trusted root CA. This will raise CertificateException // if the certificate is not trusted (expected for GFE's self-signed certs). defaultTrustManager.checkServerTrusted(certs, authType); } catch (CertificateException e) { // Check the server certificate if we've paired to this host if (certs.length == 1 && NvHTTP.this.serverCert != null) { if (!certs[0].equals(NvHTTP.this.serverCert)) { throw new CertificateException("Certificate mismatch"); } } else { // The cert chain doesn't look like a self-signed cert or we don't have // a certificate pinned, so re-throw the original validation error. throw e; } } } }; HostnameVerifier hv = new HostnameVerifier() { public boolean verify(String hostname, SSLSession session) { try { Certificate[] certificates = session.getPeerCertificates(); if (certificates.length == 1 && certificates[0].equals(NvHTTP.this.serverCert)) { // Allow any hostname if it's our pinned cert return true; } } catch (SSLPeerUnverifiedException e) { e.printStackTrace(); } // Fall back to default HostnameVerifier for validating CA-issued certs return HttpsURLConnection.getDefaultHostnameVerifier().verify(hostname, session); } }; httpClientLongConnectTimeout = new OkHttpClient.Builder() .connectionPool(new ConnectionPool(0, 1, TimeUnit.MILLISECONDS)) .hostnameVerifier(hv) .readTimeout(READ_TIMEOUT, TimeUnit.MILLISECONDS) .connectTimeout(LONG_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) .proxy(Proxy.NO_PROXY) .build(); httpClientShortConnectTimeout = httpClientLongConnectTimeout.newBuilder() .connectTimeout(SHORT_CONNECTION_TIMEOUT, TimeUnit.MILLISECONDS) .build(); httpClientLongConnectNoReadTimeout = httpClientLongConnectTimeout.newBuilder() .readTimeout(0, TimeUnit.MILLISECONDS) .build(); } public HttpUrl getHttpsUrl(boolean likelyOnline) throws IOException { if (httpsPort == 0) { // Fetch the HTTPS port if we don't have it already httpsPort = getHttpsPort(openHttpConnectionToString(likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout, baseUrlHttp, "serverinfo")); } return new HttpUrl.Builder().scheme("https").host(baseUrlHttp.host()).port(httpsPort).build(); } public NvHTTP(ComputerDetails.AddressTuple address, int httpsPort, String uniqueId, X509Certificate serverCert, LimelightCryptoProvider cryptoProvider) throws IOException { // Use the same UID for all Moonlight clients so we can quit games // started by other Moonlight clients. this.uniqueId = "0123456789ABCDEF"; this.serverCert = serverCert; initializeHttpState(cryptoProvider); this.httpsPort = httpsPort; try { // If this is an IPv4-mapped IPv6 address, OkHTTP will choke on it if it's // in IPv6 form, because InetAddress.getByName() will return an Inet4Address // for what OkHTTP thinks is an IPv6 address. Normalize it into IPv4 form // to avoid triggering this bug. String addressString = address.address; if (addressString.contains(":") && addressString.contains(".")) { InetAddress addr = InetAddress.getByName(addressString); if (addr instanceof Inet4Address) { addressString = ((Inet4Address)addr).getHostAddress(); } } this.baseUrlHttp = new HttpUrl.Builder() .scheme("http") .host(addressString) .port(address.port) .build(); } catch (IllegalArgumentException e) { // Encapsulate IllegalArgumentException into IOException for callers to handle more easily throw new IOException(e); } this.pm = new PairingManager(this, cryptoProvider); } static String getXmlString(Reader r, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); factory.setNamespaceAware(true); XmlPullParser xpp = factory.newPullParser(); xpp.setInput(r); int eventType = xpp.getEventType(); Stack currentTag = new Stack(); while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case (XmlPullParser.START_TAG): if (xpp.getName().equals("root")) { verifyResponseStatus(xpp); } currentTag.push(xpp.getName()); break; case (XmlPullParser.END_TAG): currentTag.pop(); break; case (XmlPullParser.TEXT): if (currentTag.peek().equals(tagname)) { return xpp.getText(); } break; } eventType = xpp.next(); } if (throwIfMissing) { // We throw an XmlPullParserException here for ease of handling in all the various callers. // We could also throw an IOException, but some callers expect those in cases where the // host may not be reachable. We want to distinguish unreachable hosts vs. hosts that // are returning garbage XML to us, so we use XmlPullParserException instead. throw new XmlPullParserException("Missing mandatory field in host response: "+tagname); } return null; } static String getXmlString(String str, String tagname, boolean throwIfMissing) throws XmlPullParserException, IOException { return getXmlString(new StringReader(str), tagname, throwIfMissing); } private static void verifyResponseStatus(XmlPullParser xpp) throws HostHttpResponseException { // We use Long.parseLong() because in rare cases GFE can send back a status code of // 0xFFFFFFFF, which will cause Integer.parseInt() to throw a NumberFormatException due // to exceeding Integer.MAX_VALUE. We'll get the desired error code of -1 by just casting // the resulting long into an int. int statusCode = (int)Long.parseLong(xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_code")); if (statusCode != 200) { String statusMsg = xpp.getAttributeValue(XmlPullParser.NO_NAMESPACE, "status_message"); if (statusCode == -1 && "Invalid".equals(statusMsg)) { // Special case handling an audio capture error which GFE doesn't // provide any useful status message for. statusCode = 418; statusMsg = "Missing audio capture device. Reinstall GeForce Experience."; } throw new HostHttpResponseException(statusCode, statusMsg); } } public String getServerInfo(boolean likelyOnline) throws IOException, XmlPullParserException { String resp; // If we believe the PC is online, give it a little extra time to respond OkHttpClient client = likelyOnline ? httpClientLongConnectTimeout : httpClientShortConnectTimeout; // // TODO: Shield Hub uses HTTP for this and is able to get an accurate PairStatus with HTTP. // For some reason, we always see PairStatus is 0 over HTTP and only 1 over HTTPS. It looks // like there are extra request headers required to make this stuff work over HTTP. // // When we have a pinned cert, use HTTPS to fetch serverinfo and fall back on cert mismatch if (serverCert != null) { try { try { resp = openHttpConnectionToString(client, getHttpsUrl(likelyOnline), "serverinfo"); } catch (SSLHandshakeException e) { // Detect if we failed due to a server cert mismatch if (e.getCause() instanceof CertificateException) { // Jump to the GfeHttpResponseException exception handler to retry // over HTTP which will allow us to pair again to update the cert throw new HostHttpResponseException(401, "Server certificate mismatch"); } else { throw e; } } // This will throw an exception if the request came back with a failure status. // We want this because it will throw us into the HTTP case if the client is unpaired. getServerVersion(resp); } catch (HostHttpResponseException e) { if (e.getErrorCode() == 401) { // Cert validation error - fall back to HTTP return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); } // If it's not a cert validation error, throw it throw e; } return resp; } else { // No pinned cert, so use HTTP return openHttpConnectionToString(client, baseUrlHttp, "serverinfo"); } } private static ComputerDetails.AddressTuple makeTuple(String address, int port) { if (address == null) { return null; } return new ComputerDetails.AddressTuple(address, port); } public ComputerDetails getComputerDetails(String serverInfo) throws IOException, XmlPullParserException { ComputerDetails details = new ComputerDetails(); details.name = getXmlString(serverInfo, "hostname", false); if (details.name == null || details.name.isEmpty()) { details.name = "UNKNOWN"; } // UUID is mandatory to determine which machine is responding details.uuid = getXmlString(serverInfo, "uniqueid", true); details.httpsPort = getHttpsPort(serverInfo); details.macAddress = getXmlString(serverInfo, "mac", false); // FIXME: Do we want to use the current port? details.localAddress = makeTuple(getXmlString(serverInfo, "LocalIP", false), baseUrlHttp.port()); // This is missing on on recent GFE versions, but it's present on Sunshine details.externalPort = getExternalPort(serverInfo); details.remoteAddress = makeTuple(getXmlString(serverInfo, "ExternalIP", false), details.externalPort); details.pairState = getPairState(serverInfo); details.runningGameId = getCurrentGame(serverInfo); // The MJOLNIR codename was used by GFE but never by any third-party server details.nvidiaServer = getXmlString(serverInfo, "state", true).contains("MJOLNIR"); // We could reach it so it's online details.state = ComputerDetails.State.ONLINE; return details; } public ComputerDetails getComputerDetails(boolean likelyOnline) throws IOException, XmlPullParserException { return getComputerDetails(getServerInfo(likelyOnline)); } // This hack is Android-specific but we do it on all platforms // because it doesn't really matter private OkHttpClient performAndroidTlsHack(OkHttpClient client) { // Doing this each time we create a socket is required // to avoid the SSLv3 fallback that causes connection failures try { SSLContext sc = SSLContext.getInstance("TLS"); sc.init(new KeyManager[] { keyManager }, new TrustManager[] { trustManager }, new SecureRandom()); // TLS 1.2 is not enabled by default prior to Android 5.0, so we'll need a custom // SSLSocketFactory in order to connect to GFE 3.20.4 which requires TLSv1.2 or later. // We don't just always use TLSv12SocketFactory because explicitly specifying TLS versions // prevents later TLS versions from being negotiated even if client and server otherwise // support them. return client.newBuilder().sslSocketFactory( Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? sc.getSocketFactory() : new TLSv12SocketFactory(sc), trustManager).build(); } catch (NoSuchAlgorithmException | KeyManagementException e) { throw new RuntimeException(e); } } private HttpUrl getCompleteUrl(HttpUrl baseUrl, String path, String query) { return baseUrl.newBuilder() .addPathSegment(path) .query(query) .addQueryParameter("uniqueid", uniqueId) .addQueryParameter("uuid", UUID.randomUUID().toString()) .build(); } private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { return openHttpConnection(client, baseUrl, path, null); } // Read timeout should be enabled for any HTTP query that requires no outside action // on the GFE server. Examples of queries that DO require outside action are launch, resume, and quit. // The initial pair query does require outside action (user entering a PIN) but subsequent pairing // queries do not. private ResponseBody openHttpConnection(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { HttpUrl completeUrl = getCompleteUrl(baseUrl, path, query); Request request = new Request.Builder().url(completeUrl).get().build(); Response response = performAndroidTlsHack(client).newCall(request).execute(); ResponseBody body = response.body(); if (response.isSuccessful()) { return body; } // Unsuccessful, so close the response body if (body != null) { body.close(); } if (response.code() == 404) { throw new FileNotFoundException(completeUrl.toString()); } else { throw new HostHttpResponseException(response.code(), response.message()); } } private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path) throws IOException { return openHttpConnectionToString(client, baseUrl, path, null); } private String openHttpConnectionToString(OkHttpClient client, HttpUrl baseUrl, String path, String query) throws IOException { try { ResponseBody resp = openHttpConnection(client, baseUrl, path, query); String respString = resp.string(); resp.close(); if (verbose && !path.equals("serverinfo")) { LimeLog.info(getCompleteUrl(baseUrl, path, query)+" -> "+respString); } return respString; } catch (IOException e) { if (verbose && !path.equals("serverinfo")) { LimeLog.warning(getCompleteUrl(baseUrl, path, query)+" -> "+e.getMessage()); e.printStackTrace(); } throw e; } } public String getServerVersion(String serverInfo) throws XmlPullParserException, IOException { // appversion is present in all supported GFE versions return getXmlString(serverInfo, "appversion", true); } public PairingManager.PairState getPairState() throws IOException, XmlPullParserException { return getPairState(getServerInfo(true)); } public PairingManager.PairState getPairState(String serverInfo) throws IOException, XmlPullParserException { // appversion is present in all supported GFE versions return NvHTTP.getXmlString(serverInfo, "PairStatus", true).equals("1") ? PairState.PAIRED : PairState.NOT_PAIRED; } public long getMaxLumaPixelsH264(String serverInfo) throws XmlPullParserException, IOException { // MaxLumaPixelsH264 wasn't present on old GFE versions String str = getXmlString(serverInfo, "MaxLumaPixelsH264", false); if (str != null) { return Long.parseLong(str); } else { return 0; } } public long getMaxLumaPixelsHEVC(String serverInfo) throws XmlPullParserException, IOException { // MaxLumaPixelsHEVC wasn't present on old GFE versions String str = getXmlString(serverInfo, "MaxLumaPixelsHEVC", false); if (str != null) { return Long.parseLong(str); } else { return 0; } } // Possible meaning of bits // Bit 0: H.264 Baseline // Bit 1: H.264 High // ---- // Bit 8: HEVC Main // Bit 9: HEVC Main10 // Bit 10: HEVC Main10 4:4:4 // Bit 11: ??? public long getServerCodecModeSupport(String serverInfo) throws XmlPullParserException, IOException { // ServerCodecModeSupport wasn't present on old GFE versions String str = getXmlString(serverInfo, "ServerCodecModeSupport", false); if (str != null) { return Long.parseLong(str); } else { return 0; } } public String getGpuType(String serverInfo) throws XmlPullParserException, IOException { // ServerCodecModeSupport wasn't present on old GFE versions return getXmlString(serverInfo, "gputype", false); } public String getGfeVersion(String serverInfo) throws XmlPullParserException, IOException { // ServerCodecModeSupport wasn't present on old GFE versions return getXmlString(serverInfo, "GfeVersion", false); } public boolean supports4K(String serverInfo) throws XmlPullParserException, IOException { // Only allow 4K on GFE 3.x. GfeVersion wasn't present on very old versions of GFE. String gfeVersionStr = getXmlString(serverInfo, "GfeVersion", false); if (gfeVersionStr == null || gfeVersionStr.startsWith("2.")) { return false; } return true; } public int getCurrentGame(String serverInfo) throws IOException, XmlPullParserException { // GFE 2.8 started keeping currentgame set to the last game played. As a result, it no longer // has the semantics that its name would indicate. To contain the effects of this change as much // as possible, we'll force the current game to zero if the server isn't in a streaming session. if (getXmlString(serverInfo, "state", true).endsWith("_SERVER_BUSY")) { return Integer.parseInt(getXmlString(serverInfo, "currentgame", true)); } else { return 0; } } public int getHttpsPort(String serverInfo) { try { return Integer.parseInt(getXmlString(serverInfo, "HttpsPort", true)); } catch (XmlPullParserException e) { e.printStackTrace(); return DEFAULT_HTTPS_PORT; } catch (IOException e) { e.printStackTrace(); return DEFAULT_HTTPS_PORT; } } public int getExternalPort(String serverInfo) { // This is an extension which is not present in GFE. It is present for Sunshine to be able // to support dynamic HTTP WAN ports without requiring the user to manually enter the port. try { return Integer.parseInt(getXmlString(serverInfo, "ExternalPort", true)); } catch (XmlPullParserException e) { // Expected on non-Sunshine servers return baseUrlHttp.port(); } catch (IOException e) { e.printStackTrace(); return baseUrlHttp.port(); } } public NvApp getAppById(int appId) throws IOException, XmlPullParserException { LinkedList appList = getAppList(); for (NvApp appFromList : appList) { if (appFromList.getAppId() == appId) { return appFromList; } } return null; } /* NOTE: Only use this function if you know what you're doing. * It's totally valid to have two apps named the same thing, * or even nothing at all! Look apps up by ID if at all possible * using the above function */ public NvApp getAppByName(String appName) throws IOException, XmlPullParserException { LinkedList appList = getAppList(); for (NvApp appFromList : appList) { if (appFromList.getAppName().equalsIgnoreCase(appName)) { return appFromList; } } return null; } public PairingManager getPairingManager() { return pm; } public static LinkedList getAppListByReader(Reader r) throws XmlPullParserException, IOException { XmlPullParserFactory factory = XmlPullParserFactory.newInstance(); factory.setNamespaceAware(true); XmlPullParser xpp = factory.newPullParser(); xpp.setInput(r); int eventType = xpp.getEventType(); LinkedList appList = new LinkedList(); Stack currentTag = new Stack(); boolean rootTerminated = false; while (eventType != XmlPullParser.END_DOCUMENT) { switch (eventType) { case (XmlPullParser.START_TAG): if (xpp.getName().equals("root")) { verifyResponseStatus(xpp); } currentTag.push(xpp.getName()); if (xpp.getName().equals("App")) { appList.addLast(new NvApp()); } break; case (XmlPullParser.END_TAG): currentTag.pop(); if (xpp.getName().equals("root")) { rootTerminated = true; } break; case (XmlPullParser.TEXT): NvApp app = appList.getLast(); if (currentTag.peek().equals("AppTitle")) { app.setAppName(xpp.getText()); } else if (currentTag.peek().equals("ID")) { app.setAppId(xpp.getText()); } else if (currentTag.peek().equals("IsHdrSupported")) { app.setHdrSupported(xpp.getText().equals("1")); } break; } eventType = xpp.next(); } // Throw a malformed XML exception if we've not seen the root tag ended if (!rootTerminated) { throw new XmlPullParserException("Malformed XML: Root tag was not terminated"); } // Ensure that all apps in the list are initialized ListIterator i = appList.listIterator(); while (i.hasNext()) { NvApp app = i.next(); // Remove uninitialized apps if (!app.isInitialized()) { LimeLog.warning("GFE returned incomplete app: "+app.getAppId()+" "+app.getAppName()); i.remove(); } } return appList; } public String getAppListRaw() throws IOException { return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "applist"); } public LinkedList getAppList() throws HostHttpResponseException, IOException, XmlPullParserException { if (verbose) { // Use the raw function so the app list is printed return getAppListByReader(new StringReader(getAppListRaw())); } else { try (final ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "applist")) { return getAppListByReader(new InputStreamReader(resp.byteStream())); } } } String executePairingCommand(String additionalArguments, boolean enableReadTimeout) throws HostHttpResponseException, IOException { return openHttpConnectionToString(enableReadTimeout ? httpClientLongConnectTimeout : httpClientLongConnectNoReadTimeout, baseUrlHttp, "pair", "devicename=roth&updateState=1&" + additionalArguments); } String executePairingChallenge() throws HostHttpResponseException, IOException { return openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "pair", "devicename=roth&updateState=1&phrase=pairchallenge"); } public void unpair() throws IOException { openHttpConnectionToString(httpClientLongConnectTimeout, baseUrlHttp, "unpair"); } public InputStream getBoxArt(NvApp app) throws IOException { ResponseBody resp = openHttpConnection(httpClientLongConnectTimeout, getHttpsUrl(true), "appasset", "appid=" + app.getAppId() + "&AssetType=2&AssetIdx=0"); return resp.byteStream(); } public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException { return getServerAppVersionQuad(serverInfo)[0]; } public int[] getServerAppVersionQuad(String serverInfo) throws XmlPullParserException, IOException { String serverVersion = getServerVersion(serverInfo); if (serverVersion == null) { throw new IllegalArgumentException("Missing server version field"); } String[] serverVersionSplit = serverVersion.split("\\."); if (serverVersionSplit.length != 4) { throw new IllegalArgumentException("Malformed server version field: "+serverVersion); } int[] ret = new int[serverVersionSplit.length]; for (int i = 0; i < ret.length; i++) { ret[i] = Integer.parseInt(serverVersionSplit[i]); } return ret; } final private static char[] hexArray = "0123456789ABCDEF".toCharArray(); private static String bytesToHex(byte[] bytes) { char[] hexChars = new char[bytes.length * 2]; for ( int j = 0; j < bytes.length; j++ ) { int v = bytes[j] & 0xFF; hexChars[j * 2] = hexArray[v >>> 4]; hexChars[j * 2 + 1] = hexArray[v & 0x0F]; } return new String(hexChars); } public boolean launchApp(ConnectionContext context, String verb, int appId, boolean enableHdr) throws IOException, XmlPullParserException { // Using an FPS value over 60 causes SOPS to default to 720p60, // so force it to 0 to ensure the correct resolution is set. We // used to use 60 here but that locked the frame rate to 60 FPS // on GFE 3.20.3. int fps = context.isNvidiaServerSoftware && context.streamConfig.getLaunchRefreshRate() > 60 ? 0 : context.streamConfig.getLaunchRefreshRate(); boolean enableSops = context.streamConfig.getSops(); if (context.isNvidiaServerSoftware) { // Using an unsupported resolution (not 720p, 1080p, or 4K) causes // GFE to force SOPS to 720p60. This is fine for < 720p resolutions like // 360p or 480p, but it is not ideal for 1440p and other resolutions. // When we detect an unsupported resolution, disable SOPS unless it's under 720p. // FIXME: Detect support resolutions using the serverinfo response, not a hardcoded list if (context.negotiatedWidth * context.negotiatedHeight > 1280 * 720 && context.negotiatedWidth * context.negotiatedHeight != 1920 * 1080 && context.negotiatedWidth * context.negotiatedHeight != 3840 * 2160) { LimeLog.info("Disabling SOPS due to non-standard resolution: "+context.negotiatedWidth+"x"+context.negotiatedHeight); enableSops = false; } } String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, "appid=" + appId + "&mode=" + context.negotiatedWidth + "x" + context.negotiatedHeight + "x" + fps + "&additionalStates=1&sops=" + (enableSops ? 1 : 0) + "&rikey="+bytesToHex(context.riKey.getEncoded()) + "&rikeyid="+context.riKeyId + (!enableHdr ? "" : "&hdrMode=1&clientHdrCapVersion=0&clientHdrCapSupportedFlagsInUint32=0&clientHdrCapMetaDataId=NV_STATIC_METADATA_TYPE_1&clientHdrCapDisplayData=0x0x0x0x0x0x0x0x0x0x0") + "&localAudioPlayMode=" + (context.streamConfig.getPlayLocalAudio() ? 1 : 0) + "&surroundAudioInfo=" + context.streamConfig.getAudioConfiguration().getSurroundAudioInfo() + "&remoteControllersBitmap=" + context.streamConfig.getAttachedGamepadMask() + "&gcmap=" + context.streamConfig.getAttachedGamepadMask() + "&gcpersist="+(context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + MoonBridge.getLaunchUrlQueryParameters()); if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") || (verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) { // sessionUrl0 will be missing for older GFE versions context.rtspSessionUrl = getXmlString(xmlStr, "sessionUrl0", false); return true; } else { return false; } } public boolean quitApp() throws IOException, XmlPullParserException { String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), "cancel"); if (getXmlString(xmlStr, "cancel", true).equals("0")) { return false; } // Newer GFE versions will just return success even if quitting fails // if we're not the original requestor. if (getCurrentGame(getServerInfo(true)) != 0) { // Generate a synthetic GfeResponseException letting the caller know // that they can't kill someone else's stream. throw new HostHttpResponseException(599, ""); } return true; } // Based on example code from https://blog.dev-area.net/2015/08/13/android-4-1-enable-tls-1-1-and-tls-1-2/ private static class TLSv12SocketFactory extends SSLSocketFactory { private SSLSocketFactory internalSSLSocketFactory; public TLSv12SocketFactory(SSLContext context) { internalSSLSocketFactory = context.getSocketFactory(); } @Override public String[] getDefaultCipherSuites() { return internalSSLSocketFactory.getDefaultCipherSuites(); } @Override public String[] getSupportedCipherSuites() { return internalSSLSocketFactory.getSupportedCipherSuites(); } @Override public Socket createSocket() throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket()); } @Override public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)); } @Override public Socket createSocket(String host, int port) throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port)); } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort)); } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(host, port)); } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return enableTLSv12OnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort)); } private Socket enableTLSv12OnSocket(Socket socket) { if (socket instanceof SSLSocket) { // TLS 1.2 is not enabled by default prior to Android 5.0. We must enable it // explicitly to ensure we can communicate with GFE 3.20.4 which blocks TLS 1.0. ((SSLSocket)socket).setEnabledProtocols(new String[] {"TLSv1.2"}); } return socket; } } }