initial commit

This commit is contained in:
Benjamin Wiegand
2025-04-24 14:23:50 -07:00
parent 39313db95f
commit cace7cd8ae
72 changed files with 4042 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Log/OS Files
*.log
# Android Studio generated files and folders
captures/
.externalNativeBuild/
.cxx/
*.apk
output.json
# IntelliJ
*.iml
.idea/
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
# Keystore files
*.jks
*.keystore
# Android Profiling
*.hprof
+1
View File
@@ -0,0 +1 @@
/build
+41
View File
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package io.benwiegand.atvremote.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());
}
}
+58
View File
@@ -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>
+18
View File
@@ -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>
+14
View File
@@ -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>
+127
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+16
View File
@@ -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>
+12
View File
@@ -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>
+23
View File
@@ -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>
+20
View File
@@ -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"
/>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package io.benwiegand.atvremote.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);
}
}
+66
View File
@@ -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

+4
View File
@@ -0,0 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}
+21
View File
@@ -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
+24
View File
@@ -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" }
Binary file not shown.
+6
View File
@@ -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
Vendored Executable
+185
View File
@@ -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" "$@"
Vendored
+89
View File
@@ -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
+23
View File
@@ -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'