initial commit

This commit is contained in:
Benjamin Wiegand
2025-04-24 14:58:54 -07:00
parent 04ab75b015
commit 8cf009e22c
107 changed files with 5618 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Android Profiling
*.hprof
+1
View File
@@ -0,0 +1 @@
/build
+39
View File
@@ -0,0 +1,39 @@
plugins {
alias(libs.plugins.android.application)
}
android {
namespace 'io.benwiegand.atvremote.phone'
compileSdk 35
defaultConfig {
applicationId "io.benwiegand.atvremote.phone"
minSdk 30
targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
implementation libs.appcompat
implementation libs.material
implementation libs.activity
testImplementation libs.junit
androidTestImplementation libs.ext.junit
androidTestImplementation libs.espresso.core
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package io.benwiegand.atvremote.phone;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("io.benwiegand.atvremote.phone", appContext.getPackageName());
}
}
@@ -0,0 +1,47 @@
package io.benwiegand.atvremote.phone;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import io.benwiegand.atvremote.phone.network.TCPReader;
@RunWith(AndroidJUnit4.class)
public class TCPReaderTest {
@Test
public void LF_and_CRLF_Compatibility_Test() throws IOException, InterruptedException {
final String[] TEST_LINES = new String[]{"testing 123",
"TCPReader should support", "both LF", "and CRLF", "forms of newline",
"and it shouldn't remove random '\r's in the middle of strings",
"in case for some reason that happens"};
testWithString(String.join("\n", TEST_LINES) + "\n", TEST_LINES);
testWithString(String.join("\r\n", TEST_LINES) + "\r\n", TEST_LINES);
}
public void testWithString(String string, String[] expectedLines) throws IOException, InterruptedException {
ByteArrayInputStream is = new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8));
TCPReader reader = TCPReader.createFromStream(is, StandardCharsets.UTF_8);
for (String expectedLine : expectedLines) {
String line = reader.nextLine(100);
assertEquals(expectedLine, line);
}
assertThrows("exception when reading after end of stream",
IOException.class, () -> reader.nextLine(100));
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ATVRemote"
tools:targetApi="31">
<activity
android:name=".ui.PairingActivity"
android:exported="false" />
<activity
android:name=".ui.TVDiscoveryActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.RemoteActivity"
android:exported="false" />
<activity
android:name=".ui.DebugActivity"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,172 @@
package io.benwiegand.atvremote.phone.async;
import android.util.Log;
import java.util.function.Consumer;
import java.util.function.Function;
// as in "just a sec"
public class Sec<T> {
private static final String TAG = Sec.class.getSimpleName();
private final Object lock = new Object();
private boolean finished = false;
private T result = null;
private Throwable error = null;
private boolean callbacksSet = false;
private boolean callbacksCalled = false;
private Consumer<T> onResult = null;
private Consumer<Throwable> onError = null;
Sec() {}
Adapter createAdapter() {
return new Adapter();
}
public boolean isFinished() {
synchronized (lock) {
return finished;
}
}
public boolean isSuccessful() {
synchronized (lock) {
if (!finished) throw new IllegalStateException("not finished, success is not yet known");
return error != null;
}
}
public T getResult() {
synchronized (lock) {
if (!finished) throw new IllegalStateException("not finished, result doesn't exist yet");
return result;
}
}
public Throwable getError() {
synchronized (lock) {
if (!finished) throw new IllegalStateException("not finished, error doesn't exist yet");
return error;
}
}
public T getResultOrThrow() throws Throwable {
synchronized (lock) {
if (!finished) throw new IllegalStateException("not finished, result/error doesn't exist yet");
if (error != null) throw error;
return result;
}
}
public Sec<T> doOnResult(Consumer<T> onResult) {
synchronized (lock) {
if (callbacksSet) throw new IllegalStateException("callbacks already set up");
this.onResult = onResult;
return this;
}
}
public Sec<T> doOnError(Consumer<Throwable> onError) {
synchronized (lock) {
if (callbacksSet) throw new IllegalStateException("callbacks already set up");
this.onError = onError;
return this;
}
}
public <U> Sec<U> map(Function<T, U> map) {
// for now just use the callbacks. this may change in the future
SecAdapter.SecWithAdapter<U> secWithAdapter;
synchronized (lock) {
if (callbacksSet) throw new IllegalStateException("callbacks already set up");
callbacksSet = true;
secWithAdapter = SecAdapter.createThreadless();
SecAdapter<U> adapter = secWithAdapter.secAdapter();
this.onResult = r -> {
U mapped;
try {
mapped = map.apply(r);
} catch (Throwable t) {
adapter.throwError(t);
return;
}
adapter.provideResult(mapped);
};
this.onError = adapter::throwError;
}
if (isFinished()) callCallbacks();
return secWithAdapter.sec();
}
public void callMeWhenDone() {
synchronized (lock) {
if (callbacksSet) throw new IllegalStateException("callMeWhenDone() cannot be called twice");
callbacksSet = true;
}
if (isFinished()) callCallbacks();
}
private void callCallbacks() {
synchronized (lock) {
assert finished;
if (!callbacksSet) return; // callbacks aren't ready
if (callbacksCalled) return; // don't call back twice
callbacksCalled = true;
}
try {
if (error == null && onResult != null)
onResult.accept(result);
} catch (Throwable t) {
Log.e(TAG, "error during onResult callback", t);
}
try {
if (error != null && onError != null)
onError.accept(error);
} catch (Throwable t) {
Log.e(TAG, "error during onError callback", t);
}
}
private class Adapter implements SecAdapter<T> {
@Override
public void provideResult(T r) {
synchronized (lock) {
if (finished) throw new IllegalStateException("a result or error has already been provided");
finished = true;
result = r;
}
callCallbacks();
}
@Override
public void throwError(Throwable t) {
assert t != null;
synchronized (lock) {
if (finished) throw new IllegalStateException("a result or error has already been provided");
finished = true;
error = t;
}
callCallbacks();
}
}
}
@@ -0,0 +1,18 @@
package io.benwiegand.atvremote.phone.async;
public interface SecAdapter<T> {
void provideResult(T result);
void throwError(Throwable t);
record SecWithAdapter<T>(Sec<T> sec, SecAdapter<T> secAdapter) {}
static <T> SecWithAdapter<T> createThreadless() {
Sec<T> sec = new Sec<>();
SecAdapter<T> adapter = sec.createAdapter();
return new SecWithAdapter<>(sec, adapter);
}
}
@@ -0,0 +1,7 @@
package io.benwiegand.atvremote.phone.auth.ssl;
public class CorruptedKeystoreException extends Exception {
public CorruptedKeystoreException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,59 @@
package io.benwiegand.atvremote.phone.auth.ssl;
import android.util.Log;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import javax.net.ssl.SSLPeerUnverifiedException;
import javax.net.ssl.SSLSocket;
public class KeyUtil {
private static final String TAG = KeyUtil.class.getSimpleName();
private static MessageDigest getSha256Digest() {
try {
return MessageDigest.getInstance("SHA256");
} catch (NoSuchAlgorithmException e) {
Log.e(TAG, "no JDK support for SHA256", e);
throw new UnsupportedOperationException("your device lacks SHA256 support", e);
}
}
public static byte[] calculateCertificateFingerprint(Certificate cert) throws CorruptedKeystoreException {
try {
return getSha256Digest().digest(cert.getEncoded());
} catch (CertificateEncodingException e) {
throw new CorruptedKeystoreException("certificate encoding is invalid", e);
}
}
public static SecureRandom getSecureRandom() {
try {
return SecureRandom.getInstanceStrong();
} catch (NoSuchAlgorithmException e) {
Log.w(TAG, "can't create strong secure random!", e);
}
return new SecureRandom();
}
public static Certificate getRemoteCertificate(SSLSocket socket) {
try {
Certificate[] certs = socket.getSession().getPeerCertificates();
if (certs.length == 0) {
Log.e(TAG, "peer has no certs?");
return null;
}
return certs[0];
} catch (SSLPeerUnverifiedException e) {
Log.e(TAG, "remote did not send a certificate", e);
return null;
}
}
}
@@ -0,0 +1,181 @@
package io.benwiegand.atvremote.phone.auth.ssl;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
public class KeystoreManager {
private static final String TAG = KeystoreManager.class.getSimpleName();
// try to preserve forward-compatibility if possible
private static final String KEYSTORE_TYPE = "BKS";
private static final String KEYSTORE_TYPE_FALLBACK = KeyStore.getDefaultType();
// before you scream, the password serves no purpose in this case. it's not part of the threat model
private static final char[] KEYSTORE_PASSWORD = "hunter2".toCharArray();
private static final String SSL_CERTIFICATE_ALIAS_PREFIX = "atvr_cert_";
private final File keystoreFile;
private KeyStore keystore = null;
public KeystoreManager(Context context) {
Path sslPath = context.getFilesDir().toPath().resolve("ssl");
File sslDir = sslPath.toFile();
keystoreFile = sslPath.resolve("keystore.jks").toFile();
if (!(sslDir.isDirectory() || sslDir.mkdirs()))
throw new RuntimeException("cannot make ssl directory");
}
public KeyManager[] getKeyManagers() throws CorruptedKeystoreException {
try {
KeyManagerFactory kmf = KeyManagerFactory.getInstance("X509");
kmf.init(keystore, KEYSTORE_PASSWORD);
return kmf.getKeyManagers();
} catch (NoSuchAlgorithmException e) {
throw new UnsupportedOperationException("keypair algorithm not supported", e);
} catch (UnrecoverableKeyException e) {
throw new CorruptedKeystoreException("keystore password rejected", e);
} catch (KeyStoreException e) {
throw new RuntimeException("failed to make a key manager", e);
}
}
public TrustManager[] getTrustManagers() {
try {
TrustManagerFactory tmf = TrustManagerFactory.getInstance("X509");
tmf.init(keystore);
return tmf.getTrustManagers();
} catch (NoSuchAlgorithmException e) {
throw new UnsupportedOperationException("keypair algorithm not supported", e);
} catch (KeyStoreException e) {
throw new RuntimeException("failed to make a key manager", e);
}
}
private InputStream getKeystoreInputStream() throws IOException {
try {
return new FileInputStream(keystoreFile);
} catch (FileNotFoundException e) {
// apparently this can throw even if the file exists (if there's another error)
if (keystoreFile.isFile()) throw new IOException("keystore file open failed", e);
Log.d(TAG, "keystore file not found");
return null;
}
}
private KeyStore getKeystoreInstance() {
try {
return KeyStore.getInstance(KEYSTORE_TYPE);
} catch (KeyStoreException e) {
if (KEYSTORE_TYPE.equals(KEYSTORE_TYPE_FALLBACK)) {
Log.wtf(TAG, "failed to create keystore", e);
throw new UnsupportedOperationException("Cannot create keystore of type " + KEYSTORE_TYPE);
}
Log.d(TAG, "failed to create keystore instance for " + KEYSTORE_TYPE + ", falling back to " + KEYSTORE_TYPE_FALLBACK, e);
try {
return KeyStore.getInstance(KEYSTORE_TYPE_FALLBACK);
} catch (KeyStoreException ex) {
Log.wtf(TAG, "couldn't create fallback keystore type either");
throw new UnsupportedOperationException("Cannot create keystore of type " + KEYSTORE_TYPE + " or " + KEYSTORE_TYPE_FALLBACK, ex);
}
}
}
public void loadKeystore() throws CorruptedKeystoreException, IOException {
if (keystore != null) throw new IllegalStateException("keystore already loaded");
KeyStore ks = getKeystoreInstance();
try (InputStream is = getKeystoreInputStream()) {
if (is == null) Log.i(TAG, "no keystore file, an empty one will be created");
ks.load(is, KEYSTORE_PASSWORD);
} catch (IOException e) { // io error, corrupted, or bad password
if (e.getCause() instanceof UnrecoverableKeyException) {
Log.wtf(TAG, "Keystore.load() reports that the keystore password is invalid. It's probably corrupted", e);
throw new CorruptedKeystoreException("keystore password not working, likely corrupted", e);
}
// the keystore could be corrupted, or there's an io error
throw e;
} catch (CertificateException e) { // a certificate couldn't be loaded
throw new CorruptedKeystoreException("unable to load keystore due to a corrupted entry", e);
} catch (NoSuchAlgorithmException e) { // no algorithm to check integrity (unsupported?)
throw new UnsupportedOperationException("unable to load keystore because there's no matching integrity checking algorithm", e);
}
keystore = ks;
}
public boolean deleteKeystore() {
if (keystore != null) throw new IllegalStateException("keystore already loaded, refusing to delete");
return !keystoreFile.isFile() || keystoreFile.delete();
}
public void addCertificate(String deviceId, Certificate cert) {
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
try {
keystore.setCertificateEntry(SSL_CERTIFICATE_ALIAS_PREFIX + deviceId, cert);
} catch (KeyStoreException e) {
throw new RuntimeException("failed to store certificate", e);
}
}
public void saveKeystore() throws IOException, CorruptedKeystoreException {
if (keystore == null) throw new IllegalStateException("no currently loaded keystore to save");
if (keystoreFile.isFile()) Log.v(TAG, "overwriting existing keystore at: " + keystoreFile);
else Log.v(TAG, "saving keystore as: " + keystoreFile);
try (FileOutputStream os = new FileOutputStream(keystoreFile)) {
keystore.store(os, KEYSTORE_PASSWORD);
} catch (FileNotFoundException e) {
Log.e(TAG, "cannot open keystore file for writing", e);
throw e;
} catch (IOException e) {
Log.e(TAG, "failed to write keystore", e);
throw e;
} catch (CertificateException e) {
Log.wtf(TAG, "failed to store a certificate within the keystore", e);
throw new CorruptedKeystoreException("failed to store certificate", e);
} catch (KeyStoreException e) {
Log.wtf(TAG, "keystore was never loaded", e);
throw new IllegalStateException("keystore was never loaded?", e);
} catch (NoSuchAlgorithmException e) {
Log.e(TAG, "algorithm for verifying keystore integrity not supported", e);
throw new UnsupportedOperationException("unable to save keystore because there's no matching integrity checking algorithm", e);
}
}
}
@@ -0,0 +1,48 @@
package io.benwiegand.atvremote.phone.control;
import io.benwiegand.atvremote.phone.async.Sec;
public interface InputHandler {
Sec<Void> dpadDown();
Sec<Void> dpadUp();
Sec<Void> dpadLeft();
Sec<Void> dpadRight();
Sec<Void> dpadSelect();
Sec<Void> dpadLongPress();
Sec<Void> navHome();
Sec<Void> navBack();
Sec<Void> navRecent();
Sec<Void> navApps();
Sec<Void> navNotifications();
Sec<Void> navQuickSettings();
Sec<Void> volumeUp();
Sec<Void> volumeDown();
Sec<Void> mute();
Sec<Void> pause();
Sec<Void> nextTrack();
Sec<Void> prevTrack();
Sec<Void> skipBackward();
Sec<Void> skipForward();
boolean softKeyboardEnabled();
boolean softKeyboardVisible();
Sec<Void> showSoftKeyboard();
Sec<Void> hideSoftKeyboard();
Sec<Void> setSoftKeyboardEnabled(boolean enabled);
Sec<Void> keyboardInput(String input);
boolean cursorSupported();
Sec<Void> showCursor();
Sec<Void> hideCursor();
Sec<Void> cursorMove(int x, int y);
Sec<Void> cursorDown();
Sec<Void> cursorUp();
Sec<Void> cursorContext();
Sec<Void> scrollVertical(double trajectory, boolean glide);
Sec<Void> scrollHorizontal(double trajectory, boolean glide);
}
@@ -0,0 +1,13 @@
package io.benwiegand.atvremote.phone.control;
public interface MouseCurve {
MouseCurve ACCEL_LINEAR = d -> d;
MouseCurve ACCEL_LOG10 = d -> (float) (d * Math.log10(Math.abs(d) + 1));
MouseCurve ACCEL_LN = d -> (float) (d * Math.log(Math.abs(d) + 1));
// given delta, calculate how many mouse to a mickey
// I wish I was joking, this is the mickey/mouse equation
float apply(float delta);
}
@@ -0,0 +1,7 @@
package io.benwiegand.atvremote.phone.control;
import io.benwiegand.atvremote.phone.async.SecAdapter;
public record OperationQueueEntry(SecAdapter<String> responseAdapter, String operation) {
}
@@ -0,0 +1,140 @@
package io.benwiegand.atvremote.phone.network;
import static io.benwiegand.atvremote.phone.auth.ssl.KeyUtil.calculateCertificateFingerprint;
import static io.benwiegand.atvremote.phone.auth.ssl.KeyUtil.getSecureRandom;
import static io.benwiegand.atvremote.phone.network.SocketUtil.tryClose;
import static io.benwiegand.atvremote.phone.util.ByteUtil.hexOf;
import android.content.Context;
import android.util.Log;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import javax.net.SocketFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import io.benwiegand.atvremote.phone.auth.ssl.CorruptedKeystoreException;
import io.benwiegand.atvremote.phone.auth.ssl.KeyUtil;
import io.benwiegand.atvremote.phone.auth.ssl.KeystoreManager;
import io.benwiegand.atvremote.phone.protocol.PairingData;
import io.benwiegand.atvremote.phone.protocol.PairingManager;
import io.benwiegand.atvremote.phone.protocol.RequiresPairingException;
import io.benwiegand.atvremote.phone.stuff.LGTMTrustManager;
public class ConnectionService {
private static final String TAG = ConnectionService.class.getSimpleName();
private final KeystoreManager keystoreManager;
private final SSLContext sslContext;
private final SSLContext pairingSslContext;
private SocketFactory socketFactory = null;
private SocketFactory pairingSocketFactory = null;
private final PairingManager pairingManager;
public ConnectionService(Context context) {
keystoreManager = new KeystoreManager(context);
pairingManager = new PairingManager(context, keystoreManager);
try {
sslContext = SSLContext.getInstance("TLS");
pairingSslContext = SSLContext.getInstance("TLS");
} catch (NoSuchAlgorithmException e) {
// this is unlikely. if this ever happens it's probably a bug?
Log.wtf(TAG, "unable to instantiate an SSLContext because JDK lacks TLS", e);
throw new UnsupportedOperationException("your device lacks TLS support?!", e);
}
}
public void initializeSSL() throws IOException, CorruptedKeystoreException, KeyManagementException {
keystoreManager.loadKeystore();
sslContext.init(
keystoreManager.getKeyManagers(),
keystoreManager.getTrustManagers(),
getSecureRandom()
);
// use an insecure context for pairing because the certificate isn't trusted yet
pairingSslContext.init(
keystoreManager.getKeyManagers(),
new TrustManager[] {new LGTMTrustManager()},
getSecureRandom()
);
socketFactory = sslContext.getSocketFactory();
pairingSocketFactory = pairingSslContext.getSocketFactory();
}
private SSLSocket openSocket(String hostname, int port, boolean pairing) throws IOException {
SocketFactory factory = pairing ? pairingSocketFactory : socketFactory;
if (factory == null) throw new IllegalStateException("must call initializeSSL() first");
Log.d(TAG, "connecting to tv at address: " + hostname + ":" + port);
SSLSocket socket = (SSLSocket) factory.createSocket(hostname, port);
try {
socket.setTcpNoDelay(true);
socket.setTrafficClass(0x10 /* lowdelay */);
socket.startHandshake();
Log.d(TAG, "CipherSuite: " + socket.getSession().getCipherSuite());
Log.d(TAG, "Protocol: " + socket.getSession().getProtocol());
Log.d(TAG, "PeerHost: " + socket.getSession().getPeerHost());
Log.d(TAG, "LocalPrincipal: " + socket.getSession().getLocalPrincipal());
return socket;
} catch (Throwable t) {
tryClose(socket);
throw t;
}
}
public TVReceiverConnection connectToTV(String hostname, int port, TVReceiverConnectionCallback callback) throws IOException, RequiresPairingException {
SSLSocket socket;
try {
socket = openSocket(hostname, port, false);
} catch (SSLException e) {
throw new RequiresPairingException(e);
}
Certificate cert = KeyUtil.getRemoteCertificate(socket);
if (cert == null) throw new IOException("TV didn't send an SSL certificate");
String token;
try {
String fingerprint = hexOf(calculateCertificateFingerprint(cert));
Log.d(TAG, "certificate fingerprint: " + fingerprint);
PairingData pairingData = pairingManager.fetchPairingData(fingerprint);
if (pairingData == null) throw new RequiresPairingException("certificate unknown");
token = pairingData.token();
} catch (CorruptedKeystoreException e) {
throw new RuntimeException("TV sent bad cert", e);
}
TVReceiverConnection connection = new TVReceiverConnection(socket, callback, token);
connection.init();
return connection;
}
public TVReceiverConnection startPairingToTV(String hostname, int port, TVReceiverConnectionCallback callback) throws IOException {
SSLSocket socket = openSocket(hostname, port, true);
TVReceiverConnection connection = new TVReceiverConnection(socket, callback);
try {
connection.init();
} catch (RequiresPairingException e) {
Log.wtf(TAG, "got RequiresPairingException while connecting for pairing!", e);
throw new AssertionError("Got RequiresPairingException during pairing. This shouldn't happen, if it does it's a bug", e);
}
return connection;
}
public KeystoreManager getKeystoreManager() {
return keystoreManager;
}
}
@@ -0,0 +1,40 @@
package io.benwiegand.atvremote.phone.network;
import android.util.Log;
import java.io.Closeable;
import java.net.Socket;
public class SocketUtil {
private static final String TAG = SocketUtil.class.getSimpleName();
public static void tryClose(Socket socket) {
if (socket.isClosed()) return;
tryClose((Closeable) socket);
}
public static void tryClose(TVReceiverConnection connection) {
if (connection.isDead()) return;
tryClose((Closeable) connection);
}
public static void tryClose(TCPReader reader) {
if (reader.isDead()) return;
tryClose((Closeable) reader);
}
public static void tryClose(TCPWriter writer) {
tryClose((Closeable) writer);
}
public static void tryClose(Closeable closeable) {
String name = closeable.getClass().getSimpleName();
try {
closeable.close();
Log.d(TAG, name + " closed: " + closeable);
} catch (Throwable t) {
Log.w(TAG, "failed to close " + name + ": " + closeable, t);
}
}
}
@@ -0,0 +1,155 @@
package io.benwiegand.atvremote.phone.network;
import static io.benwiegand.atvremote.phone.network.SocketUtil.tryClose;
import android.util.Log;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class TCPReader implements Closeable {
private final static String TAG = TCPReader.class.getSimpleName();
private final static int CHAR_BUFFER_SIZE = 1024;
private static final int MAX_LINE_BUFFER = 5;
private final InputStreamReader reader;
private final Thread readThread = new Thread(this::readLoop);
private boolean dead = false;
private IOException deathException = new IOException("unknown error");
// two locks (including lineBuffer itself) because read thread needs to wait for reads for
// buffer limit and read calls need to wait for lineBuffer to have things
private final Object lineBufferPollNotificationLock = new Object();
private final Queue<String> lineBuffer = new ConcurrentLinkedQueue<>();
public TCPReader(InputStreamReader reader) {
this.reader = reader;
readThread.start();
}
/**
* @return true if buffer limit reduced, false if interrupted before that happens
*/
private boolean waitForLineBufferLimit() {
synchronized (lineBufferPollNotificationLock) {
try {
while (lineBuffer.size() >= MAX_LINE_BUFFER) lineBufferPollNotificationLock.wait();
return true;
} catch (InterruptedException e) {
Log.d(TAG, "interrupted");
return false;
}
}
}
private void readLoop() {
Log.d(TAG, "starting read loop");
try {
char[] buffer = new char[CHAR_BUFFER_SIZE];
StringBuilder lineBuilder = new StringBuilder();
boolean cr = false;
while (!dead) {
if (lineBuffer.size() >= MAX_LINE_BUFFER) {
Log.w(TAG, "hit line buffer limit");
if (!waitForLineBufferLimit()) continue;
}
int len = reader.read(buffer);
if (len < 0) throw new IOException("EOS (got -1)");
int offset = 0;
for (int i = 0; i < len; i++) {
if (buffer[i] == '\n') {
lineBuilder.append(buffer, offset, i - offset);
String line = cr ? // remove cr for crlf compatibility
lineBuilder.substring(0, lineBuilder.length() - 1) :
lineBuilder.toString();
lineBuilder = new StringBuilder();
synchronized (lineBuffer) {
lineBuffer.add(line);
lineBuffer.notify();
}
offset = i + 1;
cr = false;
} else cr = buffer[i] == '\r';
}
lineBuilder.append(buffer, offset, len - offset);
}
deathException = new IOException("stream closed");
} catch (IOException e) {
Log.w(TAG, "read thread encountered IOException", e);
deathException = e;
} catch (RuntimeException e) {
Log.e(TAG, "read thread encountered unexpected exception", e);
deathException = new IOException("read thread encountered unexpected exception and will terminate", e);
} finally {
Log.d(TAG, "read thread terminating. dead = " + dead);
tryClose(this);
}
}
public String nextLine(long timeout) throws IOException, InterruptedException {
synchronized (lineBuffer) {
if (dead) throw new IOException(deathException);
String line = lineBuffer.poll();
if (line == null) {
lineBuffer.wait(timeout);
line = lineBuffer.poll();
}
if (line == null) {
if (dead) throw new IOException(deathException);
return null;
}
synchronized (lineBufferPollNotificationLock) {
lineBufferPollNotificationLock.notifyAll();
}
return line;
}
}
public boolean isDead() {
return dead;
}
@Override
public void close() {
dead = true;
// close reader if not already
tryClose(reader);
// stop the read thread
try {
readThread.interrupt();
} catch (SecurityException e) {
Log.wtf(TAG, "failed to interrupt read thread due to security exception", e);
}
// free up threads blocking for next line
synchronized (lineBuffer) {
lineBuffer.notifyAll();
}
}
public static TCPReader createFromStream(InputStream is, Charset cs) {
return new TCPReader(new InputStreamReader(is, cs));
}
}
@@ -0,0 +1,39 @@
package io.benwiegand.atvremote.phone.network;
import static io.benwiegand.atvremote.phone.protocol.ProtocolConstants.NEWLINE;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
public class TCPWriter implements Closeable {
private final OutputStreamWriter writer;
public TCPWriter(OutputStreamWriter writer) {
this.writer = writer;
}
public void sendLine(String line) throws IOException {
writer.write(line + NEWLINE);
writer.flush();
}
public void sendLines(String... lines) throws IOException {
for (String line : lines)
writer.write(line + NEWLINE);
writer.flush();
}
@Override
public void close() throws IOException {
writer.close();
}
public static TCPWriter createFromStream(OutputStream os, Charset cs) {
return new TCPWriter(new OutputStreamWriter(os, cs));
}
}
@@ -0,0 +1,515 @@
package io.benwiegand.atvremote.phone.network;
import static io.benwiegand.atvremote.phone.network.SocketUtil.tryClose;
import static io.benwiegand.atvremote.phone.protocol.ProtocolConstants.*;
import android.util.Log;
import java.io.Closeable;
import java.io.IOException;
import java.net.SocketException;
import java.security.cert.Certificate;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLSocket;
import io.benwiegand.atvremote.phone.auth.ssl.CorruptedKeystoreException;
import io.benwiegand.atvremote.phone.auth.ssl.KeyUtil;
import io.benwiegand.atvremote.phone.async.Sec;
import io.benwiegand.atvremote.phone.async.SecAdapter;
import io.benwiegand.atvremote.phone.control.InputHandler;
import io.benwiegand.atvremote.phone.control.OperationQueueEntry;
import io.benwiegand.atvremote.phone.protocol.RequiresPairingException;
public class TVReceiverConnection implements Closeable {
private static final String TAG = TVReceiverConnection.class.getSimpleName();
private static final int SOCKET_AUTH_TIMEOUT = 3000;
private static final long KEEPALIVE_INTERVAL = 5000;
private static final long RESPONSE_TIMEOUT = 2500;
private final ThreadPoolExecutor resultCallbackThreadPool;
private final Queue<OperationQueueEntry> operationQueue;
private final InputHandler inputForwarder;
private final SSLSocket socket;
private final Thread thread;
private final String token;
private boolean dead;
private boolean init;
private boolean tvReadyState = false;
private TCPWriter writer;
private TCPReader reader;
private final TVReceiverConnectionCallback callback;
TVReceiverConnection(SSLSocket socket, TVReceiverConnectionCallback callback, String token) {
resultCallbackThreadPool = new ThreadPoolExecutor(4, 8, 500, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>());
operationQueue = new ConcurrentLinkedQueue<>();
inputForwarder = new InputForwarder();
this.socket = socket;
this.callback = callback;
this.thread = new Thread(this::run);
this.token = token;
dead = false;
init = false;
}
TVReceiverConnection(SSLSocket socket, TVReceiverConnectionCallback callback) {
resultCallbackThreadPool = new ThreadPoolExecutor(4, 8, 500, TimeUnit.MILLISECONDS, new LinkedBlockingDeque<>());
operationQueue = new ConcurrentLinkedQueue<>();
inputForwarder = new InputForwarder();
this.socket = socket;
this.callback = callback;
this.thread = new Thread(this::run);
this.token = null;
dead = false;
init = false;
}
public InputHandler getInputForwarder() {
return inputForwarder;
}
void init() throws IOException, RequiresPairingException {
if (dead) throw new IllegalStateException("init() called after death");
if (init) throw new IllegalStateException("init() called twice");
init = true;
try {
Log.i(TAG, "connected to " + socket.getRemoteSocketAddress());
callback.onSocketConnected();
reader = TCPReader.createFromStream(socket.getInputStream(), CHARSET);
writer = TCPWriter.createFromStream(socket.getOutputStream(), CHARSET);
writer.sendLine(VERSION_1);
String line = reader.nextLine(SOCKET_AUTH_TIMEOUT);
if (line == null) throw new RuntimeException("TV didn't respond to version code. Make sure you have the right TV or try restarting the service on the TV.");
switch (line) {
case OP_CONFIRM -> Log.v(TAG, "agreed upon protocol v1");
case OP_UNSUPPORTED -> {
Log.e(TAG, "tv responded: unsupported protocol");
throw new RuntimeException("unsupported protocol version, try updating the app on this device and/or your TV");
}
default -> {
Log.e(TAG, "unexpected response from TV");
throw new RuntimeException("unexpected response");
}
}
// if no auth token provided, initiate pairing
boolean pairing = token == null;
if (pairing) {
Log.v(TAG, "initiating pairing");
writer.sendLine(INIT_OP_PAIR);
} else {
Log.v(TAG, "authenticating");
writer.sendLines(INIT_OP_CONNECT, token);
}
line = reader.nextLine(SOCKET_AUTH_TIMEOUT);
if (line == null) throw new RuntimeException("TV didn't respond to initial operation");
switch (line) {
case OP_CONFIRM -> Log.v(TAG, "handshake completed");
case OP_UNAUTHORIZED -> {
Log.e(TAG, "unauthorized");
if (pairing) throw new RuntimeException("pairing is disabled on TV");
else throw new RequiresPairingException("TV rejected auth token");
}
default -> {
Log.e(TAG, "unexpected response from tv");
throw new RuntimeException("unexpected response");
}
}
Log.i(TAG, "tv connected: " + socket.getRemoteSocketAddress());
callback.onConnected();
thread.start();
} catch (InterruptedException e) {
Log.e(TAG, "interrupted");
tryClose(this);
throw new RuntimeException("Connection deceased");
} catch (Throwable t) {
Log.e(TAG, "error during connection init", t);
tryClose(this);
throw t;
}
}
private void run() {
try {
connectionLoop();
} catch (SocketException e) {
Log.e(TAG, "socket died", e);
} catch (IOException e) {
Log.e(TAG, "IOException in connection", e);
} catch (InterruptedException e) {
Log.e(TAG, "connection loop interrupted", e);
} catch (Throwable t) {
Log.e(TAG, "unexpected error in connection", t);
} finally {
callback.onDisconnected();
tryClose(this);
}
}
private void connectionLoop() throws IOException, InterruptedException {
while (!dead) {
String status = reader.nextLine(RESPONSE_TIMEOUT);
if (status == null) {
ping();
continue;
}
switch (status) {
case OP_READY -> {
if (!tvReadyState) callback.onReadyStateChanged(true);
tvReadyState = true;
}
case OP_UNREADY -> {
Log.d(TAG, "TV is unready");
if (tvReadyState) callback.onReadyStateChanged(false);
tvReadyState = false;
continue;
}
default -> {
// connection could be in an inconsistent state
Log.wtf(TAG, "unexpected status from tv");
Log.d(TAG, status);
return;
}
}
// pop the queue
OperationQueueEntry entry = popOperationQueueBlocking();
if (entry == null) {
ping();
continue;
}
SecAdapter<String> secAdapter = entry.responseAdapter();
writer.sendLine(entry.operation());
String result = reader.nextLine(RESPONSE_TIMEOUT);
if (result == null) {
IOException exception = new IOException("response timed out");
resultCallbackThreadPool.execute(() -> secAdapter.throwError(exception));
throw exception;
}
// thread pool so the socket loop doesn't block here
resultCallbackThreadPool.execute(() -> secAdapter.provideResult(result));
}
}
public Certificate getCertificate() throws CorruptedKeystoreException {
if (dead) throw new IllegalStateException("can't get fingerprint after death");
return KeyUtil.getRemoteCertificate(socket);
}
private OperationQueueEntry popOperationQueueBlocking() throws InterruptedException {
synchronized (operationQueue) {
OperationQueueEntry entry = operationQueue.poll();
if (entry != null) return entry;
operationQueue.wait(TVReceiverConnection.KEEPALIVE_INTERVAL);
return operationQueue.poll();
}
}
private void ping() throws IOException, InterruptedException {
assert init;
if (dead) throw new IOException("this connection is dead");
// Log.d(TAG, "ping!");
writer.sendLine(OP_PING);
String result = reader.nextLine(RESPONSE_TIMEOUT);
if (!OP_CONFIRM.equals(result))
throw new IOException("sent ping but didn't get a pong");
// Log.d(TAG, "pong!");
}
private Sec<Void> addBasicOperation(String operation) {
synchronized (operationQueue) {
SecAdapter.SecWithAdapter<String> secWithAdapter = SecAdapter.createThreadless();
operationQueue.add(new OperationQueueEntry(secWithAdapter.secAdapter(), operation));
operationQueue.notifyAll();
return secWithAdapter.sec()
.map(r -> switch (r) {
case OP_CONFIRM -> null;
case OP_ERR -> {
Log.e(TAG, "operation failed");
throw new RuntimeException("tv replied: error");
}
case OP_UNSUPPORTED -> {
Log.e(TAG, "operation unsupported");
throw new UnsupportedOperationException("operation not supported by tv");
}
default -> throw new RuntimeException("unexpected response from tv");
});
}
}
private Sec<Void> addBasicOperation(String... operation) {
return addBasicOperation(String.join(" ", operation));
}
public Sec<String> sendPairingCode(String code) {
synchronized (operationQueue) {
SecAdapter.SecWithAdapter<String> secWithAdapter = SecAdapter.createThreadless();
operationQueue.add(new OperationQueueEntry(secWithAdapter.secAdapter(), code));
operationQueue.notifyAll();
return secWithAdapter.sec()
.map(r -> switch (r) {
case OP_UNAUTHORIZED -> {
Log.e(TAG, "bad pairing code");
throw new RuntimeException("pairing code rejected");
}
case OP_UNSUPPORTED -> {
Log.e(TAG, "operation unsupported");
throw new UnsupportedOperationException("operation not supported by tv");
}
default -> r;
});
}
}
public class InputForwarder implements InputHandler {
@Override
public Sec<Void> dpadDown() {
return addBasicOperation(OP_DPAD_DOWN);
}
@Override
public Sec<Void> dpadUp() {
return addBasicOperation(OP_DPAD_UP);
}
@Override
public Sec<Void> dpadLeft() {
return addBasicOperation(OP_DPAD_LEFT);
}
@Override
public Sec<Void> dpadRight() {
return addBasicOperation(OP_DPAD_RIGHT);
}
@Override
public Sec<Void> dpadSelect() {
return addBasicOperation(OP_DPAD_SELECT);
}
@Override
public Sec<Void> dpadLongPress() {
return addBasicOperation(OP_DPAD_LONG_PRESS);
}
@Override
public Sec<Void> navHome() {
return addBasicOperation(OP_NAV_HOME);
}
@Override
public Sec<Void> navBack() {
return addBasicOperation(OP_NAV_BACK);
}
@Override
public Sec<Void> navRecent() {
return addBasicOperation(OP_NAV_RECENT);
}
@Override
public Sec<Void> navApps() {
return addBasicOperation(OP_NAV_APPS);
}
@Override
public Sec<Void> navNotifications() {
return addBasicOperation(OP_NAV_NOTIFICATIONS);
}
@Override
public Sec<Void> navQuickSettings() {
return addBasicOperation(OP_NAV_QUICK_SETTINGS);
}
@Override
public Sec<Void> volumeUp() {
return addBasicOperation(OP_VOLUME_UP);
}
@Override
public Sec<Void> volumeDown() {
return addBasicOperation(OP_VOLUME_DOWN);
}
@Override
public Sec<Void> mute() {
return addBasicOperation(OP_MUTE);
}
@Override
public Sec<Void> pause() {
return addBasicOperation(OP_PAUSE);
}
@Override
public Sec<Void> nextTrack() {
return addBasicOperation(OP_NEXT_TRACK);
}
@Override
public Sec<Void> prevTrack() {
return addBasicOperation(OP_PREV_TRACK);
}
@Override
public Sec<Void> skipBackward() {
return addBasicOperation(OP_SKIP_BACKWARD);
}
@Override
public Sec<Void> skipForward() {
return addBasicOperation(OP_SKIP_FORWARD);
}
@Override
public boolean softKeyboardEnabled() {
// TODO
return false;
}
@Override
public boolean softKeyboardVisible() {
// TODO
return false;
}
@Override
public Sec<Void> showSoftKeyboard() {
return null;
// TODO
}
@Override
public Sec<Void> hideSoftKeyboard() {
return null;
// TODO
}
@Override
public Sec<Void> setSoftKeyboardEnabled(boolean enabled) {
return null;
// TODO
}
@Override
public Sec<Void> keyboardInput(String input) {
return null;
// TODO
}
@Override
public boolean cursorSupported() {
// TODO
return false;
}
@Override
public Sec<Void> showCursor() {
return addBasicOperation(OP_CURSOR_SHOW);
}
@Override
public Sec<Void> hideCursor() {
return addBasicOperation(OP_CURSOR_HIDE);
}
@Override
public Sec<Void> cursorMove(int x, int y) {
return addBasicOperation(OP_CURSOR_MOVE, String.valueOf(x), String.valueOf(y));
}
@Override
public Sec<Void> cursorDown() {
return addBasicOperation(OP_CURSOR_DOWN);
}
@Override
public Sec<Void> cursorUp() {
return addBasicOperation(OP_CURSOR_UP);
}
@Override
public Sec<Void> cursorContext() {
return null;
// TODO
}
@Override
public Sec<Void> scrollVertical(double trajectory, boolean glide) {
return null;
// TODO
}
@Override
public Sec<Void> scrollHorizontal(double trajectory, boolean glide) {
return null;
// TODO
}
}
public boolean isDead() {
return dead;
}
public void close() {
Log.d(TAG, "close()");
dead = true;
thread.interrupt();
OperationQueueEntry entry;
while ((entry = operationQueue.poll()) != null) {
try {
entry.responseAdapter().throwError(new IOException("connection closed"));
} catch (IllegalStateException e) {
// todo: maybe close() is being called multiple times across multiple threads
// todo: check if the poll() fix works
Log.wtf(TAG, "(bug) popped queue item isn't popped (but it had to be popped)", e);
throw e;
}
}
resultCallbackThreadPool.shutdown();
tryClose(socket);
tryClose(reader);
tryClose(writer);
}
}
@@ -0,0 +1,9 @@
package io.benwiegand.atvremote.phone.network;
public interface TVReceiverConnectionCallback {
void onSocketConnected();
void onConnected();
void onReadyStateChanged(boolean ready);
void onDisconnected();
}
@@ -0,0 +1,12 @@
package io.benwiegand.atvremote.phone.network.discovery;
import android.net.nsd.NsdServiceInfo;
public interface ServiceDiscoveryCallback {
void serviceDiscovered(String key, NsdServiceInfo serviceInfo);
void serviceLost(String key);
void discoveryStarted();
void discoveryStopped();
void discoveryFailure(ServiceDiscoveryException e);
}
@@ -0,0 +1,7 @@
package io.benwiegand.atvremote.phone.network.discovery;
public class ServiceDiscoveryException extends RuntimeException {
public ServiceDiscoveryException(String message) {
super(message);
}
}
@@ -0,0 +1,102 @@
package io.benwiegand.atvremote.phone.network.discovery;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.util.Log;
public class ServiceExplorer implements NsdManager.DiscoveryListener {
private static final String TAG = ServiceExplorer.class.getSimpleName();
private final NsdManager nsdManager;
private final ServiceDiscoveryCallback discoveryCallback;
public ServiceExplorer(NsdManager nsdManager, ServiceDiscoveryCallback discoveryCallback) {
this.nsdManager = nsdManager;
this.discoveryCallback = discoveryCallback;
}
public void startDiscovery(String serviceType) {
nsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, this);
}
public void stopDiscovery() {
nsdManager.stopServiceDiscovery(this);
}
@Override
public void onDiscoveryStarted(String serviceType) {
Log.i(TAG, "discovery started for service type: " + serviceType);
discoveryCallback.discoveryStarted();
}
@Override
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
ServiceDiscoveryException e = createExceptionForErrorCode(errorCode);
Log.e(TAG, "discovery failed for service type: " + serviceType, e);
discoveryCallback.discoveryFailure(e);
}
@Override
public void onServiceFound(NsdServiceInfo serviceInfo) {
Log.v(TAG, "found service: " + serviceInfo);
tryResolve(serviceInfo, 3);
}
private void tryResolve(NsdServiceInfo serviceInfo, int maxTries) {
nsdManager.resolveService(serviceInfo, new NsdManager.ResolveListener() {
@Override
public void onServiceResolved(NsdServiceInfo serviceInfo) {
Log.v(TAG, "resolved: " + serviceInfo);
discoveryCallback.serviceDiscovered(serviceInfo.getServiceName(), serviceInfo);;
}
@Override
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
ServiceDiscoveryException e = createExceptionForErrorCode(errorCode);
Log.w(TAG, "resolve failed for: " + serviceInfo, e);
if (maxTries > 0)
tryResolve(serviceInfo, maxTries - 1);
else
Log.e(TAG, "giving up resolving: " + serviceInfo);
}
});
}
@Override
public void onServiceLost(NsdServiceInfo serviceInfo) {
Log.v(TAG, "service lost: " + serviceInfo);
discoveryCallback.serviceLost(serviceInfo.getServiceName());
}
@Override
public void onDiscoveryStopped(String serviceType) {
Log.i(TAG, "service discovery stopped for service type: " + serviceType);
discoveryCallback.discoveryStopped();
}
@Override
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
ServiceDiscoveryException e = createExceptionForErrorCode(errorCode);
Log.e(TAG, "stop discovery failed for service type: " + serviceType, e);
nsdManager.stopServiceDiscovery(this);
discoveryCallback.discoveryFailure(e);
}
private ServiceDiscoveryException createExceptionForErrorCode(int errorCode) {
return new ServiceDiscoveryException(mapErrorCodeDebugString(errorCode));
}
private String mapErrorCodeDebugString(int errorCode) {
return switch (errorCode) {
case NsdManager.FAILURE_INTERNAL_ERROR -> "FAILURE_INTERNAL_ERROR";
case NsdManager.FAILURE_ALREADY_ACTIVE -> "FAILURE_ALREADY_ACTIVE";
case NsdManager.FAILURE_MAX_LIMIT -> "FAILURE_MAX_LIMIT";
case NsdManager.FAILURE_OPERATION_NOT_RUNNING -> "FAILURE_OPERATION_NOT_RUNNING";
case NsdManager.FAILURE_BAD_PARAMETERS -> "FAILURE_BAD_PARAMETERS";
default -> "(UNKNOWN_ERROR_CODE " + errorCode + ")";
};
}
}
@@ -0,0 +1,54 @@
package io.benwiegand.atvremote.phone.protocol;
import android.content.SharedPreferences;
import java.time.Instant;
import io.benwiegand.atvremote.phone.util.ByteUtil;
public record PairingData(String token, String fingerprint, String friendlyName, String lastConnectedIpAddress, long lastConnectedTimestamp) {
public PairingData(String token, byte[] fingerprint, String friendlyName, String lastConnectedIpAddress, long lastConnectedTimestamp) {
this(token, ByteUtil.hexOf(fingerprint), friendlyName, lastConnectedIpAddress, lastConnectedTimestamp);
}
public Instant lastConnectedInstant() {
if (lastConnectedTimestamp() < 0) return null;
return Instant.ofEpochSecond(lastConnectedTimestamp());
}
// for shared preferences
public static final String KEY_TOKEN = "token";
public static final String KEY_FINGERPRINT = "fingerprint";
public static final String KEY_FRIENDLY_NAME = "name";
public static final String KEY_LAST_CONNECTED_IP_ADDRESS = "addr";
public static final String KEY_LAST_CONNECTED_TIMESTAMP = "last_connected";
public static PairingData readFromPreferences(SharedPreferences sp) {
// token and fingerprint are required
String token = sp.getString(KEY_TOKEN, null);
String fingerprint = sp.getString(KEY_FINGERPRINT, null);
if (token == null || fingerprint == null) return null;
// there are better ways of doing this. reflection is one of them. last time I tried
// reflection in an AOSP build it broke. this can always be replaced with a better solution.
// if you are reading this and have a better solution, please contribute it I would really appreciate it.
return new PairingData(
token,
fingerprint,
sp.getString(KEY_FRIENDLY_NAME, null),
sp.getString(KEY_LAST_CONNECTED_IP_ADDRESS, null),
sp.getLong(KEY_LAST_CONNECTED_TIMESTAMP, -1)
);
}
public boolean writeToPreferences(SharedPreferences.Editor spe) {
return spe.clear()
.putString(KEY_TOKEN, token())
.putString(KEY_FINGERPRINT, fingerprint())
.putString(KEY_FRIENDLY_NAME, friendlyName())
.putString(KEY_LAST_CONNECTED_IP_ADDRESS, lastConnectedIpAddress())
.putLong(KEY_LAST_CONNECTED_TIMESTAMP, lastConnectedTimestamp())
.commit();
}
}
@@ -0,0 +1,110 @@
package io.benwiegand.atvremote.phone.protocol;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import java.io.IOException;
import java.security.cert.Certificate;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import io.benwiegand.atvremote.phone.auth.ssl.CorruptedKeystoreException;
import io.benwiegand.atvremote.phone.auth.ssl.KeystoreManager;
public class PairingManager {
private static final String TAG = PairingManager.class.getSimpleName();
private static final String KEY_PAIRED_DEVICES = "tvs";
private static final String KEY_PREFIX_PAIRING_DATA = "pairing_data_";
private final Context context;
private final KeystoreManager keystoreManager;
private final Map<String, String> fingerprintMap = new HashMap<>();
public PairingManager(Context context, KeystoreManager keystoreManager) {
this.context = context;
this.keystoreManager = keystoreManager;
loadPairedDevices();
}
private void loadPairedDevices() {
synchronized (fingerprintMap) {
Log.d(TAG, "loading ssl fingerprint map");
fingerprintMap.clear();
SharedPreferences sp = context.getSharedPreferences(KEY_PAIRED_DEVICES, Context.MODE_PRIVATE);
for (Map.Entry<String, ?> entry : sp.getAll().entrySet()) {
String deviceId = entry.getKey();
if (entry.getValue() instanceof String fingerprint)
fingerprintMap.put(fingerprint, deviceId);
else
Log.wtf(TAG, "non-string value in paired devices table for key: " + deviceId);
}
}
}
public boolean addNewDevice(Certificate certificate, PairingData data) throws IOException, CorruptedKeystoreException {
synchronized (fingerprintMap) {
String deviceId = UUID.randomUUID().toString();
boolean committed = context.getSharedPreferences(KEY_PAIRED_DEVICES, Context.MODE_PRIVATE)
.edit()
.putString(deviceId, data.fingerprint())
.commit();
if (!committed) {
Log.wtf(TAG, "failed to write token to preference map");
return false;
}
fingerprintMap.put(data.fingerprint(), deviceId);
keystoreManager.addCertificate(deviceId, certificate);
keystoreManager.saveKeystore();
return writePairingData(deviceId, data);
}
}
private boolean writePairingData(String deviceId, PairingData data) {
return data.writeToPreferences(sharedPreferencesForDevice(deviceId).edit());
}
public PairingData fetchPairingData(String fingerprint) {
String deviceId;
synchronized (fingerprintMap) {
deviceId = fingerprintMap.get(fingerprint);
if (deviceId == null) return null;
}
PairingData data = PairingData.readFromPreferences(sharedPreferencesForDevice(deviceId));
if (data != null && !fingerprint.equals(data.fingerprint())) {
// this would best be solved with re-pairing
Log.w(TAG, "fingerprint miss-match for entry: " + deviceId);
return null;
}
return data;
}
private SharedPreferences sharedPreferencesForDevice(String deviceId) {
String key = KEY_PREFIX_PAIRING_DATA + deviceId;
Log.d(TAG, "loading " + key);
return context.getSharedPreferences(key, Context.MODE_PRIVATE);
}
public boolean updatePairingData(PairingData pairingData) {
String deviceId;
synchronized (fingerprintMap) {
deviceId = fingerprintMap.get(pairingData.token());
if (deviceId == null) return false;
}
return writePairingData(deviceId, pairingData);
}
}
@@ -0,0 +1,63 @@
package io.benwiegand.atvremote.phone.protocol;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class ProtocolConstants {
// a very barbaric yet functional and simple protocol that's hard to fuck up
public static final String NEWLINE = "\n"; // we do LF 'round here
public static final Charset CHARSET = StandardCharsets.UTF_8;
// version
public static final String VERSION_1 = "v1";
// responses
public static final String OP_CONFIRM = "OK";
public static final String OP_READY = "RDY";
public static final String OP_UNREADY = "WAIT";
public static final String OP_ERR = "ERR";
public static final String OP_UNAUTHORIZED = "BAD_AUTH";
public static final String OP_UNSUPPORTED = "HUH?";
// init operaions
public static final String INIT_OP_PAIR = "PAIR";
public static final String INIT_OP_CONNECT = "CONN";
// global operations
public static final String OP_PING = "PING";
// remote control operations
public static final String OP_DPAD_UP = "DPAD_UP";
public static final String OP_DPAD_DOWN = "DPAD_DOWN";
public static final String OP_DPAD_LEFT = "DPAD_LEFT";
public static final String OP_DPAD_RIGHT = "DPAD_RIGHT";
public static final String OP_DPAD_SELECT = "DPAD_SELECT";
public static final String OP_DPAD_LONG_PRESS = "DPAD_HOLD";
public static final String OP_NAV_HOME = "NAV_HOME";
public static final String OP_NAV_BACK = "NAV_BACK";
public static final String OP_NAV_RECENT = "NAV_RECENT";
public static final String OP_NAV_APPS = "NAV_APPS";
public static final String OP_NAV_NOTIFICATIONS = "NAV_NOTIFICATIONS";
public static final String OP_NAV_QUICK_SETTINGS = "NAV_QUICK_SETTINGS";
public static final String OP_VOLUME_UP = "VOL_UP";
public static final String OP_VOLUME_DOWN = "VOL_DOWN";
public static final String OP_MUTE = "MUTE";
public static final String OP_PAUSE = "PAUSE";
public static final String OP_NEXT_TRACK = "NEXT_TRACK";
public static final String OP_PREV_TRACK = "PREV_TRACK";
public static final String OP_SKIP_BACKWARD = "SKIP_BACKWARD";
public static final String OP_SKIP_FORWARD = "SKIP_FORWARD";
public static final String OP_CURSOR_SHOW = "CURSOR_SHOW";
public static final String OP_CURSOR_HIDE = "CURSOR_HIDE";
public static final String OP_CURSOR_MOVE = "CURSOR_MOVE";
public static final String OP_CURSOR_DOWN = "CURSOR_DOWN";
public static final String OP_CURSOR_UP = "CURSOR_UP";
public static final String MDNS_SERVICE_TYPE = "_atv_remote_receiver_bw._tcp";
}
@@ -0,0 +1,12 @@
package io.benwiegand.atvremote.phone.protocol;
public class RequiresPairingException extends Exception {
public RequiresPairingException(String message) {
super(message);
}
public RequiresPairingException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,41 @@
package io.benwiegand.atvremote.phone.stuff;
import android.annotation.SuppressLint;
import android.util.Log;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import javax.net.ssl.X509TrustManager;
// this trust manager always accepts.
// it's to be used during pairing so that the pairing can actually happen.
// otherwise, the connection won't open because the cert isn't trusted.
// in other words, an intentionally insecure trust manager for certs that aren't trusted *yet*.
@SuppressLint("CustomX509TrustManager") // see above
public class LGTMTrustManager implements X509TrustManager {
private static final String TAG = LGTMTrustManager.class.getSimpleName();
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
Log.d(TAG, "got cert chain 4 client: " + Arrays.toString(chain));
Log.d(TAG, "auth type: " + authType);
Log.d(TAG, "LGTM");
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
Log.d(TAG, "got cert chain 4 server: " + Arrays.toString(chain));
Log.d(TAG, "auth type: " + authType);
Log.d(TAG, "LGTM");
}
@Override
public X509Certificate[] getAcceptedIssuers() {
Log.d(TAG, "accepted issuers");
return new X509Certificate[0];
}
}
@@ -0,0 +1,34 @@
package io.benwiegand.atvremote.phone.stuff;
import java.util.concurrent.atomic.AtomicInteger;
public class SerialInt {
private final AtomicInteger currentSerial = new AtomicInteger(0);
/**
* increases serial by 1, invalidating previous serial.
* @return the new serial
*/
public int advance() {
// roll over from MAX_VALUE to MIN_VALUE
return currentSerial.updateAndGet(i -> i == Integer.MAX_VALUE ? Integer.MIN_VALUE : i+1);
}
/**
* gets the current serial
* @return the current serial
*/
public int get() {
return currentSerial.get();
}
/**
* check whether the provided serial is still valid
* @param serial serial to check
* @return true if that serial is valid
*/
public boolean isValid(int serial) {
return get() == serial;
}
}
@@ -0,0 +1,144 @@
package io.benwiegand.atvremote.phone.ui;
import static io.benwiegand.atvremote.phone.network.SocketUtil.tryClose;
import static io.benwiegand.atvremote.phone.util.ErrorUtil.generateErrorDescription;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatActivity;
import java.io.IOException;
import java.net.UnknownHostException;
import java.security.KeyManagementException;
import java.text.MessageFormat;
import java.util.Timer;
import java.util.TimerTask;
import io.benwiegand.atvremote.phone.R;
import io.benwiegand.atvremote.phone.auth.ssl.CorruptedKeystoreException;
import io.benwiegand.atvremote.phone.network.ConnectionService;
import io.benwiegand.atvremote.phone.network.TVReceiverConnection;
import io.benwiegand.atvremote.phone.protocol.RequiresPairingException;
public abstract class ConnectingActivity extends AppCompatActivity {
private static final String TAG = ConnectingActivity.class.getSimpleName();
private static final long CONNECT_RETRY_DELAY = 1500;
// intent extras
public static final String EXTRA_DEVICE_NAME = "name";
public static final String EXTRA_HOSTNAME = "addr";
public static final String EXTRA_PORT_NUMBER = "port";
// threads
private Timer timer = null;
// connection
protected ConnectionService connectionService;
protected TVReceiverConnection connection = null;
protected String deviceName;
protected String remoteHostname;
protected int remotePort;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// parse extras
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
remoteHostname = getIntent().getStringExtra(EXTRA_HOSTNAME);
remotePort = getIntent().getIntExtra(EXTRA_PORT_NUMBER, -1);
if (remoteHostname == null || remotePort < 0 || remotePort > 65535) {
Log.e(TAG, "PairingActivity not launched with required intent extras");
setResult(-1);
finish();
return;
}
if (deviceName == null) deviceName = remoteHostname;
// start connection timer
timer = new Timer();
// connection
connectionService = new ConnectionService(this);
try {
connectionService.initializeSSL();
} catch (IOException e) {
Log.e(TAG, "failed to load keystore", e);
showError(R.string.init_failure, MessageFormat.format(getString(R.string.init_failure_desc_general), generateErrorDescription(e)));
// todo: also allow delete/retry, the keystore might be corrupted
} catch (KeyManagementException | CorruptedKeystoreException e) {
Log.e(TAG, "keystore is corrupted", e);
// todo: delete/retry
showError(R.string.init_failure, MessageFormat.format(getString(R.string.init_failure_desc_corrupted_keystore), generateErrorDescription(e)));
} catch (UnsupportedOperationException e) {
Log.e(TAG, "device unsupported?", e);
showError(R.string.init_failure, MessageFormat.format(getString(R.string.init_failure_desc_unsupported), generateErrorDescription(e)));
} catch (RuntimeException e) {
Log.e(TAG, "unexpected error", e);
showError(R.string.init_failure, MessageFormat.format(getString(R.string.init_failure_desc_unexpected_error), generateErrorDescription(e)));
}
}
@Override
protected void onDestroy() {
super.onDestroy();
Log.d(TAG, "onDestroy()");
timer.cancel();
if (connection != null) tryClose(connection);
}
protected abstract void showError(@StringRes int title, String description);
protected void scheduleConnect(long delay) {
try {
timer.schedule(new TimerTask() {
@Override
public void run() {
initConnection();
}
}, delay);
} catch (IllegalStateException e) {
Log.w(TAG, "task not scheduled, timer is dead");
}
}
private void initConnection() {
if (connection != null) tryClose(connection);
try {
connection = connectToTV();
} catch (UnknownHostException e) {
// todo: somehow inform the user without popup
Log.e(TAG, "Unknown host", e);
scheduleConnect(CONNECT_RETRY_DELAY);
} catch (IOException e) {
Log.e(TAG, "got IOException while connecting to TV", e);
scheduleConnect(CONNECT_RETRY_DELAY);
} catch (InterruptedException e) {
Log.d(TAG, "interrupted");
finish(); // assume termination
} catch (RuntimeException e) {
Log.e(TAG, "unexpected error", e);
showError(R.string.negotiation_failure, MessageFormat.format(getString(R.string.negotiation_failure_desc_unexpected_error), generateErrorDescription(e)));
} catch (RequiresPairingException e) {
Log.i(TAG, "not paired, starting pairing: " + e.getMessage());
Intent intent = new Intent(this, PairingActivity.class)
.putExtra(EXTRA_DEVICE_NAME, deviceName)
.putExtra(EXTRA_HOSTNAME, remoteHostname)
.putExtra(EXTRA_PORT_NUMBER, remotePort);
startActivity(intent);
finish();
}
}
protected abstract TVReceiverConnection connectToTV() throws RequiresPairingException, IOException, InterruptedException;
}
@@ -0,0 +1,37 @@
package io.benwiegand.atvremote.phone.ui;
import android.content.Intent;
import android.os.Bundle;
import android.widget.EditText;
import androidx.appcompat.app.AppCompatActivity;
import io.benwiegand.atvremote.phone.R;
public class DebugActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_debug);
findViewById(R.id.connect_button).setOnClickListener(v -> {
EditText ipAddressText = findViewById(R.id.ip_address_text);
EditText portText = findViewById(R.id.port_text);
String ipAddress = ipAddressText.getText().toString();
int port = Integer.parseInt(portText.getText().toString());
Intent intent = new Intent(this, RemoteActivity.class);
intent.putExtra(RemoteActivity.EXTRA_HOSTNAME, ipAddress);
intent.putExtra(RemoteActivity.EXTRA_PORT_NUMBER, port);
startActivity(intent);
});
}
}
@@ -0,0 +1,287 @@
package io.benwiegand.atvremote.phone.ui;
import static android.view.inputmethod.EditorInfo.IME_ACTION_DONE;
import static io.benwiegand.atvremote.phone.network.SocketUtil.tryClose;
import static io.benwiegand.atvremote.phone.util.ErrorUtil.generateErrorDescription;
import android.content.Intent;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.LayoutRes;
import androidx.annotation.StringRes;
import java.io.IOException;
import java.security.cert.Certificate;
import java.text.MessageFormat;
import java.time.Instant;
import io.benwiegand.atvremote.phone.R;
import io.benwiegand.atvremote.phone.auth.ssl.CorruptedKeystoreException;
import io.benwiegand.atvremote.phone.auth.ssl.KeyUtil;
import io.benwiegand.atvremote.phone.network.TVReceiverConnection;
import io.benwiegand.atvremote.phone.network.TVReceiverConnectionCallback;
import io.benwiegand.atvremote.phone.protocol.PairingData;
import io.benwiegand.atvremote.phone.protocol.PairingManager;
import io.benwiegand.atvremote.phone.util.ByteUtil;
import io.benwiegand.atvremote.phone.util.UiUtil;
public class PairingActivity extends ConnectingActivity {
private static final String TAG = PairingActivity.class.getSimpleName();
// ui
private View layoutView = null;
// pairing secrets
private Certificate certificate = null;
private byte[] fingerprint = null;
private String pairingCode = null;
// connection
private ConnectionCallback connectionCallback = null;
// misc
private final Handler handler = new Handler(Looper.getMainLooper());
private PairingManager pairingManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_pairing);
pairingManager = new PairingManager(this, connectionService.getKeystoreManager());
startConnecting();
}
@Override
protected void onDestroy() {
Log.d(TAG, "onDestroy");
super.onDestroy();
}
@Override
protected void showError(int title, String description) {
showErrorScreen(title, description);
}
private void startConnecting() {
showLoadingScreen(R.string.title_pairing_connecting, MessageFormat.format(getString(R.string.description_pairing_connecting), deviceName));
scheduleConnect(0L);
}
private void sendPairingCode() {
showLoadingScreen(R.string.title_pairing_authenticating, MessageFormat.format(getString(R.string.description_pairing_authenticating), deviceName));
connectionCallback = null;
connection.sendPairingCode(pairingCode)
.doOnError(t -> {
Log.e(TAG, "failed to get token", t);
tryClose(connection);
showErrorScreen(R.string.title_pairing_error, generateErrorDescription(t));
})
.doOnResult(token -> {
Log.d(TAG, "received token");
tryClose(connection);
try {
boolean committed = pairingManager.addNewDevice(certificate, new PairingData(token, fingerprint, deviceName, remoteHostname, Instant.now().getEpochSecond()));
if (!committed) throw new RuntimeException("failed to commit pairing data");
} catch (Throwable t) {
showErrorScreen(R.string.title_pairing_error, generateErrorDescription(t));
return;
}
Log.i(TAG, "pairing successful");
launchRemote();
})
.callMeWhenDone();
}
private void launchRemote() {
runOnUiThread(() -> {
Intent intent = new Intent(this, RemoteActivity.class)
.putExtra(EXTRA_DEVICE_NAME, deviceName)
.putExtra(EXTRA_HOSTNAME, remoteHostname)
.putExtra(EXTRA_PORT_NUMBER, remotePort);
startActivity(intent);
finish();
});
}
@Override
protected TVReceiverConnection connectToTV() throws IOException {
connectionCallback = new ConnectionCallback(); // rotate callback to avoid events from previous connection
// todo: also do the above in remote activity
TVReceiverConnection newConnection = connectionService.startPairingToTV(remoteHostname, remotePort, connectionCallback);
try {
certificate = newConnection.getCertificate();
if (certificate == null) throw new IOException("TV did not send an SSL certificate");
fingerprint = KeyUtil.calculateCertificateFingerprint(certificate);
} catch (CorruptedKeystoreException e) {
certificate = null;
fingerprint = null;
tryClose(connection);
throw new IOException("TV send a bad SSL certificate", e);
} catch (Throwable t) {
certificate = null;
fingerprint = null;
tryClose(connection);
throw t;
}
return newConnection;
}
private class ConnectionCallback implements TVReceiverConnectionCallback {
private boolean isInvalid() {
return this != connectionCallback;
}
@Override
public void onSocketConnected() {
if (isInvalid()) return;
showLoadingScreen(R.string.title_pairing_hand_shaking, MessageFormat.format(getString(R.string.description_pairing_hand_shaking), deviceName));
}
@Override
public void onConnected() {
if (isInvalid()) return;
showPairingCodeScreen();
}
@Override
public void onReadyStateChanged(boolean ready) {
// todo: this would indicate inability to show pairing prompt
}
@Override
public void onDisconnected() {
if (isInvalid()) return;
showErrorScreen(R.string.title_pairing_error, MessageFormat.format(getString(R.string.description_pairing_error_connection_lost), deviceName));
}
}
private void showLoadingScreen(@StringRes int title, String description) {
runOnUiThread(() -> {
View layout = switchLayout(R.layout.layout_pairing_loading);
TextView titleText = layout.findViewById(R.id.title_text);
titleText.setText(title);
TextView descriptionText = layout.findViewById(R.id.description_text);
descriptionText.setText(description);
layout.findViewById(R.id.cancel_button)
.setOnClickListener(v -> finish());
});
}
private void showPairingCodeScreen() {
runOnUiThread(() -> {
View layout = switchLayout(R.layout.layout_pairing_code);
EditText pairingCodeText = layout.findViewById(R.id.pairing_code_text);
Button nextButton = layout.findViewById(R.id.next_button);
layout.findViewById(R.id.cancel_button)
.setOnClickListener(v -> finish());
pairingCodeText.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == IME_ACTION_DONE) return nextButton.performClick();
return false;
});
nextButton.setOnClickListener(v -> {
pairingCode = pairingCodeText.getText().toString();
if (pairingCode.length() != 6) {
pairingCodeText.setError(getString(R.string.error_pairing_code_length));
return;
}
showFingerprintScreen();
});
});
}
private void showFingerprintScreen() {
runOnUiThread(() -> {
View layout = switchLayout(R.layout.layout_pairing_fingerprint);
TextView elevatedText = layout.findViewById(R.id.fingerprint_elevated_text);
elevatedText.setText("70 D0"); // todo
TextView fullText = layout.findViewById(R.id.fingerprint_full_text);
fullText.setText(ByteUtil.hexOf(fingerprint, " ", false));
Button matchButton = layout.findViewById(R.id.match_button);
matchButton.setOnClickListener(v -> sendPairingCode());
Button noMatchButton = layout.findViewById(R.id.no_match_button);
noMatchButton.setOnClickListener(v ->
showErrorScreen(R.string.title_pairing_error, R.string.description_pairing_error_fingerprint_differs));
// don't immediately enable buttons to prevent user from skipping through this part.
// they probably will anyway, but I tried.
handler.postDelayed(() -> {
matchButton.setEnabled(true);
noMatchButton.setEnabled(true);
}, 5000);
layout.findViewById(R.id.back_button)
.setOnClickListener(v -> showPairingCodeScreen());
});
}
private void showErrorScreen(@StringRes int title, @StringRes int description) {
showErrorScreen(title, getString(description));
}
private void showErrorScreen(@StringRes int title, String description) {
runOnUiThread(() -> {
View layout = switchLayout(R.layout.layout_pairing_failure);
TextView titleText = layout.findViewById(R.id.title_text);
titleText.setText(title);
TextView descriptionText = layout.findViewById(R.id.description_text);
descriptionText.setText(description);
layout.findViewById(R.id.cancel_button)
.setOnClickListener(v -> finish());
layout.findViewById(R.id.retry_button)
.setOnClickListener(v -> startConnecting());
});
}
private View switchLayout(@LayoutRes int layout) {
// discount fragments. I know fragments exist, but I'm lazy and this feels easier
ViewGroup root = findViewById(R.id.root);
View oldLayout = layoutView;
layoutView = getLayoutInflater().inflate(layout, root, false);
root.addView(layoutView);
if (oldLayout != null) oldLayout.animate()
.setInterpolator(UiUtil.EASE_IN)
.translationX(-root.getWidth())
.withEndAction(() -> root.removeView(oldLayout))
.start();
layoutView.setTranslationX(root.getWidth());
layoutView.animate()
.setInterpolator(UiUtil.EASE_OUT)
.translationX(0)
.start();
return layoutView;
}
}
@@ -0,0 +1,242 @@
package io.benwiegand.atvremote.phone.ui;
import android.os.Bundle;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.util.Log;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.StringRes;
import com.google.android.material.navigation.NavigationBarView;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import io.benwiegand.atvremote.phone.R;
import io.benwiegand.atvremote.phone.async.Sec;
import io.benwiegand.atvremote.phone.control.InputHandler;
import io.benwiegand.atvremote.phone.network.TVReceiverConnection;
import io.benwiegand.atvremote.phone.network.TVReceiverConnectionCallback;
import io.benwiegand.atvremote.phone.protocol.RequiresPairingException;
import io.benwiegand.atvremote.phone.ui.view.RemoteButton;
import io.benwiegand.atvremote.phone.ui.view.TrackpadButton;
import io.benwiegand.atvremote.phone.ui.view.TrackpadSurface;
public class RemoteActivity extends ConnectingActivity implements TVReceiverConnectionCallback {
private static final String TAG = RemoteActivity.class.getSimpleName();
private static final VibrationEffect CLICK_VIBRATION_EFFECT = VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK);
private static final VibrationEffect LONG_CLICK_VIBRATION_EFFECT = VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK);
private static final int BUTTON_REPEAT_DELAY = 420;
// connection
private InputHandler inputHandler = null;
// ui
private final List<View> tvControlButtons = new ArrayList<>(/* todo: define size */);
private Vibrator vibrator;
@Override
protected void onCreate(Bundle savedInstanceState) {
Log.d(TAG, "onCreate()");
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_remote);
// ui setup
TextView tvNameText = findViewById(R.id.connected_tv_name_text);
tvNameText.setText(deviceName);
vibrator = getSystemService(Vibrator.class);
setupButtons();
// connect to tv
scheduleConnect(0L);
}
@Override
protected void showError(int title, String description) {
// todo
}
@Override
protected TVReceiverConnection connectToTV() throws RequiresPairingException, IOException {
runOnUiThread(() -> {
setConnectionStatus(R.string.connection_status_connecting, true);
setControlsEnabled(false);
});
TVReceiverConnection newConnection = connectionService.connectToTV(remoteHostname, remotePort, this);
inputHandler = newConnection.getInputForwarder();
return newConnection;
}
private void setControlsEnabled(boolean enabled) {
runOnUiThread(() ->
tvControlButtons.forEach(b -> b.setEnabled(enabled)));
}
private void setConnectionStatus(@StringRes int text, boolean connecting) {
TextView connectionStatusText = findViewById(R.id.connection_status_text);
connectionStatusText.setText(text);
findViewById(R.id.connecting_indicator)
.setVisibility(connecting ? View.VISIBLE : View.INVISIBLE);
}
private void switchToLayout(@LayoutRes int layout) {
FrameLayout remoteFrame = findViewById(R.id.remote_frame);
remoteFrame.removeAllViews();
tvControlButtons.clear();
getLayoutInflater().inflate(layout, remoteFrame, true);
setupRemoteButtons();
}
private View setupBasicButton(@IdRes int buttonId, Function<InputHandler, Sec<Void>> action) {
View button = findViewById(buttonId);
if (button == null) return null;
tvControlButtons.add(button);
button.setOnClickListener(v -> {
if (inputHandler == null) return;
action.apply(inputHandler)
.doOnError(t -> Log.d(TAG, "button action failed", t))
.callMeWhenDone();
vibrator.vibrate(CLICK_VIBRATION_EFFECT);
});
return button;
}
private void setupBasicButton(@IdRes int buttonId, Function<InputHandler, Sec<Void>> action, Function<InputHandler, Sec<Void>> longPressAction) {
View button = setupBasicButton(buttonId, action);
if (button == null) return;
button.setOnLongClickListener(v -> {
if (inputHandler == null) return false;
longPressAction.apply(inputHandler)
.doOnError(t -> Log.d(TAG, "button action failed", t))
.callMeWhenDone();
vibrator.vibrate(LONG_CLICK_VIBRATION_EFFECT);
return true;
});
}
private void setupRepeatableButton(@IdRes int buttonId, Function<InputHandler, Sec<Void>> action, int repeatInterval) {
RemoteButton button = (RemoteButton) setupBasicButton(buttonId, action);
if (button == null) return;
button.setRepeat(BUTTON_REPEAT_DELAY, repeatInterval);
}
private void setupTrackpad() {
// trackpad
TrackpadSurface trackpadSurface = findViewById(R.id.trackpad_surface);
if (trackpadSurface == null) return;
trackpadSurface.setMouseTranslationConsumer((x, y) -> {
if (inputHandler == null) return null;
return inputHandler.cursorMove(x, y);
});
TrackpadButton trackpadLeftClickButton = findViewById(R.id.trackpad_click_button);
trackpadLeftClickButton.setOnPress(() -> {
if (inputHandler == null) return;
inputHandler.cursorDown();
vibrator.vibrate(CLICK_VIBRATION_EFFECT);
});
trackpadLeftClickButton.setOnRelease(() -> {
if (inputHandler == null) return;
inputHandler.cursorUp();
vibrator.vibrate(LONG_CLICK_VIBRATION_EFFECT);
});
}
private void setupRemoteButtons() {
// dpad
setupRepeatableButton(R.id.up_button, InputHandler::dpadUp, 150);
setupRepeatableButton(R.id.down_button, InputHandler::dpadDown, 150);
setupRepeatableButton(R.id.left_button, InputHandler::dpadLeft, 150);
setupRepeatableButton(R.id.right_button, InputHandler::dpadRight, 150);
setupBasicButton(R.id.select_button, InputHandler::dpadSelect, InputHandler::dpadLongPress);
// nav bar
setupBasicButton(R.id.back_button, InputHandler::navBack);
setupBasicButton(R.id.home_button, InputHandler::navHome);
setupBasicButton(R.id.recent_button, InputHandler::navRecent);
setupBasicButton(R.id.notifications_button, InputHandler::navNotifications);
setupBasicButton(R.id.quick_settings_button, InputHandler::navQuickSettings);
// volume
setupRepeatableButton(R.id.volume_up_button, InputHandler::volumeUp, 100);
setupRepeatableButton(R.id.volume_down_button, InputHandler::volumeDown, 100);
setupBasicButton(R.id.mute_button, InputHandler::mute);
setupRepeatableButton(R.id.skip_backward_button, InputHandler::skipBackward, 690);
setupBasicButton(R.id.prev_track_button, InputHandler::prevTrack);
setupBasicButton(R.id.pause_button, InputHandler::pause);
setupBasicButton(R.id.next_track_button, InputHandler::nextTrack);
setupRepeatableButton(R.id.skip_forward_button, InputHandler::skipForward, 690);
// trackpad
setupTrackpad();
}
private void setupFixedButtons() {
View disconnectButton = findViewById(R.id.disconnect_button);
disconnectButton.setOnClickListener(v -> finish());
NavigationBarView controlMethodSelector = findViewById(R.id.control_method_selector);
controlMethodSelector.setOnItemSelectedListener(item -> {
if (item.getItemId() == R.id.dpad_selector_button)
switchToLayout(R.layout.layout_remote_standard);
else if (item.getItemId() == R.id.trackpad_selector_button)
switchToLayout(R.layout.layout_remote_mouse);
else return false;
return true;
});
}
private void setupButtons() {
setupRemoteButtons();
setupFixedButtons();
}
@Override
public void onSocketConnected() {
runOnUiThread(() -> {
setConnectionStatus(R.string.connection_status_hand_shaking, true);
setControlsEnabled(false);
});
}
@Override
public void onConnected() {
runOnUiThread(() -> setConnectionStatus(R.string.connection_status_unready, false));
}
@Override
public void onReadyStateChanged(boolean ready) {
runOnUiThread(() -> {
setControlsEnabled(ready);
setConnectionStatus(
ready ? R.string.connection_status_ready : R.string.connection_status_unready,
false);
});
}
@Override
public void onDisconnected() {
scheduleConnect(0L);
runOnUiThread(() -> {
setControlsEnabled(false);
setConnectionStatus(R.string.connection_status_connection_lost, false);
});
}
}
@@ -0,0 +1,141 @@
package io.benwiegand.atvremote.phone.ui;
import static io.benwiegand.atvremote.phone.protocol.ProtocolConstants.MDNS_SERVICE_TYPE;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
import java.util.HashMap;
import java.util.Map;
import io.benwiegand.atvremote.phone.R;
import io.benwiegand.atvremote.phone.network.discovery.ServiceDiscoveryCallback;
import io.benwiegand.atvremote.phone.network.discovery.ServiceDiscoveryException;
import io.benwiegand.atvremote.phone.network.discovery.ServiceExplorer;
public class TVDiscoveryActivity extends AppCompatActivity implements ServiceDiscoveryCallback {
private static final String TAG = TVDiscoveryActivity.class.getSimpleName();
private final Map<String, View> discoveredServiceViewMap = new HashMap<>();
private ServiceExplorer serviceExplorer = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_tvdiscovery);
findViewById(R.id.manual_connection_button)
.setOnClickListener(v -> {
// todo: using placeholder DebugActivity for now
Intent intent = new Intent(this, DebugActivity.class);
startActivity(intent);
});
NsdManager nsdManager = getSystemService(NsdManager.class);
serviceExplorer = new ServiceExplorer(nsdManager, this);
}
@SuppressLint("SetTextI18n")
@Override
public void serviceDiscovered(String key, NsdServiceInfo serviceInfo) {
String hostname = serviceInfo.getHost().getHostAddress();
String deviceName = serviceInfo.getServiceName();
runOnUiThread(() -> {
LinearLayout resultList = findViewById(R.id.discovery_result_list);
View receiverEntry = getLayoutInflater().inflate(R.layout.layout_discovered_receiver_entry, resultList, false);
TextView hostnameText = receiverEntry.findViewById(R.id.device_name);
hostnameText.setText(deviceName);
TextView uriText = receiverEntry.findViewById(R.id.uri);
uriText.setText("tcp://" + serviceInfo.getHost().getHostAddress() + ":" + serviceInfo.getPort());
receiverEntry.setOnClickListener(v -> {
Log.d(TAG, "Starting remote activity");
Intent intent = new Intent(this, RemoteActivity.class);
intent.putExtra(RemoteActivity.EXTRA_DEVICE_NAME, deviceName);
intent.putExtra(RemoteActivity.EXTRA_HOSTNAME, hostname);
intent.putExtra(RemoteActivity.EXTRA_PORT_NUMBER, serviceInfo.getPort());
startActivity(intent);
});
View oldEntry = discoveredServiceViewMap.put(key, receiverEntry);
if (oldEntry != null)
resultList.removeView(oldEntry);
resultList.addView(receiverEntry);
});
}
@Override
public void serviceLost(String key) {
runOnUiThread(() -> {
View view = discoveredServiceViewMap.remove(key);
if (view == null) return;
LinearLayout resultList = findViewById(R.id.discovery_result_list);
resultList.removeView(view);
});
}
@Override
public void discoveryStarted() {
runOnUiThread(() -> {
findViewById(R.id.service_discovery_indicator)
.setVisibility(View.VISIBLE);
findViewById(R.id.service_discovery_notice)
.setVisibility(View.VISIBLE);
});
}
@Override
public void discoveryStopped() {
runOnUiThread(() -> {
findViewById(R.id.service_discovery_indicator)
.setVisibility(View.INVISIBLE);
});
}
@Override
public void discoveryFailure(ServiceDiscoveryException e) {
runOnUiThread(() -> {
findViewById(R.id.service_discovery_indicator)
.setVisibility(View.INVISIBLE);
// todo: error message
});
}
@Override
protected void onPause() {
super.onPause();
Log.d(TAG, "onPause()");
serviceExplorer.stopDiscovery();
}
@Override
protected void onResume() {
super.onResume();
Log.d(TAG, "onResume()");
// todo: make a new service explorer here (there are edge cases that break state)
serviceExplorer.startDiscovery(MDNS_SERVICE_TYPE);
}
@Override
protected void onDestroy() {
Log.d(TAG, "onDestroy()");
super.onDestroy();
}
}
@@ -0,0 +1,79 @@
package io.benwiegand.atvremote.phone.ui.view;
import static android.view.MotionEvent.ACTION_BUTTON_PRESS;
import static android.view.MotionEvent.ACTION_BUTTON_RELEASE;
import static android.view.MotionEvent.ACTION_CANCEL;
import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_UP;
import static android.view.MotionEvent.ACTION_UP;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import androidx.appcompat.widget.AppCompatImageButton;
import io.benwiegand.atvremote.phone.stuff.SerialInt;
public class RemoteButton extends AppCompatImageButton {
private int repeatInterval = -1;
private int repeatDelay = 1000;
private final SerialInt holdSerial = new SerialInt();
private boolean holdTriggered = false;
private boolean clickCancelled = false;
public RemoteButton(Context context) {
super(context, null);
}
public RemoteButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
public RemoteButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setRepeat(int repeatDelay, int repeatInterval) {
this.repeatDelay = repeatDelay;
this.repeatInterval = repeatInterval;
}
@SuppressLint("ClickableViewAccessibility") // it does call performClick() just not directly
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case ACTION_DOWN, ACTION_POINTER_DOWN, ACTION_BUTTON_PRESS -> {
if (repeatInterval == -1) break;
int serial = holdSerial.get();
holdTriggered = false;
getHandler().postDelayed(() -> handleHold(serial), repeatDelay);
}
case ACTION_UP, ACTION_POINTER_UP, ACTION_BUTTON_RELEASE, ACTION_CANCEL -> {
holdSerial.advance();
if (holdTriggered) clickCancelled = true;
}
}
return super.onTouchEvent(event);
}
@Override
public boolean performClick() {
if (clickCancelled) {
clickCancelled = false;
return false;
}
return super.performClick();
}
private void handleHold(int serial) {
if (!holdSerial.isValid(serial)) return;
holdTriggered = true;
performClick();
getHandler().postDelayed(() -> handleHold(serial), repeatInterval);
}
}
@@ -0,0 +1,62 @@
package io.benwiegand.atvremote.phone.ui.view;
import static android.view.MotionEvent.ACTION_BUTTON_PRESS;
import static android.view.MotionEvent.ACTION_BUTTON_RELEASE;
import static android.view.MotionEvent.ACTION_CANCEL;
import static android.view.MotionEvent.ACTION_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_DOWN;
import static android.view.MotionEvent.ACTION_POINTER_UP;
import static android.view.MotionEvent.ACTION_UP;
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import androidx.appcompat.widget.AppCompatButton;
public class TrackpadButton extends AppCompatButton {
private Runnable onPress = null;
private Runnable onRelease = null;
public TrackpadButton(Context context) {
super(context);
}
public TrackpadButton(Context context, AttributeSet attrs) {
super(context, attrs);
}
public TrackpadButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setOnPress(Runnable onPress) {
this.onPress = onPress;
}
public void setOnRelease(Runnable onRelease) {
this.onRelease = onRelease;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case ACTION_DOWN, ACTION_POINTER_DOWN, ACTION_BUTTON_PRESS -> {
if (onPress != null) onPress.run();
performClick();
}
case ACTION_UP, ACTION_POINTER_UP, ACTION_BUTTON_RELEASE, ACTION_CANCEL -> {
if (onRelease != null) onRelease.run();
}
}
return super.onTouchEvent(event);
}
@Override
public boolean performClick() {
return super.performClick();
}
}
@@ -0,0 +1,108 @@
package io.benwiegand.atvremote.phone.ui.view;
import android.annotation.SuppressLint;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
import java.util.function.BiFunction;
import io.benwiegand.atvremote.phone.async.Sec;
import io.benwiegand.atvremote.phone.control.MouseCurve;
public class TrackpadSurface extends View {
private static final String TAG = TrackpadSurface.class.getSimpleName();
private final Object mouseTranslationUpdateLock = new Object();
private boolean readyForUpdate = true;
private final MouseCurve curve = MouseCurve.ACCEL_LOG10; // todo
private BiFunction<Integer, Integer, Sec<Void>> mouseTranslationConsumer = null;
private float cachedDeltaX = 0;
private float cachedDeltaY = 0;
public TrackpadSurface(Context context) {
super(context);
}
public TrackpadSurface(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public TrackpadSurface(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setMouseTranslationConsumer(BiFunction<Integer, Integer, Sec<Void>> mouseTranslationConsumer) {
this.mouseTranslationConsumer = mouseTranslationConsumer;
}
@SuppressLint("ClickableViewAccessibility") // controlling a trackpad through talkback would be miserable
@Override
public boolean onTouchEvent(MotionEvent event) {
calculateMouseTranslation(event);
handleMouseTranslationUpdate();
return true;
}
private void calculateMouseTranslation(MotionEvent event) {
if (mouseTranslationConsumer == null) return;
if (event.getAction() != MotionEvent.ACTION_MOVE) return;
if (event.getHistorySize() < 1) return;
// use float to preserve sub-pixel movements
float x = event.getX() - event.getHistoricalX(0);
float y = event.getY() - event.getHistoricalY(0);
// apply curve to make the downsides of trackpads less painful
x = curve.apply(x);
y = curve.apply(y);
synchronized (mouseTranslationUpdateLock) {
cachedDeltaX += x;
cachedDeltaY += y;
}
}
private void handleMouseTranslationUpdate() {
int deltaX, deltaY;
synchronized (mouseTranslationUpdateLock) {
deltaX = (int) cachedDeltaX;
deltaY = (int) cachedDeltaY;
if (deltaX == 0 && deltaY == 0) return; // nothing significant enough to update
if (!readyForUpdate) return; // don't overwhelm the connection
readyForUpdate = false;
// preserve sub-pixel offset
cachedDeltaX -= deltaX;
cachedDeltaY -= deltaY;
}
try {
Sec<Void> operation = mouseTranslationConsumer.apply(deltaX, deltaY);
if (operation == null) return;
operation
.doOnResult(v -> {
readyForUpdate = true;
Log.d(TAG, "mouse translation updated: " + deltaX + ", " + deltaY);
handleMouseTranslationUpdate(); // do it back-to-back
})
.doOnError(t -> {
readyForUpdate = true;
Log.w(TAG, "mouse translation update failed", t);
})
.callMeWhenDone();
} catch (Throwable t) {
Log.wtf(TAG, "exception while setting up mouse translation update (this isn't supposed to happen)", t);
readyForUpdate = true; // set this to true to avoid soft-lock
}
}
}
@@ -0,0 +1,33 @@
package io.benwiegand.atvremote.phone.util;
import android.os.Build;
import java.util.HexFormat;
public class ByteUtil {
private static final char[] HEX_DIGITS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
private static final char[] HEX_DIGITS_UPPER = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
public static String hexOf(byte[] input) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
return HexFormat.of().formatHex(input);
return hexOf(input, "", false);
}
public static String hexOf(byte[] input, String separator, boolean upper) {
char[] digits = upper ? HEX_DIGITS_UPPER : HEX_DIGITS;
StringBuilder sb = new StringBuilder(input.length * (2 + separator.length()) - separator.length());
for (int i = 0; i < input.length; i++) {
byte b = input[i];
sb.append(digits[(0xF0 & b) >>> 4])
.append(digits[0x0F & b]);
if (i != input.length - 1) sb.append(separator);
}
return sb.toString();
}
}
@@ -0,0 +1,23 @@
package io.benwiegand.atvremote.phone.util;
public class ErrorUtil {
// todo: stack trace + custom view insertion
// todo: also localized exception messages
public static String generateErrorDescription(Throwable t) {
StringBuilder sb = new StringBuilder()
.append("\n")
.append(t.getClass().getSimpleName())
.append(": ")
.append(t.getLocalizedMessage());
while ((t = t.getCause()) != null) sb
.append("\n")
.append("caused by ")
.append(t.getClass().getSimpleName())
.append(": ")
.append(t.getLocalizedMessage());
return sb.toString();
}
}
@@ -0,0 +1,19 @@
package io.benwiegand.atvremote.phone.util;
import android.animation.TimeInterpolator;
public class UiUtil {
public static final TimeInterpolator EASE_OUT = t -> 1-(t-1f)*(t-1f);
public static final TimeInterpolator EASE_IN = t -> t*t;
public static final TimeInterpolator EASE_IN_OUT = chainTimeFunctions(EASE_IN, EASE_OUT);
public static final TimeInterpolator WIND_UP = t -> 2.70158f*t*t*t - 1.70158f*t*t;
public static TimeInterpolator chainTimeFunctions(TimeInterpolator tf, TimeInterpolator... additionalTfs) {
for (TimeInterpolator func : additionalTfs) {
TimeInterpolator prevTf = tf;
tf = x -> func.getInterpolation(prevTf.getInterpolation(x));
}
return tf;
}
}
+12
View File
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M146.4,67.84 L334.57,256 146.4,444.16"
android:strokeWidth="90"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:fillAlpha="0.937255"/>
</vector>
+16
View File
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M135.08,256H440.56"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M287.82,103.26 L135.08,256 287.82,408.74"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M256,256m-150,0a150,150 0,1 1,300 0a150,150 0,1 1,-300 0"
android:strokeWidth="90"
android:fillColor="#000000"
android:strokeColor="#00000000"/>
</vector>
+21
View File
@@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M256,256m-57.53,0a57.53,57.53 0,1 1,115.06 0a57.53,57.53 0,1 1,-115.06 0"
android:strokeAlpha="0.270889"
android:strokeLineJoin="round"
android:strokeWidth="0.751921"
android:fillColor="#000000"
android:fillAlpha="0.990475"
android:strokeLineCap="round"/>
<path
android:pathData="m395.07,209.64l46.36,46.36l-46.36,46.36m-92.71,92.71l-46.36,46.36l-46.36,-46.36m-92.71,-92.71L70.58,256L116.93,209.64m92.71,-92.71l46.36,-46.36l46.36,46.36"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:fillAlpha="0.990475"
android:strokeLineCap="square"/>
</vector>
+19
View File
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M377.81,247.12L377.81,371.85L134.19,371.85L134.19,247.12"
android:strokeLineJoin="miter"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="butt"/>
<path
android:pathData="M74.19,277.12 L256,147.97 437.81,277.12"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="square"/>
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
+18
View File
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M194.8,238.54h259.09v156.42h-259.09z"
android:strokeLineJoin="miter"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
<path
android:pathData="M143.64,287.12H79.26V130.7H338.34v57.1"
android:strokeLineJoin="miter"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"/>
</vector>
+23
View File
@@ -0,0 +1,23 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M79.08,114.01L432.92,114.01v218.69L79.08,332.7Z"
android:strokeLineJoin="round"
android:strokeWidth="29.5143"
android:fillColor="#000000"
android:fillAlpha="0.990475"
android:strokeLineCap="square"/>
<path
android:pathData="m79.08,99.26c-8.15,0 -14.76,6.61 -14.76,14.76v218.69,14.76L447.68,347.46v-14.76,-218.69c-0,-8.15 -6.61,-14.76 -14.76,-14.76z"
android:strokeLineJoin="round"
android:fillColor="#000000"
android:strokeLineCap="square"/>
<path
android:pathData="m64.32,355.07v14.76,28.69c0,8.15 6.61,14.76 14.76,14.76L432.92,413.27c8.15,-0 14.76,-6.61 14.76,-14.76v-28.69,-14.76z"
android:strokeLineJoin="round"
android:fillColor="#000000"
android:strokeLineCap="square"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorPrimary"/>
<corners android:bottomLeftRadius="10dp" android:bottomRightRadius="10dp"/>
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/colorPrimary"/>
<corners android:topLeftRadius="10dp" android:topRightRadius="10dp"/>
</shape>
+30
View File
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="m73.5,113.87h365v225h-365z"
android:strokeLineJoin="round"
android:strokeWidth="42"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:fillAlpha="0"
android:strokeLineCap="square"/>
<path
android:pathData="M178.13,403.23h155.75v0.48h-155.75z"
android:strokeLineJoin="round"
android:strokeWidth="30.8342"
android:fillColor="#000000"
android:strokeColor="#000000"
android:fillAlpha="0.990475"
android:strokeLineCap="square"/>
<path
android:pathData="M256,392.9L256,348.83"
android:strokeLineJoin="round"
android:strokeWidth="35.1098"
android:fillColor="#000000"
android:strokeColor="#000000"
android:fillAlpha="0.990475"
android:strokeLineCap="square"/>
</vector>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.DebugActivity">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp"
android:text="debug"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline1" />
<EditText
android:id="@+id/ip_address_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="IP Address"
android:inputType="text"
android:text="192.168.69." />
<EditText
android:id="@+id/port_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:hint="Port"
android:inputType="number"
android:text="9999" />
<Button
android:id="@+id/connect_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="connect" />
</LinearLayout>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.PairingActivity">
</FrameLayout>
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".ui.RemoteActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
>
<ImageButton
android:id="@+id/disconnect_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:padding="10dp"
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:background="?android:attr/actionBarItemBackground"
app:tint="?android:attr/textColorSecondary"
app:srcCompat="@drawable/back"
android:contentDescription="@string/button_back"
/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/connected_tv_name_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="?android:attr/textColorSecondary"
tools:text="some shitbox" />
<TextView
android:id="@+id/connection_status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
android:textColor="?android:attr/textColorSecondary"
tools:text="connected" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/connecting_indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:indeterminateOnly="true"
android:visibility="invisible"
tools:visibility="visible"
style="?android:attr/progressBarStyleHorizontal"
/>
<FrameLayout
android:id="@+id/remote_frame"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
>
<include
layout="@layout/layout_remote_standard"
/>
</FrameLayout>
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/control_method_selector"
android:layout_width="match_parent"
android:layout_height="64dp"
android:layout_weight="0"
android:minHeight="64dp"
android:background="?android:attr/windowBackground"
app:menu="@menu/control_method_selector_menu"
/>
</LinearLayout>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.TVDiscoveryActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="12dp"
android:layout_marginHorizontal="12dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline4"
android:text="@string/title_tvdiscovery"
/>
<ProgressBar
android:id="@+id/service_discovery_indicator"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:indeterminate="true"
android:indeterminateOnly="true"
android:visibility="invisible"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/discovery_result_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:id="@+id/service_discovery_notice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
tools:visibility="visible"
android:layout_gravity="center"
android:layout_margin="12dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"
android:text="@string/searching_for_tvs"
/>
<Button
android:id="@+id/manual_connection_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/button_manual_connection"
android:textColor="?android:attr/textColorSecondary"
/>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp"
android:background="?attr/selectableItemBackground"
android:clickable="true">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginRight="8dp"
app:tint="?android:attr/colorForeground"
android:src="@drawable/tv"
android:importantForAccessibility="no"
tools:ignore="RtlHardcoded" /> <!-- no accessibility, it's just a meaningless static tv icon -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
tools:text="some hostname" />
<TextView
android:id="@+id/uri"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle2"
tools:text="tcp://42.0.69.69:6969" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
>
<TextView
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="12dp"
android:text="@string/title_pairing_code_entry"
/>
<TextView
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/description_pairing_code_entry"
/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:orientation="horizontal"
android:layout_weight="1"
android:gravity="center"
>
<EditText
android:id="@+id/pairing_code_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:minWidth="96dp"
android:autofillHints=""
android:hint="@string/hint_pairing_code"
android:textSize="32sp"
android:contentDescription="@string/accessibility_pairing_code"
android:inputType="numberDecimal"
android:lines="1"
android:maxLength="6"
android:textAlignment="center"
tools:ignore="ContentDescription"
/>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="24dp"
>
<Button
android:id="@+id/cancel_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_cancel"
/>
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
<Button
android:id="@+id/next_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_next"
/>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
>
<TextView
android:id="@+id/title_text"
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="12dp"
tools:text="Something happened"
/>
<TextView
android:id="@+id/description_text"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Some happenings that happened which have interfered with the pairing process to the point where it cannot continue."
/>
</LinearLayout>
<Space
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="24dp"
>
<Button
android:id="@+id/cancel_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_cancel"
/>
<Space
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
<Button
android:id="@+id/retry_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_retry"
/>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
>
<TextView
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="12dp"
android:text="@string/title_fingerprint_verification"
/>
<TextView
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/description_fingerprint_verficiation"
/>
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="12dp"
android:layout_gravity="center_horizontal"
android:gravity="center"
android:orientation="vertical"
>
<TextView
android:id="@+id/fingerprint_elevated_text"
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="24dp"
android:textAlignment="center"
tools:text="de ad de ad be ef"
/>
<TextView
android:id="@+id/fingerprint_full_text"
style="@style/TextAppearance.MaterialComponents.Subtitle1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center"
tools:text="c2 6c 06 55 12 80 55 74 ca d8 38 1f 22 65 8e f3 51 80 b0 22 d7 32 f2 2b 9c bb bc de 54 bb 23 de"
/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="24dp"
android:gravity="center_horizontal"
>
<Button
android:id="@+id/match_button"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/button_fingerprints_match"
/>
<Button
android:id="@+id/no_match_button"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="@string/button_fingerprints_differ"
/>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="48dp"
>
<Button
android:id="@+id/back_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_back"
/>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_gravity="center"
>
<TextView
android:id="@+id/title_text"
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="48dp"
android:layout_marginBottom="12dp"
tools:text="doing things"
/>
<TextView
android:id="@+id/description_text"
style="@style/TextAppearance.MaterialComponents.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="something is doing"
/>
</LinearLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
>
<ProgressBar
style="@style/Widget.MaterialComponents.CircularProgressIndicator"
android:layout_width="90dp"
android:layout_height="90dp"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateOnly="true"
/>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="24dp"
>
<Button
android:id="@+id/cancel_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/button_cancel"
/>
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_horizontal">
<Space
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/up_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_up"
android:rotation="-90"
app:srcCompat="@drawable/arrow" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/notifications_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_notifications"
android:padding="30dp"
app:srcCompat="@android:drawable/ic_popup_reminder" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_horizontal">
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/left_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_left"
android:rotation="180"
app:srcCompat="@drawable/arrow" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/select_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_select"
app:srcCompat="@drawable/circle" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/right_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_right"
app:srcCompat="@drawable/arrow" />
</TableRow>
<TableRow
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center_horizontal">
<Space
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/down_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:contentDescription="@string/button_down"
android:rotation="90"
app:srcCompat="@drawable/arrow" />
<Space
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1" />
</TableRow>
</TableLayout>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:orientation="vertical">
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/volume_up_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_volume_up"
app:srcCompat="@android:drawable/ic_lock_silent_mode_off" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/volume_down_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_volume_down"
app:srcCompat="@android:drawable/ic_lock_silent_mode_off" />
</LinearLayout>
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/mute_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_mute_toggle"
app:srcCompat="@android:drawable/ic_lock_silent_mode" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/quick_settings_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_quick_settings"
app:srcCompat="@android:drawable/ic_menu_manage" />
</LinearLayout>
<!-- todo -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_vertical"
android:visibility="gone"
android:orientation="horizontal">
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/skip_backward_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_skip_backward"
app:srcCompat="@android:drawable/ic_media_rew" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/prev_track_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_previous_track"
app:srcCompat="@android:drawable/ic_media_previous" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/pause_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_toggle_playing"
app:srcCompat="@android:drawable/ic_media_pause" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/next_track_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_next_track"
app:srcCompat="@android:drawable/ic_media_next" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/skip_forward_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_skip_forward"
app:srcCompat="@android:drawable/ic_media_ff" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal">
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/back_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_navigate_back"
app:srcCompat="@drawable/back" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/home_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_navigate_home"
app:srcCompat="@drawable/home" />
<io.benwiegand.atvremote.phone.ui.view.RemoteButton
android:id="@+id/recent_button"
style="@style/Widget.Theme.ATVRemote.RemoteButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:contentDescription="@string/button_navigate_recent"
app:srcCompat="@drawable/recent" />
</LinearLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<io.benwiegand.atvremote.phone.ui.view.TrackpadSurface
android:id="@+id/trackpad_surface"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:alpha="0.4"
android:background="@drawable/trackpad_surface" />
<io.benwiegand.atvremote.phone.ui.view.TrackpadButton
android:id="@+id/trackpad_click_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="80dp"
android:layout_marginTop="8dp"
android:alpha="0.4"
android:background="@drawable/trackpad_mono_button"
android:contentDescription="@string/button_trackpad_left_click"
tools:ignore="RedundantDescriptionCheck,VisualLintButtonSize" />
</LinearLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/layout_remote_component_media_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0"
/>
<include
layout="@layout/layout_remote_component_trackpad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
<include
layout="@layout/layout_remote_component_navbar"
android:layout_width="match_parent"
android:layout_height="90dp"
android:layout_weight="0"
/>
</LinearLayout>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<include
layout="@layout/layout_remote_component_dpad"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
<include
layout="@layout/layout_remote_component_navbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
<include
layout="@layout/layout_remote_component_media_buttons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
/>
</LinearLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/dpad_selector_button"
android:icon="@drawable/dpad"
android:title="@string/nav_button_dpad"
android:contentDescription="@string/nav_button_accessibility_dpad"
/>
<item
android:id="@+id/trackpad_selector_button"
android:icon="@drawable/trackpad"
android:title="@string/nav_button_mouse"
android:contentDescription="@string/nav_button_accessibility_mouse"
/>
</menu>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+13
View File
@@ -0,0 +1,13 @@
<resources>
<style name="Widget.Theme.ATVRemote.RemoteButton" parent="">
<item name="android:background">?attr/selectableItemBackground</item>
<item name="tint">?android:attr/textColorSecondary</item>
<item name="android:adjustViewBounds">true</item>
<item name="minWidth">48dp</item>
<item name="minHeight">48dp</item>
<item name="android:layout_margin">12dp</item>
<item name="height">64dp</item>
<item name="android:padding">6dp</item>
<item name="android:scaleType">fitCenter</item>
</style>
</resources>
+31
View File
@@ -0,0 +1,31 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ATVRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- primary color -->
<item name="colorPrimary">@color/material_dynamic_primary80</item>
<item name="colorPrimaryVariant">@color/material_dynamic_primary50</item>
<item name="colorOnPrimary">@color/black</item>
<!-- secondary color -->
<item name="colorSecondary">@color/material_dynamic_secondary80</item>
<item name="colorSecondaryVariant">@color/material_dynamic_secondary50</item>
<item name="colorOnSecondary">@color/black</item>
<!-- ui elements -->
<item name="android:progressTint">@color/material_dynamic_primary80</item>
<item name="android:indeterminateTint">@color/material_dynamic_primary80</item>
<item name="colorOnSurface">@color/gray_400</item> <!-- for BottomNavigationBar -->
<!-- edge to edge is overrated -->
<item name="android:statusBarColor">?android:attr/windowBackground</item>
<item name="android:navigationBarColor">?android:attr/windowBackground</item>
<!-- no action bar -->
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<!-- black like my soul -->
<item name="android:windowBackground">@color/black</item>
</style>
</resources>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="gary">#FFAAAAAA</color>
<color name="material_gary">#FF303030</color>
<color name="light_blue_400">#FF29B6F6</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="gray_400">#FFBDBDBD</color>
<color name="gray_600">#FF757575</color>
</resources>
+87
View File
@@ -0,0 +1,87 @@
<resources>
<string name="app_name">A TV Remote</string>
<!-- common ui -->
<string name="button_cancel">Cancel</string>
<string name="button_next">next</string>
<string name="button_back">go back</string>
<string name="button_retry">retry</string>
<!-- general connection failures -->
<string name="init_failure">failed to initialize</string>
<string name="init_failure_desc_general">Failed to initialize cryptographic secrets:{0}</string>
<string name="init_failure_desc_corrupted_keystore">Failed to load cryptographic secrets used to secure the connection to your TV, they might be corrupted:{0}</string>
<string name="init_failure_desc_unsupported">One or more algorithms required for a secure connection to your TV are not supported by your device:{0}</string>
<string name="init_failure_desc_unexpected_error">Encountered an unexpected error while trying to initialize cryptographic secrets:{0}</string>
<string name="negotiation_failure">failed to initialize</string>
<string name="negotiation_failure_desc_unexpected_error">Encountered an unexpected error while negotiating with the TV:{0}</string>
<!-- connection status -->
<string name="connection_status_connecting">connecting…</string>
<string name="connection_status_hand_shaking">negotiating…</string>
<string name="connection_status_unready">connected (TV not ready)</string>
<string name="connection_status_ready">connected</string>
<string name="connection_status_connection_lost">connection lost</string>
<!-- tv discovery -->
<string name="title_tvdiscovery">connect to TV</string>
<string name="searching_for_tvs">searching for TVs running the receiver app</string>
<string name="button_manual_connection">connect manually</string>
<!-- remote buttons -->
<!-- the remote buttons don't actually have text, these are accessibility hints -->
<string name="button_up">up</string>
<string name="button_notifications">notifications</string>
<string name="button_left">left</string>
<string name="button_select">select</string>
<string name="button_right">right</string>
<string name="button_down">down</string>
<string name="button_volume_up">volume up</string>
<string name="button_volume_down">volume down</string>
<string name="button_mute_toggle">toggle mute</string>
<string name="button_quick_settings">quick settings</string>
<string name="button_skip_backward">skip backward</string>
<string name="button_previous_track">previous track</string>
<string name="button_toggle_playing">pause/play</string>
<string name="button_next_track">next track</string>
<string name="button_skip_forward">skip forward</string>
<string name="button_navigate_back">back</string>
<string name="button_navigate_home">home</string>
<string name="button_navigate_recent">recent</string>
<string name="button_trackpad_left_click">left click</string>
<!-- remote nav bar (for control method switching) -->
<string name="nav_button_dpad">dpad</string>
<string name="nav_button_accessibility_dpad">directional pad</string>
<string name="nav_button_mouse">mouse</string>
<string name="nav_button_accessibility_mouse">mouse control</string>
<!-- pairing -->
<string name="title_pairing_connecting">connecting…</string>
<string name="description_pairing_connecting">connecting to {0}</string>
<string name="title_pairing_hand_shaking">hand-shaking…</string>
<string name="description_pairing_hand_shaking">asking to pair with {0}</string>
<string name="title_pairing_authenticating">pairing…</string>
<string name="description_pairing_authenticating">authenticating with {0}</string>
<!-- pairing errors -->
<string name="title_pairing_error">pairing failed</string>
<string name="description_pairing_error_connection_lost">lost connection with {0}</string>
<string name="description_pairing_error_fingerprint_differs">failed to verify connection security. someone may have intercepted your connection! please secure your network before trying again.</string>
<!-- pairing code entry -->
<string name="title_pairing_code_entry">Pairing code</string>
<string name="description_pairing_code_entry">Please enter the 6 digit pairing code displayed on your TV</string>
<string name="hint_pairing_code">000000</string>
<string name="accessibility_pairing_code">pairing code</string>
<string name="error_pairing_code_length">must be 6 digits</string>
<!-- fingerprint verification -->
<string name="title_fingerprint_verification">Verify authenticity</string>
<string name="description_fingerprint_verficiation">Please verify that the code below matches the one on your TV.</string>
<string name="button_fingerprints_match">they match</string>
<string name="button_fingerprints_differ">they\'re different!</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<resources>
<style name="Widget.Theme.ATVRemote.RemoteButton" parent="">
<item name="android:background">?attr/selectableItemBackground</item>
<item name="tint">?android:attr/textColorSecondary</item>
<item name="android:adjustViewBounds">true</item>
<item name="minWidth">48dp</item>
<item name="minHeight">48dp</item>
<item name="android:layout_margin">12dp</item>
<item name="height">64dp</item>
<item name="android:padding">6dp</item>
<item name="android:scaleType">fitCenter</item>
</style>
</resources>
+34
View File
@@ -0,0 +1,34 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.ATVRemote" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- primary color -->
<item name="colorPrimary">@color/material_dynamic_primary80</item>
<item name="colorPrimaryVariant">@color/material_dynamic_primary50</item>
<item name="colorOnPrimary">@color/black</item>
<!-- secondary color -->
<item name="colorSecondary">@color/material_dynamic_secondary80</item>
<item name="colorSecondaryVariant">@color/material_dynamic_secondary50</item>
<item name="colorOnSecondary">@color/black</item>
<!-- ui elements -->
<item name="android:progressTint">@color/material_dynamic_primary80</item>
<item name="android:indeterminateTint">@color/material_dynamic_primary80</item>
<item name="colorOnSurface">@color/gray_400</item> <!-- for BottomNavigationBar -->
<item name="android:textColorHint">@color/gray_400</item> <!-- fix text hint -->
<!-- edge to edge is overrated -->
<item name="android:statusBarColor">?android:attr/windowBackground</item>
<item name="android:navigationBarColor">?android:attr/windowBackground</item>
<!-- no action bar -->
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<!-- G R A Y (white looks weird for a remote) -->
<item name="android:windowBackground">@color/material_gary</item>
<item name="android:colorForeground">@color/white</item>
</style>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package io.benwiegand.atvremote.phone;
import org.junit.Test;
import static org.junit.Assert.*;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() {
assertEquals(4, 2 + 2);
}
}
@@ -0,0 +1,31 @@
package io.benwiegand.atvremote.phone;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
import io.benwiegand.atvremote.phone.stuff.SerialInt;
public class SerialIntTest {
@Test
public void wrapAround_Test() {
SerialInt serial = new SerialInt();
assertEquals("SerialInt must start at 0", serial.get(), 0);
for (int i = 0; i < Integer.MAX_VALUE; i++) serial.advance();
assertEquals("MAX_VALUE advance() calls resulting in a MAX_VALUE serial",
Integer.MAX_VALUE, serial.get());
assertEquals("wrap around to MIN_VALUE",
Integer.MIN_VALUE, serial.advance());
assertEquals("consistency between advance() and get() during wrap",
Integer.MIN_VALUE, serial.get());
assertEquals("increment after wrap",
Integer.MIN_VALUE + 1, serial.advance());
assertEquals("consistency between advance() and get() after wrap",
Integer.MIN_VALUE + 1, serial.get());
}
}
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="arrow.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.5192538"
inkscape:cx="210.30061"
inkscape:cy="228.0725"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;fill-opacity:0.937255;stroke:#000000;stroke-width:90;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-opacity:1"
d="M 146.40421,67.8391 334.56511,256 146.40421,444.1609"
id="path2"
inkscape:label="chevron" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+50
View File
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="back.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.1418047"
inkscape:cx="254.8597"
inkscape:cy="137.06372"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#000000;stroke-width:42;stroke-miterlimit:7.1;stroke-dasharray:none"
d="M 135.0774,256 H 440.56207"
id="path1" />
<path
style="fill:none;stroke:#000000;stroke-width:42;stroke-miterlimit:7.1;stroke-dasharray:none"
d="M 287.81973,103.25767 135.0774,256 287.81973,408.74234"
id="path2" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="circle.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.6152344"
inkscape:cx="255.69045"
inkscape:cy="256"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<defs
id="defs1" />
<circle
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:90;stroke-miterlimit:7.1"
id="path1"
cx="256"
cy="256"
r="150" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="dpad.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.57090235"
inkscape:cx="104.22098"
inkscape:cy="282.0097"
inkscape:window-width="1920"
inkscape:window-height="1014"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="layer1"
showgrid="false" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<circle
style="fill:#000000;fill-opacity:0.990475;stroke-width:0.751921;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.270889"
id="path422"
cx="256"
cy="256"
r="57.527908" />
<path
id="rect1153"
style="fill:none;fill-opacity:0.990475;stroke:#000000;stroke-width:42;stroke-linecap:square"
transform="rotate(45)"
d="m 427.59565,-131.11397 h 65.55698 l 0,65.556985 m 0,131.11397 v 65.556985 h -65.55698 m -131.11397,0 H 230.9247 V 65.556985 m 0,-131.11397 v -65.556985 h 65.55698"
sodipodi:nodetypes="cccccccccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="home.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.1657792"
inkscape:cx="276.2101"
inkscape:cy="240.61159"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="translate(-8.3201825e-6,3.4847315)">
<path
id="rect1"
style="fill:none;stroke:#000000;stroke-width:42;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:7.1;stroke-dasharray:none"
d="M 377.8146,243.6324 V 368.36758 H 134.18542 V 243.6324"
sodipodi:nodetypes="cccc" />
<path
style="fill:none;stroke:#000000;stroke-width:42;stroke-linecap:square;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-opacity:1"
d="M 74.185417,273.6324 256,144.48126 437.8146,273.6324"
id="path2"
sodipodi:nodetypes="ccc" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+54
View File
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="recent.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.89264645"
inkscape:cx="281.18635"
inkscape:cy="283.98702"
inkscape:window-width="1920"
inkscape:window-height="1010"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:none;stroke:#000000;stroke-width:42;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-linejoin:miter"
id="rect1"
width="259.0874"
height="156.41983"
x="194.80084"
y="238.54268" />
<path
id="rect1-5"
style="fill:none;stroke:#000000;stroke-width:42;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-linejoin:miter"
d="M 143.64307,287.11746 H 79.257141 V 130.69763 H 338.34454 v 57.10495"
sodipodi:nodetypes="ccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="trackpad.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.57090235"
inkscape:cx="149.76291"
inkscape:cy="310.03551"
inkscape:window-width="1920"
inkscape:window-height="1014"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="rect1334-6"
showgrid="false" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1414"
transform="translate(0,-32.640507)">
<g
id="g1544">
<g
id="rect1334">
<path
style="color:#000000;fill:#000000;fill-opacity:0.990475;stroke-width:29.5143;stroke-linecap:square;stroke-linejoin:round;-inkscape-stroke:none"
d="M 79.076637,146.65535 H 432.92335 v 218.6893 H 79.076637 Z"
id="path1478" />
<path
style="color:#000000;fill:#000000;stroke-linecap:square;stroke-linejoin:round;-inkscape-stroke:none"
d="m 79.076172,131.89844 c -8.149565,0.002 -14.75541,6.60824 -14.75586,14.75781 v 218.6875 14.75781 H 447.67969 v -14.75781 -218.6875 c -4.5e-4,-8.14957 -6.60629,-14.75628 -14.75586,-14.75781 z"
id="path1480"
sodipodi:nodetypes="ccccccccc" />
</g>
<g
id="rect1334-6"
transform="translate(-9.9999997e-7,259.80839)">
<path
style="color:#000000;fill:#000000;stroke-linecap:square;stroke-linejoin:round;-inkscape-stroke:none"
d="m 64.320312,127.89844 v 14.75781 28.6875 c 4.5e-4,8.14957 6.606295,14.75628 14.75586,14.75781 H 432.92383 c 8.14957,-0.002 14.75541,-6.60824 14.75586,-14.75781 v -28.6875 -14.75781 z"
id="path1480-5"
sodipodi:nodetypes="ccccccccc" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="tv.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="0.80737785"
inkscape:cx="822.41543"
inkscape:cy="222.94394"
inkscape:window-width="1920"
inkscape:window-height="1014"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="g1414"
showgrid="false" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1414"
transform="translate(0,-32.640507)">
<g
id="g1544">
<g
id="g1560"
transform="translate(0,32.621935)">
<path
id="rect1426"
style="fill:none;fill-opacity:0;stroke:#000000;stroke-width:42;stroke-linecap:square;stroke-linejoin:round"
d="m 73.5,113.88708 h 365 v 225 h -365 z"
sodipodi:nodetypes="ccccc" />
<rect
style="fill:#000000;fill-opacity:0.990475;stroke:#000000;stroke-width:30.8342;stroke-linecap:square;stroke-linejoin:round"
id="rect1430"
width="155.74879"
height="0.48464262"
x="178.12561"
y="403.24832" />
<path
style="fill:#000000;fill-opacity:0.990475;stroke:#000000;stroke-width:35.1098;stroke-linecap:square;stroke-linejoin:round"
d="M 256,392.91821 V 348.84562"
id="path1555"
sodipodi:nodetypes="cc" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}

Some files were not shown because too many files have changed in this diff Show More