876 lines
37 KiB
Java
876 lines
37 KiB
Java
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<String> currentTag = new Stack<String>();
|
|
|
|
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<NvApp> 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<NvApp> appList = getAppList();
|
|
for (NvApp appFromList : appList) {
|
|
if (appFromList.getAppName().equalsIgnoreCase(appName)) {
|
|
return appFromList;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public PairingManager getPairingManager() {
|
|
return pm;
|
|
}
|
|
|
|
public static LinkedList<NvApp> 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<NvApp> appList = new LinkedList<NvApp>();
|
|
Stack<String> currentTag = new Stack<String>();
|
|
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<NvApp> 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<NvApp> 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;
|
|
}
|
|
}
|
|
}
|