initial commit
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||
}
|
||||