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,41 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'io.benwiegand.atvremote.receiver'
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "io.benwiegand.atvremote.receiver"
|
||||
minSdk 26
|
||||
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
|
||||
implementation libs.bouncycastle
|
||||
implementation(libs.androidx.leanback)
|
||||
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.receiver;
|
||||
|
||||
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.receiver", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?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-feature android:name="android.software.leanback" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
|
||||
<!-- <uses-permission android:name="android.permission.INJECT_EVENTS" />-->
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:banner="@drawable/ic_launcher_background"
|
||||
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.ATVRemoteReceiver"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.DebugActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".control.AccessibilityInputService"
|
||||
android:exported="false"
|
||||
android:label="@string/accessibility_input_service_label"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_input_service_config" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".network.TVRemoteServer"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,7 @@
|
||||
package io.benwiegand.atvremote.receiver.auth.ssl;
|
||||
|
||||
public class CorruptedKeystoreException extends Exception {
|
||||
public CorruptedKeystoreException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package io.benwiegand.atvremote.receiver.auth.ssl;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import org.bouncycastle.jce.provider.JDKMessageDigest;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
|
||||
public class KeyUtil {
|
||||
private static final String TAG = KeyUtil.class.getSimpleName();
|
||||
|
||||
private static MessageDigest getSha256Digest() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
Log.w(TAG, "no JDK support for SHA256");
|
||||
return new JDKMessageDigest.SHA256(); // if I'm gonna be forced to have bouncycastle I might as well use it
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package io.benwiegand.atvremote.receiver.auth.ssl;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import org.bouncycastle.asn1.x509.X509Name;
|
||||
import org.bouncycastle.x509.X509V3CertificateGenerator;
|
||||
|
||||
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.math.BigInteger;
|
||||
import java.nio.file.Path;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SignatureException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
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 KEYPAIR_ALGORITHM = "RSA";
|
||||
private static final String SIGNING_ALGORITHM = "SHA256WithRSAEncryption";
|
||||
private static final int KEY_SIZE = 4096;
|
||||
|
||||
private static final String SSL_SERVER_KEY_ALIAS = "atvr_server_key";
|
||||
public static final String CERTIFICATE_COMMON_NAME = "Bob"; // bob is a pretty common name
|
||||
|
||||
private final File keystoreFile;
|
||||
private KeyStore keystore = null;
|
||||
private boolean modified = false;
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
// reset modified state (empty keystore creation always counts)
|
||||
modified = is == null;
|
||||
|
||||
} 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();
|
||||
}
|
||||
|
||||
private KeyPair generateKeypair() {
|
||||
try {
|
||||
Log.d(TAG, "generating a " + KEY_SIZE + "-bit " + KEYPAIR_ALGORITHM + " key");
|
||||
KeyPairGenerator keygen = KeyPairGenerator.getInstance(KEYPAIR_ALGORITHM);
|
||||
keygen.initialize(KEY_SIZE);
|
||||
return keygen.genKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("no sufficient keypair algorithm found", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Certificate signKeypair(KeyPair keypair) {
|
||||
try {
|
||||
Log.d(TAG, "self-signing keypair with X509");
|
||||
X509V3CertificateGenerator certgen = new X509V3CertificateGenerator();
|
||||
X509Name commonName = new X509Name("CN=" + CERTIFICATE_COMMON_NAME);
|
||||
certgen.setIssuerDN(commonName);
|
||||
certgen.setSubjectDN(commonName);
|
||||
certgen.setPublicKey(keypair.getPublic());
|
||||
certgen.setSerialNumber(BigInteger.valueOf(42069));
|
||||
certgen.setNotBefore(Date.from(Instant.ofEpochSecond(0))); // the date might be incorrectly set on first boot
|
||||
certgen.setNotAfter(Date.from(Instant.ofEpochSecond(99999999999L))); // should hold us off until the far-off year of 5138
|
||||
certgen.setSignatureAlgorithm(SIGNING_ALGORITHM);
|
||||
return certgen.generate(keypair.getPrivate());
|
||||
} catch (CertificateEncodingException e) {
|
||||
throw new RuntimeException("failed to encode certificate", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("no suitable signing algorithm found", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("failed to sign keypair", e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new UnsupportedOperationException("bouncycastle rejected keys", e);
|
||||
}
|
||||
}
|
||||
|
||||
public void initSSL() {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
|
||||
try {
|
||||
if (keystore.containsAlias(SSL_SERVER_KEY_ALIAS)) return; // for now assume any existing key is sufficient
|
||||
|
||||
KeyPair keypair = generateKeypair();
|
||||
Certificate cert = signKeypair(keypair);
|
||||
|
||||
keystore.setKeyEntry(SSL_SERVER_KEY_ALIAS, keypair.getPrivate(), KEYSTORE_PASSWORD, new Certificate[] {cert});
|
||||
} catch (KeyStoreException e) {
|
||||
throw new RuntimeException("failed to store self-signed keypair", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public Certificate getSSLCertificate() {
|
||||
if (keystore == null) throw new IllegalStateException("keystore must be loaded first");
|
||||
|
||||
try {
|
||||
return keystore.getCertificate(SSL_SERVER_KEY_ALIAS);
|
||||
} catch (KeyStoreException e) {
|
||||
throw new IllegalStateException("keystore not initialized?", e);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void saveKeystore() throws IOException, CorruptedKeystoreException {
|
||||
if (keystore == null) throw new IllegalStateException("no currently loaded keystore to save");
|
||||
if (!modified) {
|
||||
Log.d(TAG, "not saving keystore because it wasn't modified");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
modified = false;
|
||||
|
||||
} 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,491 @@
|
||||
package io.benwiegand.atvremote.receiver.control;
|
||||
|
||||
import android.accessibilityservice.AccessibilityService;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.Rect;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Binder;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.Display;
|
||||
import android.view.View;
|
||||
import android.view.accessibility.AccessibilityEvent;
|
||||
import android.view.accessibility.AccessibilityNodeInfo;
|
||||
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
import io.benwiegand.atvremote.receiver.control.cursor.AccessibilityGestureCursor;
|
||||
import io.benwiegand.atvremote.receiver.control.cursor.CursorController;
|
||||
import io.benwiegand.atvremote.receiver.protocol.PairingCallback;
|
||||
import io.benwiegand.atvremote.receiver.ui.NotificationOverlay;
|
||||
import io.benwiegand.atvremote.receiver.ui.PairingDialog;
|
||||
|
||||
public class AccessibilityInputService extends AccessibilityService {
|
||||
private static final String TAG = AccessibilityInputService.class.getSimpleName();
|
||||
|
||||
public static final String INTENT_ACCESSIBILITY_INPUT_BINDER_REQUEST = "io.benwiegand.atvremote.receiver.control.accessibilityinput.GIVE_ME_BINDER";
|
||||
public static final String INTENT_ACCESSIBILITY_INPUT_BINDER_INSTANCE = "io.benwiegand.atvremote.receiver.control.accessibilityinput.BINDER_INSTANCE";
|
||||
public static final String EXTRA_BINDER_INSTANCE = "binder";
|
||||
|
||||
private final AccessibilityInputHandler binder = new AccessibilityInputHandler();
|
||||
private final BroadcastReceiver receiver = new Receiver();
|
||||
|
||||
private CursorController cursorController = null;
|
||||
private NotificationOverlay notificationOverlay = null;
|
||||
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
@Override
|
||||
protected void onServiceConnected() {
|
||||
super.onServiceConnected();
|
||||
Log.i(TAG, "service connected!");
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(INTENT_ACCESSIBILITY_INPUT_BINDER_REQUEST);
|
||||
LocalBroadcastManager
|
||||
.getInstance(this)
|
||||
.registerReceiver(receiver, filter);
|
||||
|
||||
cursorController = new AccessibilityGestureCursor(this);
|
||||
notificationOverlay = new NotificationOverlay(this);
|
||||
notificationOverlay.start();
|
||||
|
||||
broadcastBinder();
|
||||
}
|
||||
|
||||
public NotificationOverlay getNotificationOverlay() {
|
||||
return notificationOverlay;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInterrupt() {
|
||||
Log.d(TAG, "onInterrupt()");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUnbind(Intent intent) {
|
||||
Log.d(TAG, "onUnbind()");
|
||||
if (cursorController != null) cursorController.destroy();
|
||||
return super.onUnbind(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccessibilityEvent(AccessibilityEvent event) {
|
||||
Log.d(TAG, "onAccessibilityEvent()");
|
||||
}
|
||||
|
||||
private void broadcastBinder() {
|
||||
Log.d(TAG, "sending binder instance broadcast");
|
||||
Intent intent = new Intent(INTENT_ACCESSIBILITY_INPUT_BINDER_INSTANCE);
|
||||
Bundle extras = new Bundle();
|
||||
extras.putBinder(EXTRA_BINDER_INSTANCE, binder);
|
||||
intent.putExtras(extras);
|
||||
|
||||
LocalBroadcastManager
|
||||
.getInstance(this)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
public class Receiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
// makeshift bind
|
||||
Log.d(TAG, "got binder request");
|
||||
broadcastBinder();
|
||||
}
|
||||
}
|
||||
|
||||
private Display getDisplayCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return getDisplay();
|
||||
} else {
|
||||
DisplayManager dm = getSystemService(DisplayManager.class);
|
||||
return dm.getDisplay(Display.DEFAULT_DISPLAY);
|
||||
}
|
||||
}
|
||||
|
||||
private Point getResolution() {
|
||||
Point resolution = new Point();
|
||||
getDisplayCompat().getRealSize(resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
private AccessibilityNodeInfo getFocusedNode() {
|
||||
AccessibilityNodeInfo root = getRootInActiveWindow();
|
||||
if (root == null) {
|
||||
Log.w(TAG, "window root is null!!!");
|
||||
return null;
|
||||
}
|
||||
AccessibilityNodeInfo node = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT);
|
||||
if (node == null) node = root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY);
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
private void fakeDpad(int direction) {
|
||||
// todo: this shit sucks
|
||||
|
||||
AccessibilityNodeInfo node = getFocusedNode();
|
||||
if (node == null) return;//todo: get first focusable
|
||||
|
||||
Log.i(TAG, "faking dpad in direction " + direction);
|
||||
AccessibilityNodeInfo newNode;
|
||||
// if (direction == View.FOCUS_LEFT || direction == View.FOCUS_RIGHT)
|
||||
// newNode = traverseHorizontal(node, direction == View.FOCUS_LEFT);
|
||||
// else
|
||||
newNode = node.focusSearch(direction); // todo: traverse all children fallback
|
||||
if (newNode == null) return;
|
||||
|
||||
Log.i(TAG, "found node");
|
||||
newNode.performAction(AccessibilityNodeInfo.ACTION_FOCUS);
|
||||
|
||||
}
|
||||
|
||||
private static final int TRAVERSE_X_DISTANCE_THRESHOLD = 5;
|
||||
private AccessibilityNodeInfo traverseHorizontal(AccessibilityNodeInfo node, boolean left) {
|
||||
AccessibilityNodeInfo parent = node.getParent();
|
||||
if (parent == null) return null;
|
||||
|
||||
Rect nodeBounds = new Rect();
|
||||
node.getBoundsInScreen(nodeBounds);
|
||||
|
||||
// rank nodes by distance on x/y
|
||||
record NodeRanking(AccessibilityNodeInfo node, int distanceX, int distanceY) {}
|
||||
List<NodeRanking> rankings = new LinkedList<>();
|
||||
Rect siblingBounds = new Rect();
|
||||
for (int i = 0; i < parent.getChildCount(); i++) {
|
||||
AccessibilityNodeInfo sibling = parent.getChild(i);
|
||||
if (sibling == node) continue;
|
||||
sibling.getBoundsInScreen(siblingBounds);
|
||||
|
||||
int distanceX = left ? nodeBounds.left - siblingBounds.right : (siblingBounds.left - nodeBounds.right);
|
||||
Log.i(TAG, "dx:" + distanceX);
|
||||
if (distanceX < 0) continue;
|
||||
|
||||
int distanceY = Math.abs(nodeBounds.centerY() - siblingBounds.centerY());
|
||||
|
||||
rankings.add(new NodeRanking(sibling, distanceX, distanceY));
|
||||
}
|
||||
|
||||
Log.i(TAG, "Found " + rankings.size() + " nodes in direction");
|
||||
|
||||
// search parent if no nodes in direction
|
||||
if (rankings.isEmpty()) return traverseHorizontal(parent, left);
|
||||
|
||||
// rank nodes by x axis first
|
||||
rankings.sort(Comparator.comparingInt(NodeRanking::distanceX));
|
||||
|
||||
// eliminate nodes outside of a certain threshold from the first node
|
||||
NodeRanking closestX = rankings.remove(0);
|
||||
List<NodeRanking> newRankings = new LinkedList<>(Collections.singletonList(closestX));
|
||||
while (!rankings.isEmpty()) {
|
||||
NodeRanking ranking = rankings.remove(0);
|
||||
if (closestX.distanceX() >= ranking.distanceX() - TRAVERSE_X_DISTANCE_THRESHOLD) break;
|
||||
newRankings.add(ranking);
|
||||
}
|
||||
|
||||
Log.i(TAG, "trimmed to " + newRankings.size() + " candidates");
|
||||
|
||||
rankings.clear();
|
||||
rankings = newRankings;
|
||||
|
||||
// sort by distance y to get the closest node vertically
|
||||
rankings.sort(Comparator.comparingInt(NodeRanking::distanceY));
|
||||
assert !rankings.isEmpty();
|
||||
|
||||
return rankings.remove(0).node();
|
||||
}
|
||||
|
||||
public class AccessibilityInputHandler extends Binder implements InputHandler {
|
||||
|
||||
public void showPairingDialog() {
|
||||
|
||||
AtomicReference<PairingDialog> pd = new AtomicReference<>();
|
||||
|
||||
PairingCallback cb = new PairingCallback() {
|
||||
@Override
|
||||
public void cancel() {
|
||||
pd.get().destroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disablePairingForAWhile(TimeUnit timeUnit, long period) {
|
||||
pd.get().destroy();
|
||||
}
|
||||
};
|
||||
|
||||
pd.set(new PairingDialog(AccessibilityInputService.this, cb, 696969, "deez nuts".getBytes(StandardCharsets.UTF_8)));
|
||||
pd.get().start();
|
||||
}
|
||||
|
||||
public void showTestNotification() {
|
||||
notificationOverlay.displayNotification("Test notification", "this is a test", R.drawable.ic_launcher_foreground);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScreenWidth() {
|
||||
return getResolution().x;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScreenHeight() {
|
||||
return getResolution().y;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadDown() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
performGlobalAction(GLOBAL_ACTION_DPAD_DOWN);
|
||||
} else {
|
||||
fakeDpad(View.FOCUS_DOWN);
|
||||
|
||||
// todo: use this if installed as system
|
||||
// Instrumentation i = new Instrumentation();
|
||||
// i.sendKeyDownUpSync(KeyEvent.KEYCODE_DPAD_DOWN);
|
||||
|
||||
// todo
|
||||
// InputMethod.AccessibilityInputConnection c;
|
||||
// c.sendKeyEvent();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadUp() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
performGlobalAction(GLOBAL_ACTION_DPAD_UP);
|
||||
} else {
|
||||
fakeDpad(View.FOCUS_UP);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadLeft() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
performGlobalAction(GLOBAL_ACTION_DPAD_LEFT);
|
||||
} else {
|
||||
fakeDpad(View.FOCUS_BACKWARD);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadRight() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
performGlobalAction(GLOBAL_ACTION_DPAD_RIGHT);
|
||||
} else {
|
||||
fakeDpad(View.FOCUS_FORWARD);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadSelect() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
performGlobalAction(GLOBAL_ACTION_DPAD_CENTER);
|
||||
} else {
|
||||
AccessibilityNodeInfo node = getFocusedNode();
|
||||
if (node != null) node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dpadLongPress() {
|
||||
AccessibilityNodeInfo node = getFocusedNode();
|
||||
if (node != null) node.performAction(AccessibilityNodeInfo.ACTION_LONG_CLICK);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navHome() {
|
||||
performGlobalAction(GLOBAL_ACTION_HOME);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navBack() {
|
||||
performGlobalAction(GLOBAL_ACTION_BACK);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navRecent() {
|
||||
performGlobalAction(GLOBAL_ACTION_RECENTS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navApps() {
|
||||
// TODO: probably just remove this
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
performGlobalAction(GLOBAL_ACTION_ACCESSIBILITY_ALL_APPS);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navNotifications() {
|
||||
performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void navQuickSettings() {
|
||||
performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS);
|
||||
}
|
||||
|
||||
// todo: power (sleep and menu)
|
||||
|
||||
@Override
|
||||
public void volumeUp() {
|
||||
AudioManager audioManager = getSystemService(AudioManager.class);
|
||||
audioManager.adjustVolume(AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void volumeDown() {
|
||||
AudioManager audioManager = getSystemService(AudioManager.class);
|
||||
audioManager.adjustVolume(AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mute() {
|
||||
AudioManager audioManager = getSystemService(AudioManager.class);
|
||||
audioManager.adjustVolume(AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void nextTrack() {
|
||||
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prevTrack() {
|
||||
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void skipBackward() {
|
||||
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void skipForward() {
|
||||
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean softKeyboardEnabled() {
|
||||
// TODO
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean softKeyboardVisible() {
|
||||
// TODO
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showSoftKeyboard() {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideSoftKeyboard() {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSoftKeyboardEnabled(boolean enabled) {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void keyboardInput(String input) {
|
||||
// TODO: this is probably wrong
|
||||
AccessibilityNodeInfo node = getFocusedNode();
|
||||
if (node == null) return;
|
||||
node.setText(node.getText() + input);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean cursorSupported() {
|
||||
return cursorController != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showCursor() {
|
||||
if (cursorController == null) return;
|
||||
cursorController.showCursor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideCursor() {
|
||||
if (cursorController == null) return;
|
||||
cursorController.hideCursor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorMove(int x, int y) {
|
||||
if (cursorController == null) return;
|
||||
cursorController.cursorMove(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorDown() {
|
||||
if (cursorController == null) return;
|
||||
cursorController.cursorDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorUp() {
|
||||
if (cursorController == null) return;
|
||||
cursorController.cursorUp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorContext() {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void scrollVertical(double trajectory, boolean glide) {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void scrollHorizontal(double trajectory, boolean glide) {
|
||||
// TODO
|
||||
|
||||
}
|
||||
|
||||
public AccessibilityInputService getService() {
|
||||
return AccessibilityInputService.this;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package io.benwiegand.atvremote.receiver.control;
|
||||
|
||||
public interface InputHandler {
|
||||
|
||||
int getScreenWidth();
|
||||
int getScreenHeight();
|
||||
|
||||
void dpadDown();
|
||||
void dpadUp();
|
||||
void dpadLeft();
|
||||
void dpadRight();
|
||||
void dpadSelect();
|
||||
void dpadLongPress();
|
||||
|
||||
void navHome();
|
||||
void navBack();
|
||||
void navRecent();
|
||||
void navApps();
|
||||
void navNotifications();
|
||||
void navQuickSettings();
|
||||
|
||||
void volumeUp();
|
||||
void volumeDown();
|
||||
void mute();
|
||||
|
||||
void pause();
|
||||
void nextTrack();
|
||||
void prevTrack();
|
||||
void skipBackward();
|
||||
void skipForward();
|
||||
|
||||
boolean softKeyboardEnabled();
|
||||
boolean softKeyboardVisible();
|
||||
void showSoftKeyboard();
|
||||
void hideSoftKeyboard();
|
||||
void setSoftKeyboardEnabled(boolean enabled);
|
||||
void keyboardInput(String input);
|
||||
|
||||
boolean cursorSupported();
|
||||
void showCursor();
|
||||
void hideCursor();
|
||||
void cursorMove(int x, int y);
|
||||
void cursorDown();
|
||||
void cursorUp();
|
||||
void cursorContext();
|
||||
|
||||
void scrollVertical(double trajectory, boolean glide);
|
||||
void scrollHorizontal(double trajectory, boolean glide);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package io.benwiegand.atvremote.receiver.control.cursor;
|
||||
|
||||
import android.accessibilityservice.AccessibilityService;
|
||||
import android.accessibilityservice.GestureDescription;
|
||||
import android.graphics.Path;
|
||||
import android.util.Log;
|
||||
|
||||
public class AccessibilityGestureCursor extends FakeCursor {
|
||||
private static final String TAG = AccessibilityGestureCursor.class.getSimpleName();
|
||||
|
||||
private GestureDescription.StrokeDescription gestureStroke = null;
|
||||
|
||||
|
||||
public AccessibilityGestureCursor(AccessibilityService context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
private void dispatchStrokeLocked() {
|
||||
context.dispatchGesture(
|
||||
new GestureDescription.Builder().addStroke(gestureStroke).build(),
|
||||
new AccessibilityService.GestureResultCallback() {
|
||||
@Override
|
||||
public void onCompleted(GestureDescription gestureDescription) {
|
||||
Log.d(TAG, "on gesture completed");
|
||||
super.onCompleted(gestureDescription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCancelled(GestureDescription gestureDescription) {
|
||||
Log.w(TAG, "on gesture cancelled");
|
||||
super.onCancelled(gestureDescription);
|
||||
}
|
||||
}, handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleDragLocked(int oldX, int oldY) {
|
||||
if (gestureStroke == null) return;
|
||||
|
||||
// click and drag
|
||||
Log.d(TAG, "STROKING " + oldX + ", " + oldY + " -> " + cursorX + ", " + cursorY);
|
||||
Path path = new Path();
|
||||
path.moveTo(oldX, oldY);
|
||||
path.lineTo(cursorX, cursorY);
|
||||
gestureStroke = gestureStroke.continueStroke(path, 0, 1, true);
|
||||
dispatchStrokeLocked();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMouseDownLocked() {
|
||||
// no need to cancel active stroke, this will overwrite it
|
||||
Log.d(TAG, "START STROKE: " + cursorX + ", " + cursorY);
|
||||
Path path = new Path();
|
||||
path.moveTo(cursorX, cursorY);
|
||||
gestureStroke = new GestureDescription.StrokeDescription(path, 0, 1, true);
|
||||
dispatchStrokeLocked();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMouseUpLocked() {
|
||||
if (gestureStroke == null) return;
|
||||
|
||||
Log.d(TAG, "END STROKE: " + cursorX + ", " + cursorY);
|
||||
Path path = new Path();
|
||||
path.moveTo(cursorX, cursorY);
|
||||
gestureStroke = gestureStroke.continueStroke(path, 0, 1, false);
|
||||
dispatchStrokeLocked();
|
||||
gestureStroke = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
super.destroy();
|
||||
synchronized (cursorLock) {
|
||||
handleMouseUpLocked();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package io.benwiegand.atvremote.receiver.control.cursor;
|
||||
|
||||
public interface CursorController {
|
||||
void showCursor();
|
||||
void hideCursor();
|
||||
void cursorMove(int deltaX, int deltaY);
|
||||
void cursorDown();
|
||||
void cursorUp();
|
||||
|
||||
void destroy();
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package io.benwiegand.atvremote.receiver.control.cursor;
|
||||
|
||||
import android.accessibilityservice.AccessibilityService;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
|
||||
public abstract class FakeCursor implements CursorController {
|
||||
private static final String TAG = FakeCursor.class.getSimpleName();
|
||||
private static final long HIDE_CURSOR_AFTER = 10000;
|
||||
private static final long HIDE_CURSOR_POLL_INTERVAL = 1000;
|
||||
|
||||
private static final WindowManager.LayoutParams CURSOR_LAYOUT_PARAMS = new WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
|
||||
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT);
|
||||
|
||||
protected final Handler handler = new Handler(Looper.getMainLooper());
|
||||
protected AccessibilityService context;
|
||||
|
||||
protected final Object cursorLock = new Object();
|
||||
protected int cursorX = 0;
|
||||
protected int cursorY = 0;
|
||||
private View overlayView = null;
|
||||
private boolean destroyed = false;
|
||||
private long cursorLastTouched = 0;
|
||||
|
||||
|
||||
public FakeCursor(AccessibilityService context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
protected abstract void handleDragLocked(int oldX, int oldY);
|
||||
protected abstract void handleMouseDownLocked();
|
||||
protected abstract void handleMouseUpLocked();
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
private View inflateCursor() {
|
||||
LayoutInflater inflater = LayoutInflater.from(context);
|
||||
View view = inflater.inflate(R.layout.layout_cursor, null);
|
||||
// todo: fix cutout (tvs don't usually have cutouts so this isn't too important)
|
||||
|
||||
// set image manually because there's no root for the inflater to derive it from
|
||||
ImageView cursor = view.findViewById(R.id.cursor);
|
||||
Drawable cursorImage = AppCompatResources.getDrawable(context, R.drawable.mouse);
|
||||
cursor.setImageDrawable(cursorImage);
|
||||
|
||||
// make sure cursor is in the right position
|
||||
cursor.setTranslationX(cursorX);
|
||||
cursor.setTranslationY(cursorY);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
public void keepCursorVisible() {
|
||||
synchronized (cursorLock) {
|
||||
cursorLastTouched = SystemClock.elapsedRealtime();
|
||||
showCursor();
|
||||
}
|
||||
}
|
||||
|
||||
private void hideCursorAfterTimeout() {
|
||||
synchronized (cursorLock) {
|
||||
if (SystemClock.elapsedRealtime() - cursorLastTouched >= HIDE_CURSOR_AFTER)
|
||||
hideCursor();
|
||||
else
|
||||
handler.postDelayed(this::hideCursorAfterTimeout, HIDE_CURSOR_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showCursor() {
|
||||
if (overlayView != null) return;
|
||||
|
||||
handler.post(() -> {
|
||||
View view = inflateCursor();
|
||||
WindowManager wm = context.getSystemService(WindowManager.class);
|
||||
|
||||
synchronized (cursorLock) {
|
||||
if (destroyed) return;
|
||||
if (overlayView != null) return;
|
||||
wm.addView(view, CURSOR_LAYOUT_PARAMS);
|
||||
overlayView = view;
|
||||
|
||||
handler.postDelayed(this::hideCursorAfterTimeout, HIDE_CURSOR_AFTER);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideCursor() {
|
||||
if (overlayView == null) return;
|
||||
|
||||
handler.post(() -> {
|
||||
WindowManager wm = context.getSystemService(WindowManager.class);
|
||||
|
||||
synchronized (cursorLock) {
|
||||
if (overlayView == null) return;
|
||||
wm.removeView(overlayView);
|
||||
overlayView = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorMove(int x, int y) {
|
||||
keepCursorVisible();
|
||||
|
||||
handler.post(() -> {
|
||||
synchronized (cursorLock) {
|
||||
if (overlayView == null) return;
|
||||
|
||||
int oldX = cursorX;
|
||||
int oldY = cursorY;
|
||||
cursorX += x;
|
||||
cursorY += y;
|
||||
|
||||
// limit cursor to overlay bounds
|
||||
int width = overlayView.getWidth();
|
||||
int height = overlayView.getHeight();
|
||||
if (width != 0 && height != 0) { // dimensions may be 0 for a brief period while showing
|
||||
if (cursorX > width) cursorX = width;
|
||||
else if (cursorX < 0) cursorX = 0;
|
||||
if (cursorY > height) cursorY = height;
|
||||
else if (cursorY < 0) cursorY = 0;
|
||||
}
|
||||
|
||||
// update visible cursor
|
||||
View cursor = overlayView.findViewById(R.id.cursor);
|
||||
cursor.setTranslationX(cursorX);
|
||||
cursor.setTranslationY(cursorY);
|
||||
|
||||
handleDragLocked(oldX, oldY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorDown() {
|
||||
keepCursorVisible();
|
||||
|
||||
handler.post(() -> {
|
||||
synchronized (cursorLock) {
|
||||
if (overlayView == null) return;
|
||||
handleMouseDownLocked();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cursorUp() {
|
||||
keepCursorVisible();
|
||||
|
||||
handler.post(() -> {
|
||||
synchronized (cursorLock) {
|
||||
if (overlayView == null) return;
|
||||
handleMouseUpLocked();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
Log.d(TAG, "destroy()");
|
||||
synchronized (cursorLock) {
|
||||
destroyed = true;
|
||||
if (overlayView != null) {
|
||||
WindowManager wm = context.getSystemService(WindowManager.class);
|
||||
wm.removeView(overlayView);
|
||||
overlayView = null;
|
||||
}
|
||||
}
|
||||
|
||||
context = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package io.benwiegand.atvremote.receiver.network;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.protocol.ProtocolConstants.MDNS_SERVICE_TYPE;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.nsd.NsdManager;
|
||||
import android.net.nsd.NsdServiceInfo;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
|
||||
public class ServiceAdvertiser implements NsdManager.RegistrationListener {
|
||||
private static final String TAG = ServiceAdvertiser.class.getSimpleName();
|
||||
|
||||
private final NsdServiceInfo serviceInfo;
|
||||
private final NsdManager nsdManager;
|
||||
|
||||
public ServiceAdvertiser(NsdManager nsdManager, NsdServiceInfo serviceInfo) {
|
||||
this.serviceInfo = serviceInfo;
|
||||
this.nsdManager = nsdManager;
|
||||
}
|
||||
|
||||
public void register() {
|
||||
Log.d(TAG, "registering NSD service");
|
||||
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, this);
|
||||
}
|
||||
|
||||
public void unregister() {
|
||||
Log.d(TAG, "unregistering NSD service");
|
||||
nsdManager.unregisterService(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceRegistered(NsdServiceInfo serviceInfo) {
|
||||
Log.i(TAG, "NSD service registered as: " + serviceInfo.getServiceName());
|
||||
Log.d(TAG, serviceInfo.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
|
||||
Log.e(TAG, "NSD registration failed: " + errorCode);
|
||||
Log.e(TAG, serviceInfo.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceUnregistered(NsdServiceInfo serviceInfo) {
|
||||
Log.i(TAG, "NSD service unregistered");
|
||||
Log.d(TAG, serviceInfo.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {
|
||||
Log.e(TAG, "NSD unregistration failed: " + errorCode);
|
||||
Log.e(TAG, serviceInfo.toString());
|
||||
}
|
||||
|
||||
private static String findDeviceName(Context context) {
|
||||
String hostname = Settings.Global.getString(context.getContentResolver(), "device_name");
|
||||
if (hostname != null) return hostname;
|
||||
|
||||
Log.d(TAG, "no device_name, falling back to app name");
|
||||
return context.getString(R.string.app_name);
|
||||
}
|
||||
|
||||
public static ServiceAdvertiser createReceiverAdvertiser(Context context, NsdManager nsdManager, int port) {
|
||||
NsdServiceInfo serviceInfo = new NsdServiceInfo();
|
||||
|
||||
serviceInfo.setServiceName(findDeviceName(context));
|
||||
serviceInfo.setServiceType(MDNS_SERVICE_TYPE);
|
||||
serviceInfo.setPort(port);
|
||||
|
||||
return new ServiceAdvertiser(nsdManager, serviceInfo);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package io.benwiegand.atvremote.receiver.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(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.receiver.network;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.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);
|
||||
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';
|
||||
}
|
||||
|
||||
if (len == -1) throw new IOException("EOS (got -1)");
|
||||
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.receiver.network;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.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,270 @@
|
||||
package io.benwiegand.atvremote.receiver.network;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.network.SocketUtil.tryClose;
|
||||
import static io.benwiegand.atvremote.receiver.protocol.ProtocolConstants.*;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.net.InetAddress;
|
||||
import java.net.SocketException;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
import io.benwiegand.atvremote.receiver.control.InputHandler;
|
||||
import io.benwiegand.atvremote.receiver.protocol.AccessibilityContextNeeded;
|
||||
import io.benwiegand.atvremote.receiver.protocol.PairingData;
|
||||
import io.benwiegand.atvremote.receiver.protocol.PairingManager;
|
||||
import io.benwiegand.atvremote.receiver.ui.NotificationOverlay;
|
||||
|
||||
public class TVRemoteConnection implements Closeable {
|
||||
private static final String TAG = TVRemoteConnection.class.getSimpleName();
|
||||
|
||||
private static final int SOCKET_AUTH_TIMEOUT = 3000;
|
||||
private static final int PAIRING_TIME_LIMIT = 360000; // 5 mins //todo
|
||||
private static final long KEEPALIVE_INTERVAL = 5000;
|
||||
private static final long KEEPALIVE_TIMEOUT = KEEPALIVE_INTERVAL * 2;
|
||||
|
||||
private final Thread thread = new Thread(this::run);
|
||||
private PairingData pairingData = null; //todo: update pairing data with pairing manager
|
||||
private boolean dead = false;
|
||||
|
||||
private final PairingManager pairingManager;
|
||||
private final SSLSocket socket;
|
||||
private InputHandler inputHandler;
|
||||
private NotificationOverlay notificationOverlay;
|
||||
|
||||
public TVRemoteConnection(PairingManager pairingManager, SSLSocket socket, InputHandler inputHandler, NotificationOverlay notificationOverlay) {
|
||||
this.pairingManager = pairingManager;
|
||||
this.socket = socket;
|
||||
this.inputHandler = inputHandler;
|
||||
this.notificationOverlay = notificationOverlay;
|
||||
|
||||
thread.start();
|
||||
}
|
||||
|
||||
public void setInputHandler(InputHandler inputHandler) {
|
||||
this.inputHandler = inputHandler;
|
||||
}
|
||||
|
||||
public void setNotificationOverlay(NotificationOverlay notificationOverlay) {
|
||||
this.notificationOverlay = notificationOverlay;
|
||||
}
|
||||
|
||||
public InetAddress getRemoteAddress() {
|
||||
return socket.getInetAddress();
|
||||
}
|
||||
|
||||
public boolean isDead() {
|
||||
return dead;
|
||||
}
|
||||
|
||||
private void run() {
|
||||
TCPReader reader = null;
|
||||
TCPWriter writer = null;
|
||||
try {
|
||||
Log.d(TAG, "Connection from " + socket.getRemoteSocketAddress());
|
||||
|
||||
// init socket
|
||||
socket.setTcpNoDelay(true);
|
||||
socket.setTrafficClass(0x10 /* lowdelay */);
|
||||
socket.startHandshake();
|
||||
|
||||
writer = TCPWriter.createFromStream(socket.getOutputStream(), CHARSET);
|
||||
reader = TCPReader.createFromStream(socket.getInputStream(), CHARSET);
|
||||
|
||||
String version = reader.nextLine(SOCKET_AUTH_TIMEOUT);
|
||||
|
||||
// check verison
|
||||
if (!VERSION_1.equals(version)) {
|
||||
writer.sendLine(OP_UNSUPPORTED);
|
||||
return;
|
||||
}
|
||||
writer.sendLine(OP_CONFIRM);
|
||||
|
||||
String op = reader.nextLine(SOCKET_AUTH_TIMEOUT);
|
||||
|
||||
if (op.equals(INIT_OP_PAIR)) {
|
||||
doPairing(writer, reader);
|
||||
return; // force a reconnection
|
||||
} else if (!op.equals(INIT_OP_CONNECT)) {
|
||||
throw new RuntimeException("Bad initial operation");
|
||||
}
|
||||
|
||||
String auth = reader.nextLine(SOCKET_AUTH_TIMEOUT);
|
||||
pairingData = pairingManager.fetchPairingData(auth);
|
||||
if (pairingData == null) {
|
||||
Log.w(TAG, "client sent invalid authorization token");
|
||||
writer.sendLine(OP_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
assert auth.equals(pairingData.token()); // the token should match
|
||||
|
||||
writer.sendLine(OP_CONFIRM);
|
||||
|
||||
// connection is trusted at this point
|
||||
Log.i(TAG, "remote connected: " + socket.getRemoteSocketAddress());
|
||||
|
||||
if (notificationOverlay != null)
|
||||
notificationOverlay.displayNotification(R.string.notification_remote_connected_title, socket.getRemoteSocketAddress().toString(), androidx.leanback.R.drawable.lb_ic_sad_cloud); //todo
|
||||
connectionLoop(writer, reader);
|
||||
|
||||
} catch (SocketException e) {
|
||||
Log.e(TAG, "socket died", e);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "IOException in connection", e);
|
||||
} catch (RuntimeException e) {
|
||||
Log.e(TAG, "unexpected error in connection", e);
|
||||
} catch (InterruptedException e) {
|
||||
Log.d(TAG, "interrupted", e);
|
||||
} finally {
|
||||
// todo: cleanup callback
|
||||
tryClose(this);
|
||||
if (reader != null) tryClose(reader);
|
||||
if (writer != null) tryClose(writer);
|
||||
}
|
||||
}
|
||||
|
||||
private void doPairing(TCPWriter writer, TCPReader reader) throws IOException, InterruptedException {
|
||||
Log.v(TAG, "starting pairing");
|
||||
Runnable cancelCallback = () -> tryClose(socket);
|
||||
|
||||
// try to start pairing
|
||||
try {
|
||||
pairingManager.startPairing(cancelCallback);
|
||||
} catch (AccessibilityContextNeeded e) {
|
||||
writer.sendLine(OP_UNREADY);
|
||||
throw new RuntimeException("no accessibility context yet", e);
|
||||
}
|
||||
|
||||
try {
|
||||
String line;
|
||||
do {
|
||||
writer.sendLine(OP_CONFIRM);
|
||||
writer.sendLine(OP_READY);
|
||||
line = reader.nextLine(KEEPALIVE_TIMEOUT);
|
||||
if (line == null) throw generateKeepaliveTimeoutException();
|
||||
} while (line.equals(OP_PING));
|
||||
|
||||
int code;
|
||||
try {
|
||||
code = Integer.parseInt(line);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new RuntimeException("received pairing code was not a number", e);
|
||||
}
|
||||
|
||||
String token = pairingManager.pair(code, cancelCallback);
|
||||
|
||||
if (token == null) {
|
||||
Log.w(TAG, "pairing code was wrong");
|
||||
writer.sendLine(OP_UNAUTHORIZED);
|
||||
if (notificationOverlay != null)
|
||||
notificationOverlay.displayNotification(R.string.notification_pairing_failed_title, R.string.notification_pairing_failed_description_invalid_code, androidx.leanback.R.drawable.lb_ic_sad_cloud); //todo
|
||||
throw new RuntimeException("pairing code wrong");
|
||||
}
|
||||
|
||||
Log.i(TAG, "pairing complete");
|
||||
writer.sendLine(token);
|
||||
if (notificationOverlay != null)
|
||||
notificationOverlay.displayNotification(R.string.notification_pairing_complete_title, R.string.notification_pairing_complete_description, androidx.leanback.R.drawable.lb_ic_sad_cloud); // todo
|
||||
} finally {
|
||||
pairingManager.cancelPairing(cancelCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private IOException generateKeepaliveTimeoutException() {
|
||||
return new IOException("didn't receive anything within KEEPALIVE_TIMEOUT (" + KEEPALIVE_TIMEOUT + ")");
|
||||
}
|
||||
|
||||
private void connectionLoop(TCPWriter writer, TCPReader reader) throws IOException, InterruptedException {
|
||||
while (!dead) {
|
||||
|
||||
// enter unready state until an input handler exists
|
||||
while (inputHandler == null) {
|
||||
writer.sendLine(OP_UNREADY);
|
||||
String line = reader.nextLine(KEEPALIVE_TIMEOUT); // polling on an interval
|
||||
|
||||
// handle keepalive
|
||||
if (line == null) throw generateKeepaliveTimeoutException();
|
||||
else if (line.equals(OP_PING)) writer.sendLine(OP_CONFIRM);
|
||||
else writer.sendLine(OP_ERR);
|
||||
}
|
||||
|
||||
// wait for and execute next operation
|
||||
writer.sendLine(OP_READY);
|
||||
String line = reader.nextLine(KEEPALIVE_TIMEOUT);
|
||||
|
||||
// handle keepalive
|
||||
if (line == null) throw generateKeepaliveTimeoutException();
|
||||
String[] opLine = line.split(" ");
|
||||
|
||||
switch (opLine[0]) {
|
||||
case OP_DPAD_UP -> inputHandler.dpadUp();
|
||||
case OP_DPAD_DOWN -> inputHandler.dpadDown();
|
||||
case OP_DPAD_LEFT -> inputHandler.dpadLeft();
|
||||
case OP_DPAD_RIGHT -> inputHandler.dpadRight();
|
||||
case OP_DPAD_SELECT -> inputHandler.dpadSelect();
|
||||
case OP_DPAD_LONG_PRESS -> inputHandler.dpadLongPress();
|
||||
|
||||
case OP_NAV_HOME -> inputHandler.navHome();
|
||||
case OP_NAV_BACK -> inputHandler.navBack();
|
||||
case OP_NAV_RECENT -> inputHandler.navRecent();
|
||||
case OP_NAV_APPS -> inputHandler.navApps();
|
||||
case OP_NAV_NOTIFICATIONS -> inputHandler.navNotifications();
|
||||
case OP_NAV_QUICK_SETTINGS -> inputHandler.navQuickSettings();
|
||||
|
||||
case OP_VOLUME_UP -> inputHandler.volumeUp();
|
||||
case OP_VOLUME_DOWN -> inputHandler.volumeDown();
|
||||
case OP_MUTE -> inputHandler.mute();
|
||||
|
||||
case OP_PAUSE -> inputHandler.pause();
|
||||
case OP_NEXT_TRACK -> inputHandler.nextTrack();
|
||||
case OP_PREV_TRACK -> inputHandler.prevTrack();
|
||||
case OP_SKIP_BACKWARD -> inputHandler.skipBackward();
|
||||
case OP_SKIP_FORWARD -> inputHandler.skipForward();
|
||||
|
||||
case OP_CURSOR_SHOW -> inputHandler.showCursor();
|
||||
case OP_CURSOR_HIDE -> inputHandler.hideCursor();
|
||||
case OP_CURSOR_MOVE -> {
|
||||
if (opLine.length != 3) {
|
||||
writer.sendLine(OP_ERR);
|
||||
continue;
|
||||
}
|
||||
int x, y;
|
||||
try {
|
||||
x = Integer.parseInt(opLine[1]);
|
||||
y = Integer.parseInt(opLine[2]);
|
||||
} catch (NumberFormatException e) {
|
||||
Log.e(TAG, "malformed mouse coordinate", e);
|
||||
writer.sendLine(OP_ERR);
|
||||
continue;
|
||||
}
|
||||
inputHandler.cursorMove(x, y);
|
||||
}
|
||||
case OP_CURSOR_DOWN -> inputHandler.cursorDown();
|
||||
case OP_CURSOR_UP -> inputHandler.cursorUp();
|
||||
|
||||
case OP_PING -> {}
|
||||
default -> {
|
||||
writer.sendLine(OP_UNSUPPORTED);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
writer.sendLine(OP_CONFIRM);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
Log.d(TAG, "close()");
|
||||
dead = true;
|
||||
tryClose(socket);
|
||||
thread.interrupt();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package io.benwiegand.atvremote.receiver.network;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.network.SocketUtil.tryClose;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.nsd.NsdManager;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLServerSocket;
|
||||
import javax.net.ssl.SSLServerSocketFactory;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.auth.ssl.CorruptedKeystoreException;
|
||||
import io.benwiegand.atvremote.receiver.auth.ssl.KeyUtil;
|
||||
import io.benwiegand.atvremote.receiver.auth.ssl.KeystoreManager;
|
||||
import io.benwiegand.atvremote.receiver.control.AccessibilityInputService;
|
||||
import io.benwiegand.atvremote.receiver.control.InputHandler;
|
||||
import io.benwiegand.atvremote.receiver.protocol.PairingManager;
|
||||
import io.benwiegand.atvremote.receiver.ui.NotificationOverlay;
|
||||
|
||||
public class TVRemoteServer extends Service {
|
||||
private static final String TAG = TVRemoteServer.class.getSimpleName();
|
||||
|
||||
private static final int AUTO_PORT_NUMBER = 0;
|
||||
|
||||
private final BroadcastReceiver accessibilityBinderReceiver = new AccessibilityBinderReceiver();
|
||||
private final ServerBinder binder = new ServerBinder();
|
||||
private SSLServerSocketFactory serverSocketFactory = null;
|
||||
private PairingManager pairingManager = null;
|
||||
private NotificationOverlay notificationOverlay = null;
|
||||
|
||||
private final List<TVRemoteConnection> connections = new LinkedList<>();
|
||||
|
||||
private ServiceAdvertiser serviceAdvertiser = null;
|
||||
private NsdManager nsdManager = null;
|
||||
|
||||
private final Object listenThreadLock = new Object();
|
||||
private Thread listenThread = null;
|
||||
private InputHandler inputHandler = null;
|
||||
private SSLServerSocket serverSocket = null;
|
||||
private boolean shutdown = false;
|
||||
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
Log.d(TAG, "onCreate()");
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(AccessibilityInputService.INTENT_ACCESSIBILITY_INPUT_BINDER_INSTANCE);
|
||||
LocalBroadcastManager
|
||||
.getInstance(this)
|
||||
.registerReceiver(accessibilityBinderReceiver, filter);
|
||||
|
||||
nsdManager = this.getSystemService(NsdManager.class);
|
||||
pairingManager = new PairingManager(this);
|
||||
|
||||
|
||||
startListening();
|
||||
requestAccessibilityBinder();
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
Log.d(TAG, "onDestroy()");
|
||||
|
||||
shutdown = true;
|
||||
for (TVRemoteConnection connection : connections) {
|
||||
//todo: move off main thread
|
||||
tryClose(connection);
|
||||
}
|
||||
|
||||
tryClose(serverSocket);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
return binder;
|
||||
}
|
||||
|
||||
private void startListening() {
|
||||
synchronized (listenThreadLock) {
|
||||
if (listenThread != null) return;
|
||||
Log.i(TAG, "starting socket listen thread");
|
||||
listenThread = new Thread(this::listenLoop);
|
||||
listenThread.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void startAdvertising(int port) {
|
||||
if (serviceAdvertiser != null) serviceAdvertiser.unregister();
|
||||
serviceAdvertiser = ServiceAdvertiser.createReceiverAdvertiser(this, nsdManager, port);
|
||||
serviceAdvertiser.register();
|
||||
}
|
||||
|
||||
private void listenLoop() {
|
||||
try {
|
||||
|
||||
KeystoreManager keystoreManager;
|
||||
byte[] fingerprint;
|
||||
try {
|
||||
Log.v(TAG, "initializing keystore");
|
||||
keystoreManager = new KeystoreManager(this);
|
||||
keystoreManager.loadKeystore();
|
||||
keystoreManager.initSSL();
|
||||
keystoreManager.saveKeystore();
|
||||
|
||||
// since there's no "root of trust" here, the user must somehow compare these fingerprints
|
||||
fingerprint = KeyUtil.calculateCertificateFingerprint(keystoreManager.getSSLCertificate());
|
||||
pairingManager.setFingerprint(fingerprint);
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(keystoreManager.getKeyManagers(), keystoreManager.getTrustManagers(), SecureRandom.getInstanceStrong());
|
||||
// todo: harden supported ciphers
|
||||
// sslContext.getSupportedSSLParameters().setCipherSuites();
|
||||
|
||||
serverSocketFactory = sslContext.getServerSocketFactory();
|
||||
|
||||
} catch (IOException | CorruptedKeystoreException | KeyManagementException |
|
||||
NoSuchAlgorithmException e) {
|
||||
Log.wtf(TAG, "failed to load keystore", e);
|
||||
// todo: error notifications
|
||||
return;
|
||||
}
|
||||
|
||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||
Log.w(TAG, "closing existing server socket");
|
||||
tryClose(serverSocket);
|
||||
}
|
||||
|
||||
Log.d(TAG, "starting server socket on port " + AUTO_PORT_NUMBER);
|
||||
serverSocket = (SSLServerSocket) serverSocketFactory.createServerSocket(AUTO_PORT_NUMBER);
|
||||
Log.d(TAG, "listening on port " + serverSocket.getLocalPort());
|
||||
startAdvertising(serverSocket.getLocalPort());
|
||||
|
||||
while (!shutdown) {
|
||||
SSLSocket newSocket = (SSLSocket) serverSocket.accept();
|
||||
Log.d(TAG, "CipherSuite: " + newSocket.getSession().getCipherSuite());
|
||||
Log.d(TAG, "Protocol: " + newSocket.getSession().getProtocol());
|
||||
Log.d(TAG, "LocalPrincipal: " + newSocket.getSession().getLocalPrincipal());
|
||||
|
||||
synchronized (connections) {
|
||||
TVRemoteConnection connection = new TVRemoteConnection(pairingManager, newSocket, inputHandler, notificationOverlay);
|
||||
connections.add(connection);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// todo: try to recover from specific errors
|
||||
// - in use
|
||||
// - security exception
|
||||
Log.e(TAG, "IOException during socket listen loop", e);
|
||||
// todo: error notif
|
||||
} finally {
|
||||
stopSelf();
|
||||
}
|
||||
}
|
||||
|
||||
public void requestAccessibilityBinder() {
|
||||
Log.v(TAG, "requesting accessibility service binder");
|
||||
Intent intent = new Intent(AccessibilityInputService.INTENT_ACCESSIBILITY_INPUT_BINDER_REQUEST);
|
||||
LocalBroadcastManager
|
||||
.getInstance(this)
|
||||
.sendBroadcast(intent);
|
||||
}
|
||||
|
||||
public class AccessibilityBinderReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "got accessibility binder instance intent");
|
||||
Bundle extras = intent.getExtras();
|
||||
assert extras != null; // this intent should always have an extra
|
||||
|
||||
IBinder binder = extras.getBinder(AccessibilityInputService.EXTRA_BINDER_INSTANCE);
|
||||
Log.i(TAG, "accessibility binder instance: " + binder);
|
||||
assert binder != null; // this extra should never be null
|
||||
inputHandler = (InputHandler) binder;
|
||||
|
||||
AccessibilityInputService accessibilityContext = ((AccessibilityInputService.AccessibilityInputHandler) binder).getService();
|
||||
pairingManager.setAccessibilityContext(accessibilityContext);
|
||||
notificationOverlay = accessibilityContext.getNotificationOverlay();
|
||||
|
||||
synchronized (connections) {
|
||||
for (TVRemoteConnection connection : connections) {
|
||||
connection.setInputHandler(inputHandler);
|
||||
connection.setNotificationOverlay(notificationOverlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class ServerBinder extends Binder {
|
||||
public int getPort() {
|
||||
if (serverSocket == null) return -1;
|
||||
return serverSocket.getLocalPort();
|
||||
}
|
||||
|
||||
public List<TVRemoteConnection> getConnections() {
|
||||
return connections;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
public class AccessibilityContextNeeded extends Exception {
|
||||
public AccessibilityContextNeeded() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
public enum DeviceType {
|
||||
UNKNOWN,
|
||||
PHONE,
|
||||
TABLET,
|
||||
COMPUTER;
|
||||
|
||||
public static DeviceType fromInt(int type) {
|
||||
if (type < 0) return UNKNOWN;
|
||||
DeviceType[] types = values();
|
||||
if (type >= types.length) return UNKNOWN;
|
||||
return types[type];
|
||||
}
|
||||
|
||||
// for naming consistency sake
|
||||
public int toInt() {
|
||||
return ordinal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public interface PairingCallback {
|
||||
|
||||
void cancel();
|
||||
void disablePairingForAWhile(TimeUnit timeUnit, long period);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record PairingData(String token, String friendlyName, String lastConnectedIpAddress, long lastConnectedTimestamp, int deviceType) {
|
||||
|
||||
public Instant lastConnectedInstant() {
|
||||
if (lastConnectedTimestamp() < 0) return null;
|
||||
return Instant.ofEpochSecond(lastConnectedTimestamp());
|
||||
}
|
||||
|
||||
public DeviceType deviceTypeEnum() {
|
||||
return DeviceType.fromInt(deviceType());
|
||||
}
|
||||
|
||||
// for shared preferences
|
||||
public static final String KEY_TOKEN = "token";
|
||||
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 final String KEY_DEVICE_TYPE = "type";
|
||||
|
||||
public static PairingData readFromPreferences(SharedPreferences sp) {
|
||||
// token is required
|
||||
String token = sp.getString(KEY_TOKEN, null);
|
||||
if (token == 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,
|
||||
sp.getString(KEY_FRIENDLY_NAME, null),
|
||||
sp.getString(KEY_LAST_CONNECTED_IP_ADDRESS, null),
|
||||
sp.getLong(KEY_LAST_CONNECTED_TIMESTAMP, -1),
|
||||
sp.getInt(KEY_DEVICE_TYPE, -1)
|
||||
);
|
||||
}
|
||||
|
||||
public boolean writeToPreferences(SharedPreferences.Editor spe) {
|
||||
return spe.clear()
|
||||
.putString(KEY_TOKEN, token())
|
||||
.putString(KEY_FRIENDLY_NAME, friendlyName())
|
||||
.putString(KEY_LAST_CONNECTED_IP_ADDRESS, lastConnectedIpAddress())
|
||||
.putLong(KEY_LAST_CONNECTED_TIMESTAMP, lastConnectedTimestamp())
|
||||
.putInt(KEY_DEVICE_TYPE, deviceType())
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
import static io.benwiegand.atvremote.receiver.auth.ssl.KeyUtil.getSecureRandom;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.ui.PairingDialog;
|
||||
|
||||
public class PairingManager implements PairingCallback {
|
||||
private static final String TAG = PairingManager.class.getSimpleName();
|
||||
|
||||
private static final int TOKEN_MIN_LENGTH = 64;
|
||||
private static final int TOKEN_MAX_LENGTH = 128;
|
||||
private static final String KEY_PAIRED_DEVICES = "devices";
|
||||
private static final String KEY_PREFIX_PAIRING_DATA = "pairing_data_";
|
||||
|
||||
private final Object pairingLock = new Object();
|
||||
private PairingSession pairingSession = null;
|
||||
|
||||
private final Context context;
|
||||
private Context accessibilityContext = null;
|
||||
private byte[] fingerprint = null;
|
||||
|
||||
private final Map<String, String> tokenMap = new HashMap<>();
|
||||
|
||||
public PairingManager(Context context) {
|
||||
this.context = context;
|
||||
|
||||
loadPairedDevices();
|
||||
}
|
||||
|
||||
public void setAccessibilityContext(Context accessibilityContext) {
|
||||
this.accessibilityContext = accessibilityContext;
|
||||
}
|
||||
|
||||
public void setFingerprint(byte[] fingerprint) {
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
|
||||
private void loadPairedDevices() {
|
||||
synchronized (tokenMap) {
|
||||
Log.d(TAG, "loading token map");
|
||||
tokenMap.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 token)
|
||||
tokenMap.put(token, deviceId);
|
||||
else
|
||||
Log.wtf(TAG, "non-string value in paired devices table for key: " + deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean addNewDevice(PairingData data) {
|
||||
synchronized (tokenMap) {
|
||||
String deviceId = UUID.randomUUID().toString();
|
||||
|
||||
boolean tokenCommitted = context.getSharedPreferences(KEY_PAIRED_DEVICES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(deviceId, data.token())
|
||||
.commit();
|
||||
|
||||
if (!tokenCommitted) {
|
||||
Log.wtf(TAG, "failed to write token to preference map");
|
||||
return false;
|
||||
}
|
||||
|
||||
tokenMap.put(data.token(), deviceId);
|
||||
|
||||
return writePairingData(deviceId, data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private boolean writePairingData(String deviceId, PairingData data) {
|
||||
return data.writeToPreferences(sharedPreferencesForDevice(deviceId).edit());
|
||||
}
|
||||
|
||||
public PairingData fetchPairingData(String token) {
|
||||
String deviceId;
|
||||
synchronized (tokenMap) {
|
||||
deviceId = tokenMap.get(token);
|
||||
if (deviceId == null) return null;
|
||||
}
|
||||
|
||||
return PairingData.readFromPreferences(sharedPreferencesForDevice(deviceId));
|
||||
}
|
||||
|
||||
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 void startPairing(Runnable cancelCallback) throws AccessibilityContextNeeded {
|
||||
synchronized (pairingLock) {
|
||||
if (pairingSession == null) pairingSession = createPairingSessionLocked();
|
||||
pairingSession.cancelCallbacks().add(cancelCallback);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean updatePairingData(PairingData pairingData) {
|
||||
String deviceId;
|
||||
synchronized (tokenMap) {
|
||||
deviceId = tokenMap.get(pairingData.token());
|
||||
if (deviceId == null) return false;
|
||||
}
|
||||
|
||||
return writePairingData(deviceId, pairingData);
|
||||
}
|
||||
|
||||
public String pair(int pairingCode, Runnable cancelCallback) {
|
||||
synchronized (pairingLock) {
|
||||
if (pairingSession == null) return null;
|
||||
|
||||
PairingSession pairingSession = this.pairingSession;
|
||||
pairingSession.cancelCallbacks().remove(cancelCallback);
|
||||
cancelPairingLocked();
|
||||
|
||||
boolean success = false;
|
||||
try {
|
||||
if (pairingSession.pairingCode() != pairingCode) {
|
||||
Log.v(TAG, "wrong code provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
String token = generateToken();
|
||||
|
||||
PairingData data = new PairingData(token, null, null, -1, -1);
|
||||
if (!addNewDevice(data)) return null;
|
||||
|
||||
success = true;
|
||||
return token;
|
||||
} finally {
|
||||
if (!success) {
|
||||
try {
|
||||
cancelCallback.run();
|
||||
} catch (Throwable t) {
|
||||
Log.wtf(TAG, "exception thrown in cancel callback", t);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void cancelPairing(Runnable cancelCallback) {
|
||||
synchronized (pairingLock) {
|
||||
if (pairingSession == null) return;
|
||||
pairingSession.cancelCallbacks().remove(cancelCallback);
|
||||
if (pairingSession.cancelCallbacks().isEmpty()) cancelPairingLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static int nextIntBetween(SecureRandom r, int min, int upperbound) {
|
||||
return r.nextInt(upperbound - min) + min;
|
||||
}
|
||||
|
||||
private String generateToken() {
|
||||
SecureRandom r = getSecureRandom();
|
||||
|
||||
int length = nextIntBetween(r, TOKEN_MIN_LENGTH, TOKEN_MAX_LENGTH);
|
||||
char[] cBuffer = new char[length];
|
||||
for (int i = 0; i < length; i++) {
|
||||
cBuffer[i] = (char) nextIntBetween(r, 33, 127);
|
||||
}
|
||||
|
||||
return new String(cBuffer);
|
||||
}
|
||||
|
||||
private PairingSession createPairingSessionLocked() throws AccessibilityContextNeeded {
|
||||
Log.i(TAG, "starting new pairing session");
|
||||
|
||||
int code = getSecureRandom().nextInt(999999);
|
||||
|
||||
// todo: system app support should bypass this
|
||||
if (accessibilityContext == null) throw new AccessibilityContextNeeded();
|
||||
if (fingerprint == null) throw new IllegalStateException("fingerprint should be set for a pairing process to initiate");
|
||||
|
||||
PairingDialog dialog = new PairingDialog(accessibilityContext, this, code, fingerprint);
|
||||
dialog.start();
|
||||
|
||||
return new PairingSession(dialog, new LinkedList<>(), code, fingerprint);
|
||||
}
|
||||
|
||||
private void cancelPairingLocked() {
|
||||
if (pairingSession == null) return;
|
||||
pairingSession.dialog().destroy();
|
||||
for (Runnable cancelCb : pairingSession.cancelCallbacks()) {
|
||||
try {
|
||||
cancelCb.run();
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "exception in cancel callback", t);
|
||||
}
|
||||
}
|
||||
pairingSession = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel() {
|
||||
synchronized (pairingLock) {
|
||||
cancelPairingLocked();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disablePairingForAWhile(TimeUnit timeUnit, long period) {
|
||||
synchronized (pairingLock) {
|
||||
cancelPairingLocked();
|
||||
// todo: apply pairing ban
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package io.benwiegand.atvremote.receiver.protocol;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.ui.PairingDialog;
|
||||
|
||||
public record PairingSession(PairingDialog dialog, List<Runnable> cancelCallbacks, int pairingCode, byte[] fingerprint) {
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package io.benwiegand.atvremote.receiver.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,147 @@
|
||||
package io.benwiegand.atvremote.receiver.ui;
|
||||
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.activity.EdgeToEdge;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
import io.benwiegand.atvremote.receiver.control.AccessibilityInputService;
|
||||
import io.benwiegand.atvremote.receiver.network.TVRemoteConnection;
|
||||
import io.benwiegand.atvremote.receiver.network.TVRemoteServer;
|
||||
|
||||
public class DebugActivity extends AppCompatActivity {
|
||||
private static final String TAG = DebugActivity.class.getSimpleName();
|
||||
|
||||
private AccessibilityInputService.AccessibilityInputHandler binder = null;
|
||||
private final BroadcastReceiver receiver = new Receiver();
|
||||
|
||||
private TVRemoteServer.ServerBinder serverBinder = null;
|
||||
|
||||
@SuppressLint({"InlinedApi"})
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
EdgeToEdge.enable(this);
|
||||
setContentView(R.layout.activity_debug);
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.debug), (v, insets) -> {
|
||||
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
|
||||
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
|
||||
return insets;
|
||||
});
|
||||
|
||||
findViewById(R.id.try_bind_button).setOnClickListener(v -> {
|
||||
Intent intent = new Intent(AccessibilityInputService.INTENT_ACCESSIBILITY_INPUT_BINDER_REQUEST);
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
||||
Log.i(TAG, "binder request broadcast sent");
|
||||
});
|
||||
|
||||
findViewById(R.id.press_home_button).setOnClickListener(v -> {
|
||||
if (binder == null) return;
|
||||
binder.navHome();
|
||||
});
|
||||
|
||||
findViewById(R.id.show_notifs_button).setOnClickListener(v -> {
|
||||
if (binder == null) return;
|
||||
binder.navNotifications();
|
||||
});
|
||||
|
||||
findViewById(R.id.show_test_notif_button).setOnClickListener(v -> {
|
||||
if (binder == null) return;
|
||||
binder.showTestNotification();
|
||||
});
|
||||
|
||||
findViewById(R.id.show_cursor_button).setOnClickListener(v -> {
|
||||
if (binder == null) return;
|
||||
binder.showCursor();
|
||||
binder.cursorMove(420, 420);
|
||||
});
|
||||
|
||||
findViewById(R.id.show_pairing_dialog_button).setOnClickListener(v -> {
|
||||
if (binder == null) return;
|
||||
binder.showPairingDialog();
|
||||
});
|
||||
|
||||
findViewById(R.id.start_server_button).setOnClickListener(v -> {
|
||||
Intent sintent = new Intent(this, TVRemoteServer.class);
|
||||
startService(sintent);
|
||||
bindService(sintent, new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName name, IBinder service) {
|
||||
Log.i(TAG, "service connected: " + name);
|
||||
serverBinder = (TVRemoteServer.ServerBinder) service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
Log.i(TAG, "service disconnected: " + name);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
findViewById(R.id.accessibility_settings_button).setOnClickListener(v ->
|
||||
startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)));
|
||||
|
||||
findViewById(R.id.update_debug_info_button).setOnClickListener(v -> {
|
||||
TextView debugInfoText = findViewById(R.id.debug_info_text);
|
||||
|
||||
StringBuilder sb = new StringBuilder("====== debug ======\n");
|
||||
|
||||
if (binder != null) sb.append("accessibility service connected\n");
|
||||
if (serverBinder != null) {
|
||||
sb.append("server connected\n")
|
||||
.append("port: ")
|
||||
.append(serverBinder.getPort())
|
||||
.append("\n")
|
||||
.append("connections (")
|
||||
.append(serverBinder.getConnections().size())
|
||||
.append("):\n");
|
||||
|
||||
for (TVRemoteConnection conn : serverBinder.getConnections()) sb
|
||||
.append(" - from ")
|
||||
.append(conn.getRemoteAddress())
|
||||
.append(" - dead = ")
|
||||
.append(conn.isDead())
|
||||
.append("\n");
|
||||
|
||||
}
|
||||
|
||||
debugInfoText.setText(sb.toString());
|
||||
});
|
||||
|
||||
// todo
|
||||
// startActivity(new Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS));
|
||||
// startActivity(new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS));
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(AccessibilityInputService.INTENT_ACCESSIBILITY_INPUT_BINDER_INSTANCE);
|
||||
LocalBroadcastManager.getInstance(this)
|
||||
.registerReceiver(receiver, filter);
|
||||
}
|
||||
|
||||
public class Receiver extends BroadcastReceiver {
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
binder = (AccessibilityInputService.AccessibilityInputHandler) intent.getExtras().getBinder(AccessibilityInputService.EXTRA_BINDER_INSTANCE);
|
||||
Log.i(TAG, "got binder instance: " + binder);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package io.benwiegand.atvremote.receiver.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
|
||||
public abstract class MakeshiftActivity {
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Context context;
|
||||
private final WindowManager wm;
|
||||
private final WindowManager.LayoutParams layoutParams;
|
||||
|
||||
protected final View root;
|
||||
|
||||
protected MakeshiftActivity(Context context, @LayoutRes int layout, WindowManager.LayoutParams layoutParams) {
|
||||
this.context = context;
|
||||
this.wm = context.getSystemService(WindowManager.class);
|
||||
this.layoutParams = layoutParams;
|
||||
|
||||
root = getLayoutInflater().inflate(layout, null);
|
||||
}
|
||||
|
||||
|
||||
public void start() {
|
||||
runOnUiThread(this::show);
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
runOnUiThread(this::hide);
|
||||
}
|
||||
|
||||
public void show() {
|
||||
wm.addView(root, layoutParams);
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
wm.removeView(root);
|
||||
}
|
||||
|
||||
protected void runOnUiThread(Runnable run) {
|
||||
handler.post(run);
|
||||
}
|
||||
|
||||
public Context getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public LayoutInflater getLayoutInflater() {
|
||||
return LayoutInflater.from(context);
|
||||
}
|
||||
|
||||
protected Handler getHandler() {
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package io.benwiegand.atvremote.receiver.ui;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
import io.benwiegand.atvremote.receiver.util.UiUtil;
|
||||
|
||||
public class NotificationOverlay extends MakeshiftActivity {
|
||||
|
||||
public static final long NOTIFICATION_DURATION = 6000L;
|
||||
public static final float NOTIFICATION_ALPHA = 0.9f;
|
||||
|
||||
// animation constants
|
||||
public static final int FALLBACK_TRANSLATION_X = 1000;
|
||||
public static final int FLY_OUT_DELAY = 100;
|
||||
public static final int FLY_OUT_DURATION_INNER = 300;
|
||||
public static final int FLY_OUT_DURATION_OUTER = 250;
|
||||
public static final int CASCADE_DURATION = 250;
|
||||
public static final long CASCADE_LAG_MULTIPLIER = 100L;
|
||||
|
||||
private final Object lock = new Object();
|
||||
|
||||
|
||||
public NotificationOverlay(Context context) {
|
||||
// todo: system overlay if system
|
||||
super(context, R.layout.layout_notification_overlay, new WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
|
||||
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT));
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
super.start();
|
||||
// todo: manage visibility
|
||||
// hide();
|
||||
}
|
||||
|
||||
private void insertNotification(LinearLayout notificationList, View notification) {
|
||||
runOnUiThread(() -> {
|
||||
synchronized (lock) {
|
||||
animateCascadeLocked(notificationList);
|
||||
|
||||
notification.setAlpha(NOTIFICATION_ALPHA);
|
||||
notificationList.addView(notification, 0);
|
||||
|
||||
animateFlyOutLocked(notification);
|
||||
|
||||
scheduleRemovalLocked(notificationList, notification, NOTIFICATION_DURATION);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void scheduleRemovalLocked(LinearLayout notificationList, View notification, long duration) {
|
||||
getHandler().postDelayed(() -> {
|
||||
synchronized (lock) {
|
||||
notification.getWidth();
|
||||
View outline = notification.findViewById(R.id.background_outline);
|
||||
View background = notification.findViewById(R.id.inner_background);
|
||||
|
||||
outline.animate()
|
||||
.setDuration(FLY_OUT_DURATION_OUTER)
|
||||
.setInterpolator(UiUtil.WIND_UP)
|
||||
.translationX(outline.getWidth())
|
||||
.start();
|
||||
|
||||
background.animate()
|
||||
.setDuration(FLY_OUT_DURATION_INNER)
|
||||
.setInterpolator(UiUtil.EASE_IN)
|
||||
.translationX(background.getWidth())
|
||||
.withEndAction(() -> notificationList.removeView(notification))
|
||||
.start();
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
private void animateCascadeLocked(LinearLayout notificationList) {
|
||||
// cascade existing notifications down
|
||||
View firstSibling = notificationList.getChildAt(0);
|
||||
if (firstSibling == null) return;
|
||||
|
||||
float margin = firstSibling.getY();
|
||||
|
||||
for (int i = 0; i < notificationList.getChildCount(); i++) {
|
||||
View sibling = notificationList.getChildAt(i);
|
||||
boolean alreadyAnimating = sibling.getTranslationY() != 0;
|
||||
|
||||
sibling.setTranslationY(-margin - sibling.getHeight() + sibling.getTranslationY());
|
||||
sibling.animate()
|
||||
.setStartDelay(alreadyAnimating ? 0 : i * CASCADE_LAG_MULTIPLIER)
|
||||
.setInterpolator(alreadyAnimating ? UiUtil.EASE_OUT : UiUtil.EASE_IN_OUT)
|
||||
.setDuration(CASCADE_DURATION)
|
||||
.translationY(0)
|
||||
.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void animateFlyOutLocked(View notification) {
|
||||
// notification fly out
|
||||
View outline = notification.findViewById(R.id.background_outline);
|
||||
View background = notification.findViewById(R.id.inner_background);
|
||||
|
||||
outline.setTranslationX(FALLBACK_TRANSLATION_X);
|
||||
outline.animate()
|
||||
.setStartDelay(FLY_OUT_DELAY)
|
||||
.setDuration(FLY_OUT_DURATION_OUTER)
|
||||
.setInterpolator(UiUtil.EASE_OUT)
|
||||
.withStartAction(() -> outline.setTranslationX(outline.getWidth()))
|
||||
.translationX(0)
|
||||
.start();
|
||||
|
||||
background.setTranslationX(FALLBACK_TRANSLATION_X);
|
||||
background.animate()
|
||||
.setStartDelay(FLY_OUT_DELAY)
|
||||
.setDuration(FLY_OUT_DURATION_INNER)
|
||||
.setInterpolator(UiUtil.EASE_OUT)
|
||||
.withStartAction(() -> {
|
||||
background.setTranslationX(background.getWidth());
|
||||
background.setAlpha(0f);
|
||||
})
|
||||
.translationX(0)
|
||||
.alpha(1f)
|
||||
.start();
|
||||
}
|
||||
|
||||
public void displayNotification(String title, String description, @DrawableRes int icon) {
|
||||
LinearLayout notificationList = root.findViewById(R.id.notification_list);
|
||||
View notification = getLayoutInflater().inflate(R.layout.layout_notification, notificationList, false);
|
||||
|
||||
TextView titleText = notification.findViewById(R.id.notification_title);
|
||||
titleText.setText(title);
|
||||
|
||||
TextView descriptionText = notification.findViewById(R.id.notification_description);
|
||||
descriptionText.setText(description);
|
||||
|
||||
ImageView iconView = notification.findViewById(R.id.notification_icon);
|
||||
iconView.setImageResource(icon);
|
||||
|
||||
insertNotification(notificationList, notification);
|
||||
}
|
||||
|
||||
public void displayNotification(@StringRes int title, @StringRes int description, @DrawableRes int icon) {
|
||||
displayNotification(getContext().getString(title), getContext().getString(description), icon);
|
||||
}
|
||||
|
||||
public void displayNotification(String title, @StringRes int description, @DrawableRes int icon) {
|
||||
displayNotification(title, getContext().getString(description), icon);
|
||||
}
|
||||
|
||||
public void displayNotification(@StringRes int title, String description, @DrawableRes int icon) {
|
||||
displayNotification(getContext().getString(title), description, icon);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package io.benwiegand.atvremote.receiver.ui;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.PixelFormat;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.TextView;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.benwiegand.atvremote.receiver.R;
|
||||
import io.benwiegand.atvremote.receiver.protocol.PairingCallback;
|
||||
import io.benwiegand.atvremote.receiver.util.ByteUtil;
|
||||
|
||||
public class PairingDialog extends MakeshiftActivity {
|
||||
private static final String TAG = PairingDialog.class.getSimpleName();
|
||||
|
||||
private final PairingCallback callback;
|
||||
private final int pairingCode;
|
||||
private final byte[] fingerprint;
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
public PairingDialog(Context context, PairingCallback callback, int pairingCode, byte[] fingerprint) {
|
||||
// todo: use system overlay if system app
|
||||
super(context, R.layout.layout_pairing, new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, 0, PixelFormat.TRANSLUCENT));
|
||||
|
||||
this.pairingCode = pairingCode;
|
||||
this.fingerprint = fingerprint;
|
||||
this.callback = callback;
|
||||
|
||||
runOnUiThread(() -> {
|
||||
updateText();
|
||||
bindButtons();
|
||||
});
|
||||
}
|
||||
|
||||
private void updateText() {
|
||||
TextView fingerprintText = root.findViewById(R.id.certificate_fingerprint_text);
|
||||
fingerprintText.setText(ByteUtil.hexOf(fingerprint));
|
||||
|
||||
TextView fingerprintElevatedText = root.findViewById(R.id.certificate_fingerprint_elevated_text);
|
||||
fingerprintElevatedText.setText("70 D0"); // todo
|
||||
|
||||
// todo: handle starting with a zero
|
||||
TextView pairingCodeText = root.findViewById(R.id.pairing_code_text);
|
||||
pairingCodeText.setText(String.format(Locale.ROOT, "%d", pairingCode));
|
||||
}
|
||||
|
||||
private void bindButtons() {
|
||||
root.findViewById(R.id.cancel_button)
|
||||
.setOnClickListener(v -> callback.cancel());
|
||||
root.findViewById(R.id.cancel_forawhile_button)
|
||||
.setOnClickListener(v -> callback.disablePairingForAWhile(TimeUnit.HOURS, 6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package io.benwiegand.atvremote.receiver.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,19 @@
|
||||
package io.benwiegand.atvremote.receiver.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,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="128dp"
|
||||
android:height="128dp"
|
||||
android:viewportWidth="128"
|
||||
android:viewportHeight="128">
|
||||
<path
|
||||
android:pathData="M0.33,10.36 L0.64,96.72 19.06,78.15 33.45,110.03 48.47,101.83 33.76,70.26h25.54z"
|
||||
android:strokeAlpha="0.8"
|
||||
android:strokeWidth="5.7"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#000000"
|
||||
android:fillAlpha="0.804044"/>
|
||||
<path
|
||||
android:pathData="M2.88,6.98 L3.18,93.35 21.6,74.77 36,106.66 51.01,98.46 36.31,66.88h25.54z"
|
||||
android:strokeWidth="5.7"
|
||||
android:fillColor="#000000"
|
||||
android:strokeColor="#fffffd"/>
|
||||
</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="?android:attr/windowBackground"/>
|
||||
<stroke android:color="?android:attr/windowBackground" android:width="2dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<corners android:bottomLeftRadius="5dp" android:topLeftRadius="5dp"/>
|
||||
<gradient android:startColor="@color/purple_200" android:endColor="@color/purple_700" android:angle="45" />
|
||||
</shape>
|
||||
@@ -0,0 +1,77 @@
|
||||
<?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/debug"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
tools:context=".ui.DebugActivity">
|
||||
|
||||
<Button
|
||||
android:id="@+id/try_bind_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="try bind" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/press_home_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="simulate home" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/show_notifs_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="show notifs" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/show_cursor_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="show fake cursor" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/show_pairing_dialog_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="show pairing dialog" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/show_test_notif_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="show test notification" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/start_server_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="start server" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/accessibility_settings_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="go to accessibility settings" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/debug_info_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
tools:text="some info" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/update_debug_info_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="update debug info" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout 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:fitsSystemWindows="false">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cursor"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
app:srcCompat="@drawable/mouse" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,68 @@
|
||||
<?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/background_outline"
|
||||
android:theme="@style/Theme.ATVRemoteReceiver.Leanback"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_gravity="end|top"
|
||||
android:gravity="center_vertical|end"
|
||||
android:layout_marginTop="24dp"
|
||||
android:background="@drawable/notification_outline"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/inner_background"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="5dp"
|
||||
android:background="@drawable/notification_background"
|
||||
>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/notification_icon"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:contentDescription="icon"
|
||||
tools:srcCompat="@drawable/lb_ic_more"
|
||||
android:layout_margin="12dp"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginEnd="12dp"
|
||||
>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_title"
|
||||
style="@style/TextAppearance.Leanback.DetailsDescriptionSubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Some Phone connected"
|
||||
android:lines="1"
|
||||
android:ellipsize="marquee"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/notification_description"
|
||||
style="@style/TextAppearance.Leanback.DetailsDescriptionBody"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="10.4.20.69"
|
||||
android:lines="1"
|
||||
android:ellipsize="marquee"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/notification_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,127 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout 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:background="@color/overlay_background"
|
||||
android:theme="@style/Theme.ATVRemoteReceiver.Leanback"
|
||||
>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/lb_basic_card_bg_color"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="24dp"
|
||||
>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp"
|
||||
>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.Title"
|
||||
android:text="@string/title_pairing_popup"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.Header"
|
||||
android:textAllCaps="false"
|
||||
android:text="@string/label_pairing_popup_code"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pairing_code_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.DetailsDescriptionTitle"
|
||||
tools:text="42069"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.Header"
|
||||
android:text="@string/label_pairing_popup_fingerprint"
|
||||
/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="6dp"
|
||||
android:padding="8dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:background="@color/lb_basic_card_info_bg_color"
|
||||
>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/certificate_fingerprint_elevated_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.DetailsDescriptionSubtitle"
|
||||
tools:text="de ad de ad be ef"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/certificate_fingerprint_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="420dp"
|
||||
android:textAlignment="center"
|
||||
android:textAppearance="@style/TextAppearance.Leanback.DetailsDescriptionBody"
|
||||
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>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
>
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancel_button"
|
||||
style="@style/Widget.Leanback.DetailsActionButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/button_cancel"
|
||||
/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/cancel_forawhile_button"
|
||||
style="@style/Widget.Leanback.DetailsActionButtonStyle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/button_pairing_popup_disable_for_time"
|
||||
/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -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,16 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.ATVRemoteReceiver" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?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="overlay_background">#DD000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,23 @@
|
||||
<resources>
|
||||
<string name="app_name">A TV Remote Receiver</string>
|
||||
<string name="accessibility_input_service_label">A TV Remote Inputs</string>
|
||||
<string name="accessibility_input_service_description">Allows control of your device via a remote client</string>
|
||||
|
||||
<!-- general ui -->
|
||||
<string name="button_cancel">Cancel</string>
|
||||
|
||||
<!-- pairing popup -->
|
||||
<string name="title_pairing_popup">pair remote</string>
|
||||
<string name="label_pairing_popup_code">Pairing code</string>
|
||||
<string name="label_pairing_popup_fingerprint">Fingerprint (make sure it matches!)</string>
|
||||
<string name="button_pairing_popup_disable_for_time">disable pairing for 6 hours</string>
|
||||
|
||||
<!-- notifications -->
|
||||
<string name="notification_pairing_complete_title">pairing complete</string>
|
||||
<string name="notification_pairing_complete_description">new device successfully paired</string>
|
||||
|
||||
<string name="notification_pairing_failed_title">pairing failed</string>
|
||||
<string name="notification_pairing_failed_description_invalid_code">invalid pairing code</string>
|
||||
|
||||
<string name="notification_remote_connected_title">remote connected</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,20 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.ATVRemoteReceiver" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.ATVRemoteReceiver.Leanback" parent="@style/Theme.Leanback">
|
||||
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/accessibility_input_service_description"
|
||||
android:accessibilityEventTypes=""
|
||||
android:accessibilityFlags="flagDefault"
|
||||
android:canPerformGestures="true"
|
||||
android:accessibilityFeedbackType="feedbackSpoken"
|
||||
android:notificationTimeout="100"
|
||||
android:canRetrieveWindowContent="true"
|
||||
/>
|
||||
@@ -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.receiver;
|
||||
|
||||
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,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="128"
|
||||
height="128"
|
||||
viewBox="0 0 128 128"
|
||||
sodipodi:docname="mouse.svg"
|
||||
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
|
||||
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">
|
||||
<defs
|
||||
id="defs1">
|
||||
<filter
|
||||
inkscape:collect="always"
|
||||
style="color-interpolation-filters:sRGB"
|
||||
id="filter1"
|
||||
x="-0.18224973"
|
||||
y="-0.14903165"
|
||||
width="1.4311464"
|
||||
height="1.2678943">
|
||||
<feGaussianBlur
|
||||
inkscape:collect="always"
|
||||
stdDeviation="3.2800746"
|
||||
id="feGaussianBlur1" />
|
||||
</filter>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="3.7515678"
|
||||
inkscape:cx="58.375594"
|
||||
inkscape:cy="102.49048"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1010"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g1" />
|
||||
<path
|
||||
style="mix-blend-mode:normal;fill:none;fill-opacity:0.804044;stroke:#000000;stroke-width:5.7;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-opacity:0.8;paint-order:normal;filter:url(#filter1)"
|
||||
d="M 0.33171173,10.35716 0.64126373,96.722339 19.059644,78.149179 33.45384,110.0331 48.467141,101.82996 33.763393,70.255589 h 25.538089 z"
|
||||
id="path1-3"
|
||||
sodipodi:nodetypes="cccccccc" />
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Image"
|
||||
id="g1">
|
||||
<path
|
||||
style="fill:#000000;fill-opacity:1;stroke:#fffffd;stroke-width:5.7;stroke-miterlimit:7.1;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 2.8750457,6.9826909 3.1845977,93.347867 21.602978,74.774711 35.997174,106.65863 51.010475,98.455489 36.306727,66.88112 h 25.538089 z"
|
||||
id="path1"
|
||||
sodipodi:nodetypes="cccccccc" />
|
||||
</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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,24 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
appcompat = "1.7.0"
|
||||
material = "1.12.0"
|
||||
activity = "1.10.1"
|
||||
leanback = "1.0.0"
|
||||
bouncycastle = "1.45"
|
||||
|
||||
[libraries]
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
|
||||
androidx-leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" }
|
||||
bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk16", version.ref = "bouncycastle" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#Thu Mar 27 10:16:54 PDT 2025
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,23 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "A TV Remote Receiver"
|
||||
include ':app'
|
||||